@adecore/agents 0.23.0 → 0.24.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 (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,19 +1,17 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
3
- import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure, uiHasFence, uiMayReferenceHost } from '@adecore/intelligent-ui';
4
- import { UiStream } from '@adecore/intelligent-ui/stream';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { HOST_TASK_ORIGIN, notResumedNote } from '@adecore/agent-contracts';
3
+ import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure } from '@adecore/intelligent-ui';
5
4
  import { commandLabel, isBackgroundWork, reportsOnBackgroundWork, runningInBackground } from './background-work.js';
6
5
  import { ChatError } from './errors.js';
7
6
  import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.js';
8
7
  import { isMainAgentOutput, ThreadProjector } from './projector.js';
9
8
  import { readableAssistantText } from './readable-text.js';
10
9
  import { ChatThread } from './thread.js';
10
+ import { UiReplies } from './ui-replies.js';
11
11
  import { errorText } from '../error-text.js';
12
12
  import { systemClock } from '../outbox/outbox-worker.js';
13
13
  // Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
14
14
  const TITLE_RECHECK_MS = 10_000;
15
- // Longer than any fence line or the name of a tag a scan looks for, so a match never falls between two deltas.
16
- const UI_SCAN_OVERLAP = 64;
17
15
  /* One per limited turn, so a second fork of the same turn writes no second note. */
18
16
  function continuedNoteId(turnId) {
19
17
  return `continued-${turnId}`;
@@ -79,37 +77,32 @@ export class ChatSession {
79
77
  pendingPreambles;
80
78
  submission = null;
81
79
  steering = new Set();
82
- uiStreams = new Map();
83
- uiCompiled = new Map();
84
80
  capturedUiAccess;
85
- // How much of a streaming reply's text the fence and host scans already covered, so a delta scans only its tail.
86
- uiScanned = new Map();
87
- uiReferencing = new Set();
88
- uiAttachments = new Map();
89
- uiFenceLanguage;
81
+ uiReplies;
90
82
  constructor(options) {
91
83
  this.options = options;
92
- this.uiFenceLanguage = options.uiFenceLanguage ?? UI_FENCE_LANGUAGE;
93
84
  this.pendingPreambles = options.preambles ?? [];
94
85
  this.capturedUiAccess = new Map(Object.entries(options.uiAccess ?? {}));
95
86
  this.thread = new ChatThread(options.info, options.items);
96
87
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
88
+ this.uiReplies = new UiReplies({
89
+ thread: this.thread,
90
+ fenceLanguage: options.uiFenceLanguage ?? UI_FENCE_LANGUAGE,
91
+ clock: options.clock ?? systemClock,
92
+ ...(options.ui ? { host: options.ui } : {}),
93
+ emit: (event) => options.emit(event),
94
+ repair: (note) => {
95
+ if (!this.pendingPreambles.includes(note)) {
96
+ this.pendingPreambles = [...this.pendingPreambles, note];
97
+ }
98
+ }
99
+ });
97
100
  }
98
101
  get uiPreviews() {
99
- const events = [];
100
- for (const [itemId, preview] of this.uiCompiled) {
101
- const item = this.thread.get(itemId);
102
- if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
103
- events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
104
- }
105
- }
106
- return events;
102
+ return this.uiReplies.previews();
107
103
  }
108
104
  previewItems(items) {
109
- return items.map((item) => {
110
- const preview = this.uiCompiled.get(item.id);
111
- return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
112
- });
105
+ return this.uiReplies.previewItems(items);
113
106
  }
114
107
  get id() {
115
108
  return this.thread.info.chatId;
@@ -131,7 +124,35 @@ export class ChatSession {
131
124
  get busy() {
132
125
  const turnId = this.thread.info.activeTurnId;
133
126
  const turn = turnId ? this.thread.get(turnId) : undefined;
134
- return turnId !== null && (turn?.kind !== 'turn' || turn.origin !== 'agent');
127
+ // 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.
128
+ return turnId !== null && (turn?.kind !== 'turn' || turn.origin !== 'agent' || this.goalWorks());
129
+ }
130
+ /* Whether the CLI works toward a goal, which goes on in turns of its own until the goal is met, paused or cleared. */
131
+ goalWorks() {
132
+ return this.thread.info.goal?.status === 'active';
133
+ }
134
+ /*
135
+ * Pauses, resumes or clears the chat's goal. A CLI with calls for it takes them at once, even while a goal
136
+ * turn runs; resuming an idle goal and clearing a goal of a CLI without them go out as `/goal` in a turn of
137
+ * their own, which a goal turn still running is in the way of.
138
+ */
139
+ async goal(action) {
140
+ const supported = this.options.provider.capabilities.goals;
141
+ if (supported === undefined || (supported === 'command' && action !== 'clear')) {
142
+ throw new ChatError('chat-unsupported', `${this.options.provider.name} cannot ${action} a goal`);
143
+ }
144
+ if (this.thread.info.goal === undefined) {
145
+ throw new ChatError('goal-not-found', 'This chat has no goal');
146
+ }
147
+ const backend = this.backend;
148
+ if (supported === 'native' && backend?.goal && backend.running && (action !== 'resume' || this.busy)) {
149
+ await backend.goal(action);
150
+ return;
151
+ }
152
+ if (this.busy) {
153
+ throw new ChatError('chat-busy', 'Stop the turn that works toward the goal first');
154
+ }
155
+ this.send(`/goal ${action}`);
135
156
  }
136
157
  /* The next process uses these settings; live work keeps the process it started in. */
137
158
  configure(patch) {
@@ -303,6 +324,8 @@ export class ChatSession {
303
324
  if (extras.delivery !== 'steer' ||
304
325
  this.info.queuePaused ||
305
326
  text.startsWith('/') ||
327
+ // A goal runs as turns of its own; a message waits for the one running instead of joining it.
328
+ this.goalWorks() ||
306
329
  !this.busy ||
307
330
  !turnId ||
308
331
  !backend?.steerTurn ||
@@ -435,6 +458,10 @@ export class ChatSession {
435
458
  dispatch(text, extras, requestedTurnId) {
436
459
  this.pauseQueue(false);
437
460
  this.settleAgentTurn();
461
+ // A goal that was met has said what it had to; the next message of the person leaves it behind.
462
+ if (this.thread.info.goal?.status === 'complete') {
463
+ this.emit([this.thread.patchInfo({ goal: undefined })]);
464
+ }
438
465
  const { preamble, note } = this.contextNote(text);
439
466
  // A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
440
467
  const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
@@ -552,6 +579,20 @@ export class ChatSession {
552
579
  this.emit([this.thread.upsert({ ...item, state: 'dismissed' }), this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
553
580
  return true;
554
581
  }
582
+ /*
583
+ * Marks the chat as waiting on a person for something the host asks beside the CLI, such as a
584
+ * card a verb holds its call for. The chat reads `needs-you` until every key is released.
585
+ */
586
+ holdForPerson(key) {
587
+ if (this.thread.hold(key)) {
588
+ this.emit([this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
589
+ }
590
+ }
591
+ releaseHold(key) {
592
+ if (this.thread.release(key) && this.thread.info.status === 'needs-you') {
593
+ this.emit([this.thread.setStatus(this.thread.statusFor(this.thread.info.activeTurnId))]);
594
+ }
595
+ }
555
596
  /*
556
597
  * Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
557
598
  * The thread resets at once; the returned promise lets the host wait for pending attachment
@@ -588,6 +629,7 @@ export class ChatSession {
588
629
  queuePaused: false,
589
630
  background: [],
590
631
  delegating: undefined,
632
+ goal: undefined,
591
633
  effectiveRuntimeMode: undefined,
592
634
  permissionMode: undefined,
593
635
  slashCommands: [],
@@ -957,7 +999,7 @@ export class ChatSession {
957
999
  for (const item of this.thread.list()) {
958
1000
  const settled = item.kind === 'turn' && item.state === 'running'
959
1001
  ? { ...item, state: 'aborted', endedAt: now }
960
- : item.kind === 'subagent' && item.origin !== 'ruimte' && item.status === 'running'
1002
+ : item.kind === 'subagent' && item.origin !== HOST_TASK_ORIGIN && item.status === 'running'
961
1003
  ? { ...item, status: 'failed', finishedAt: now }
962
1004
  : settleStoredItem(item, null);
963
1005
  if (settled !== item) {
@@ -979,9 +1021,29 @@ export class ChatSession {
979
1021
  this.emit(events);
980
1022
  this.options.persist();
981
1023
  }
1024
+ /*
1025
+ * Ends the CLI and keeps the conversation: the next message starts a fresh process that resumes it,
1026
+ * and with it the skills, plugins and servers the CLI reads at its start. Refused while a turn runs or
1027
+ * the process still holds work, a request or a background command, since those would end with it.
1028
+ */
1029
+ restart() {
1030
+ if (this.thread.info.activeTurnId !== null || this.processInUse()) {
1031
+ throw new ChatError('chat-busy', `Chat ${this.id} is still working; stop it before restarting its agent session`);
1032
+ }
1033
+ this.clearStartWarning();
1034
+ this.clearWorkflowWarnings();
1035
+ const backend = this.backend;
1036
+ this.backend = null;
1037
+ this.starting = null;
1038
+ this.launchedSelection = null;
1039
+ // What the init frame of the process that goes announced; a skill added since would be filtered out by it.
1040
+ this.emit([this.thread.patchInfo({ running: false, effectiveRuntimeMode: undefined, permissionMode: undefined, skills: undefined })]);
1041
+ this.options.persist();
1042
+ return backend?.dispose() ?? Promise.resolve();
1043
+ }
982
1044
  /* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
983
1045
  freeze() {
984
- this.clearUi();
1046
+ this.uiReplies.clear();
985
1047
  this.clearStartWarning();
986
1048
  this.clearWorkflowWarnings();
987
1049
  this.frozen = true;
@@ -989,7 +1051,7 @@ export class ChatSession {
989
1051
  }
990
1052
  /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
991
1053
  dispose() {
992
- this.clearUi();
1054
+ this.uiReplies.clear();
993
1055
  this.clearStartWarning();
994
1056
  this.clearWorkflowWarnings();
995
1057
  if (this.titleTimer !== null) {
@@ -1146,7 +1208,9 @@ export class ChatSession {
1146
1208
  }
1147
1209
  /* Marks every running subagent of the CLI's own as stopped, for a person stopping the turn together with them. */
1148
1210
  markSubagentsStopped() {
1149
- this.markStopped(this.thread.list().filter((item) => item.kind === 'subagent' && item.status === 'running' && item.origin !== 'ruimte'));
1211
+ this.markStopped(this.thread
1212
+ .list()
1213
+ .filter((item) => item.kind === 'subagent' && item.status === 'running' && item.origin !== HOST_TASK_ORIGIN));
1150
1214
  }
1151
1215
  markStopped(rows) {
1152
1216
  if (rows.length === 0) {
@@ -1424,7 +1488,7 @@ export class ChatSession {
1424
1488
  return runningInBackground(this.thread.list()).length > 0 || (this.info.background?.length ?? 0) > 0;
1425
1489
  }
1426
1490
  processInUse() {
1427
- return this.hasNativeWork() || this.thread.pending().length > 0 || this.awaitingBackgroundReport;
1491
+ return this.hasNativeWork() || this.thread.pending().length > 0 || this.awaitingBackgroundReport || this.goalWorks();
1428
1492
  }
1429
1493
  replacementWaits() {
1430
1494
  return this.requiresReplacement() && (this.info.activeTurnId !== null || this.processInUse());
@@ -1469,6 +1533,7 @@ export class ChatSession {
1469
1533
  saveGeneratedImage: this.options.saveGeneratedImage,
1470
1534
  instructions: this.options.instructions?.() ?? null,
1471
1535
  resumeNote: this.options.resumeNote?.() ?? null,
1536
+ ...(info.goal === undefined ? {} : { goal: info.goal }),
1472
1537
  ...(folders.length > 0 ? { folders } : {}),
1473
1538
  ...(this.options.spawn ? { spawn: this.options.spawn } : {})
1474
1539
  };
@@ -1594,7 +1659,7 @@ export class ChatSession {
1594
1659
  }
1595
1660
  this.emit(projected);
1596
1661
  this.trackSubagentOwner(generation, event);
1597
- this.watchWorkflows(event);
1662
+ this.watchWorkflows(generation, event);
1598
1663
  if (isMainAgentOutput(event)) {
1599
1664
  this.spokenTurnId = this.thread.info.activeTurnId;
1600
1665
  }
@@ -1660,48 +1725,54 @@ export class ChatSession {
1660
1725
  }
1661
1726
  this.workflowWarnings.clear();
1662
1727
  }
1663
- watchWorkflows(event) {
1664
- if (!['tool.started', 'tool.progress', 'tool.output', 'tool.done', 'workflow.progress', 'task.started', 'task.done', 'exit', 'failed'].includes(event.type)) {
1728
+ watchWorkflows(generation, event) {
1729
+ if (event.type === 'tool.output' || event.type === 'tool.progress') {
1730
+ // These come by the thousand, and only the call they name can be a workflow they move.
1731
+ const item = this.projector.itemOf(generation, event.ref);
1732
+ if (item?.kind === 'tool' && item.name === 'Workflow') {
1733
+ this.watchWorkflow(item, true);
1734
+ }
1735
+ return;
1736
+ }
1737
+ if (!['tool.started', 'tool.done', 'workflow.progress', 'task.started', 'task.done', 'exit', 'failed'].includes(event.type)) {
1665
1738
  return;
1666
1739
  }
1667
- const clock = this.options.clock ?? systemClock;
1668
1740
  const ref = 'ref' in event ? event.ref : null;
1669
1741
  for (const item of this.thread.list()) {
1670
- if (item.kind !== 'tool' || item.name !== 'Workflow') {
1671
- continue;
1742
+ if (item.kind === 'tool' && item.name === 'Workflow') {
1743
+ this.watchWorkflow(item, ref === item.toolUseId && ['workflow.progress', 'tool.started', 'tool.done', 'task.started'].includes(event.type));
1672
1744
  }
1673
- const existing = this.workflowWarnings.get(item.id);
1674
- const progress = ref === item.toolUseId &&
1675
- ['workflow.progress', 'tool.started', 'tool.progress', 'tool.output', 'tool.done', 'task.started'].includes(event.type);
1676
- if (item.state !== 'running' || progress) {
1677
- if (existing !== undefined) {
1678
- clock.clearTimeout(existing);
1679
- this.workflowWarnings.delete(item.id);
1680
- }
1681
- }
1682
- if (item.state !== 'running' || (!progress && existing !== undefined) || (!progress && item.workflow?.stalledAt !== undefined)) {
1683
- continue;
1684
- }
1685
- if (progress) {
1686
- this.emit([
1687
- this.thread.upsert({
1688
- ...item,
1689
- workflow: { name: null, phases: [], agents: [], ...item.workflow, lastProgressAt: clock.now(), stalledAt: undefined }
1690
- })
1691
- ]);
1692
- }
1693
- this.workflowWarnings.set(item.id, clock.setTimeout(() => {
1745
+ }
1746
+ }
1747
+ watchWorkflow(item, progress) {
1748
+ const clock = this.options.clock ?? systemClock;
1749
+ const existing = this.workflowWarnings.get(item.id);
1750
+ if (item.state !== 'running' || progress) {
1751
+ if (existing !== undefined) {
1752
+ clock.clearTimeout(existing);
1694
1753
  this.workflowWarnings.delete(item.id);
1695
- const current = this.thread.get(item.id);
1696
- if (this.frozen || current?.kind !== 'tool' || current.state !== 'running') {
1697
- return;
1698
- }
1699
- this.emit([
1700
- this.thread.upsert({ ...current, workflow: { name: null, phases: [], agents: [], ...current.workflow, stalledAt: clock.now() } })
1701
- ]);
1702
- this.options.persist();
1703
- }, 5 * 60_000));
1754
+ }
1755
+ }
1756
+ if (item.state !== 'running' || (!progress && existing !== undefined) || (!progress && item.workflow?.stalledAt !== undefined)) {
1757
+ return;
1704
1758
  }
1759
+ if (progress) {
1760
+ this.emit([
1761
+ this.thread.upsert({
1762
+ ...item,
1763
+ workflow: { name: null, phases: [], agents: [], ...item.workflow, lastProgressAt: clock.now(), stalledAt: undefined }
1764
+ })
1765
+ ]);
1766
+ }
1767
+ this.workflowWarnings.set(item.id, clock.setTimeout(() => {
1768
+ this.workflowWarnings.delete(item.id);
1769
+ const current = this.thread.get(item.id);
1770
+ if (this.frozen || current?.kind !== 'tool' || current.state !== 'running') {
1771
+ return;
1772
+ }
1773
+ this.emit([this.thread.upsert({ ...current, workflow: { name: null, phases: [], agents: [], ...current.workflow, stalledAt: clock.now() } })]);
1774
+ this.options.persist();
1775
+ }, 5 * 60_000));
1705
1776
  }
1706
1777
  /* Whether the CLI that reports was started on a pick standing for the same window as the chat's own. */
1707
1778
  reportsCurrentWindow() {
@@ -1802,121 +1873,9 @@ export class ChatSession {
1802
1873
  }
1803
1874
  emit(events) {
1804
1875
  const delegating = this.delegatingChange(events);
1805
- for (let event of delegating === null ? events : [...events, delegating]) {
1806
- if (event.type === 'reset') {
1807
- this.clearUi();
1808
- }
1809
- if (event.type === 'item' && event.item.kind === 'tool') {
1810
- this.uiAttachments.delete(event.item.turnId);
1811
- }
1812
- const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
1813
- if (item?.kind !== 'assistant' || item.parentToolUseId) {
1814
- this.options.emit(event);
1815
- continue;
1816
- }
1817
- const tail = this.unscannedUiText(item, event.type === 'item');
1818
- if (uiMayReferenceHost(tail)) {
1819
- this.uiReferencing.add(item.id);
1820
- }
1821
- let stream = this.uiStreams.get(item.id);
1822
- if (!stream && !uiHasFence(tail, this.uiFenceLanguage)) {
1823
- this.forgetUiScan(item);
1824
- this.options.emit(event);
1825
- continue;
1826
- }
1827
- if (!stream) {
1828
- stream = new UiStream({
1829
- querySchemas: this.options.ui?.schemas,
1830
- fenceLanguage: this.uiFenceLanguage,
1831
- id: item.id,
1832
- clock: this.options.clock ?? systemClock,
1833
- emit: (preview) => {
1834
- this.uiCompiled.set(item.id, preview);
1835
- this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
1836
- }
1837
- });
1838
- this.uiStreams.set(item.id, stream);
1839
- }
1840
- if (this.uiReferencing.has(item.id)) {
1841
- this.options.ui?.observe(item, false);
1842
- }
1843
- this.forgetUiScan(item);
1844
- const latestAttachment = this.latestUiAttachment(item.turnId);
1845
- if (event.type === 'item' && !item.streaming) {
1846
- const ui = stream
1847
- .finish(item.text, latestAttachment)
1848
- .map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
1849
- this.uiStreams.delete(item.id);
1850
- this.uiCompiled.delete(item.id);
1851
- const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
1852
- if (diagnostics.length > 0) {
1853
- const repair = `Your last ${this.uiFenceLanguage} block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(0, 1200);
1854
- if (!this.pendingPreambles.includes(repair)) {
1855
- this.pendingPreambles = [...this.pendingPreambles, repair];
1856
- }
1857
- }
1858
- const current = this.thread.get(item.id);
1859
- const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
1860
- event = this.thread.upsert(completed);
1861
- this.options.emit(event);
1862
- this.options.ui?.observe(completed, true);
1863
- }
1864
- else {
1865
- this.options.emit(event);
1866
- stream.update(item.text, latestAttachment);
1867
- }
1868
- }
1869
- }
1870
- /*
1871
- * The text a scan has not covered yet, from the start of the line the last scan ended in, since a fence
1872
- * is one line; a long line is cut short, behind a character no fence or tag can follow.
1873
- */
1874
- unscannedUiText(item, whole) {
1875
- const scanned = whole ? 0 : (this.uiScanned.get(item.id) ?? 0);
1876
- this.uiScanned.set(item.id, item.text.length);
1877
- if (scanned === 0 || scanned > item.text.length) {
1878
- return item.text;
1879
- }
1880
- const line = item.text.lastIndexOf('\n', scanned - 1) + 1;
1881
- const from = Math.max(line, scanned - UI_SCAN_OVERLAP);
1882
- return from === line ? item.text.slice(from) : `\0${item.text.slice(from)}`;
1883
- }
1884
- forgetUiScan(item) {
1885
- if (!item.streaming) {
1886
- this.uiScanned.delete(item.id);
1887
- this.uiReferencing.delete(item.id);
1888
- }
1889
- }
1890
- /* Looked up once per turn, and again only after one of its tool calls changed. */
1891
- latestUiAttachment(turnId) {
1892
- if (this.uiAttachments.has(turnId)) {
1893
- return this.uiAttachments.get(turnId);
1894
- }
1895
- const attachment = this.findUiAttachment(turnId);
1896
- this.uiAttachments.set(turnId, attachment);
1897
- return attachment;
1898
- }
1899
- findUiAttachment(turnId) {
1900
- for (const item of this.thread.list().toReversed()) {
1901
- if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
1902
- continue;
1903
- }
1904
- const input = GeneratedImageInputSchema.safeParse(item.input);
1905
- if (input.success && input.data.attachment?.mime.startsWith('image/')) {
1906
- return input.data.attachment.id;
1907
- }
1908
- }
1909
- return undefined;
1910
- }
1911
- clearUi() {
1912
- for (const stream of this.uiStreams.values()) {
1913
- stream.dispose();
1876
+ for (const event of delegating === null ? events : [...events, delegating]) {
1877
+ this.uiReplies.pass(event);
1914
1878
  }
1915
- this.uiStreams.clear();
1916
- this.uiCompiled.clear();
1917
- this.uiScanned.clear();
1918
- this.uiReferencing.clear();
1919
- this.uiAttachments.clear();
1920
1879
  }
1921
1880
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
1922
1881
  delegatingChange(events) {
@@ -33,6 +33,7 @@ export declare class ChatStore {
33
33
  writeSync(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras, uiAccess?: Readonly<Record<string, unknown>>): void;
34
34
  read(chatId: string): Promise<ChatRecord | null>;
35
35
  unreadable(chatId: string): Promise<boolean>;
36
+ settled(chatId: string): Promise<boolean>;
36
37
  private readSnapshot;
37
38
  delete(chatId: string): Promise<void>;
38
39
  }
@@ -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 } from '@adecore/agent-contracts';
5
5
  import { z } from 'zod';
@@ -30,6 +30,7 @@ function withoutOwnKeys(record) {
30
30
  function logName(chatId) {
31
31
  return `${encodeURIComponent(chatId)}.log`;
32
32
  }
33
+ // `settled` reads the info from the head of the record, so it stays the first key.
33
34
  function recordBody(info, items, at, preambles, extras, uiAccess) {
34
35
  return JSON.stringify({
35
36
  info,
@@ -154,6 +155,18 @@ export class ChatStore {
154
155
  async unreadable(chatId) {
155
156
  return (await this.readSnapshot(chatId)) === UNREADABLE;
156
157
  }
158
+ /*
159
+ * Whether the chat has neither a turn in flight nor a resume after a limit, by the info its record
160
+ * starts with and the last one its log wrote, without parsing the thread. False whenever that
161
+ * cannot be told so cheaply, which leaves the decision to `read`.
162
+ */
163
+ async settled(chatId) {
164
+ if (!settledInfo(await leadingInfo(join(this.dir, recordFileName(chatId))))) {
165
+ return false;
166
+ }
167
+ const last = lastLoggedInfo((await readOrNull(this.logPath(chatId))) ?? '');
168
+ return last === undefined || settledInfo(last);
169
+ }
157
170
  async readSnapshot(chatId) {
158
171
  const raw = await readOrNull(join(this.dir, recordFileName(chatId)));
159
172
  if (raw === null) {
@@ -182,6 +195,97 @@ export class ChatStore {
182
195
  }
183
196
  }
184
197
  const UNREADABLE = Symbol('unreadable');
198
+ const RECORD_HEAD = Buffer.from('{"info":{');
199
+ const HEAD_CHUNK_BYTES = 16384;
200
+ const QUOTE = 0x22;
201
+ const BACKSLASH = 0x5c;
202
+ const OPEN_BRACE = 0x7b;
203
+ const CLOSE_BRACE = 0x7d;
204
+ /*
205
+ * The info a record starts with, read only as far as it goes; undefined for a record that is missing,
206
+ * starts otherwise (an older or hand-edited one) or ends before its info does. Scanning bytes is safe
207
+ * in UTF-8, where no byte of a multibyte character equals a quote, a backslash or a brace.
208
+ */
209
+ async function leadingInfo(path) {
210
+ let handle;
211
+ try {
212
+ handle = await open(path, 'r');
213
+ }
214
+ catch (e) {
215
+ if (isNotFound(e)) {
216
+ return undefined;
217
+ }
218
+ throw e;
219
+ }
220
+ try {
221
+ let head = Buffer.alloc(0);
222
+ let at = RECORD_HEAD.length;
223
+ let depth = 1;
224
+ let inString = false;
225
+ let escaped = false;
226
+ const chunk = Buffer.alloc(HEAD_CHUNK_BYTES);
227
+ let { bytesRead } = await handle.read(chunk, 0, chunk.length, 0);
228
+ while (bytesRead > 0) {
229
+ head = Buffer.concat([head, chunk.subarray(0, bytesRead)]);
230
+ if (!head.subarray(0, RECORD_HEAD.length).equals(RECORD_HEAD.subarray(0, Math.min(head.length, RECORD_HEAD.length)))) {
231
+ return undefined;
232
+ }
233
+ for (; at < head.length; at++) {
234
+ const byte = head[at];
235
+ if (escaped) {
236
+ escaped = false;
237
+ }
238
+ else if (inString) {
239
+ escaped = byte === BACKSLASH;
240
+ inString = byte !== QUOTE;
241
+ }
242
+ else if (byte === QUOTE) {
243
+ inString = true;
244
+ }
245
+ else if (byte === OPEN_BRACE) {
246
+ depth += 1;
247
+ }
248
+ else if (byte === CLOSE_BRACE) {
249
+ depth -= 1;
250
+ if (depth === 0) {
251
+ return parseOrUndefined(head.subarray(RECORD_HEAD.length - 1, at + 1).toString('utf8'));
252
+ }
253
+ }
254
+ }
255
+ ({ bytesRead } = await handle.read(chunk, 0, chunk.length, head.length));
256
+ }
257
+ return undefined;
258
+ }
259
+ finally {
260
+ await handle.close();
261
+ }
262
+ }
263
+ /* The info of the last info or reset line in a log, unvalidated; undefined when no line carries one. */
264
+ function lastLoggedInfo(raw) {
265
+ const lines = raw.split('\n');
266
+ for (let i = lines.length - 1; i >= 0; i--) {
267
+ // Most lines are items and deltas, which are larger and never carry this key.
268
+ if (!lines[i].includes('"info"')) {
269
+ continue;
270
+ }
271
+ const event = parseOrUndefined(lines[i])?.event;
272
+ if (event?.type === 'info' || event?.type === 'reset') {
273
+ return event.info;
274
+ }
275
+ }
276
+ return undefined;
277
+ }
278
+ function settledInfo(info) {
279
+ return typeof info === 'object' && info !== null && info.activeTurnId === null && !('resumeAt' in info);
280
+ }
281
+ function parseOrUndefined(text) {
282
+ try {
283
+ return JSON.parse(text);
284
+ }
285
+ catch {
286
+ return undefined;
287
+ }
288
+ }
185
289
  async function exists(path) {
186
290
  try {
187
291
  await stat(path);
@@ -12,6 +12,7 @@ export declare class ClaudeBackend implements ChatBackend {
12
12
  private stdinClosed;
13
13
  private interrupted;
14
14
  private readonly steers;
15
+ private steerTimer;
15
16
  private active;
16
17
  constructor(launch: BackendLaunch, host: BackendHost, options?: ClaudeBackendOptions);
17
18
  get running(): boolean;
@@ -31,7 +32,10 @@ export declare class ClaudeBackend implements ChatBackend {
31
32
  private readLines;
32
33
  private handleLine;
33
34
  private readLine;
35
+ private awaitSteers;
36
+ private clearSteerTimer;
34
37
  private rejectSteers;
38
+ private broken;
35
39
  private failed;
36
40
  private respond;
37
41
  private write;