@adecore/agents 0.21.0 → 0.21.2

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 (105) hide show
  1. package/dist/async.js +3 -8
  2. package/dist/chat/chat-core.d.ts +7 -3
  3. package/dist/chat/chat-core.js +19 -9
  4. package/dist/chat/chat-log.js +2 -2
  5. package/dist/chat/chat-session.d.ts +13 -0
  6. package/dist/chat/chat-session.js +75 -5
  7. package/dist/chat/chat-store.d.ts +3 -2
  8. package/dist/chat/chat-store.js +44 -16
  9. package/dist/chat/chat-title.js +4 -8
  10. package/dist/chat/claude-backend.d.ts +1 -0
  11. package/dist/chat/claude-backend.js +8 -6
  12. package/dist/chat/claude-title.js +1 -1
  13. package/dist/chat/codex-backend.d.ts +2 -2
  14. package/dist/chat/codex-backend.js +5 -8
  15. package/dist/chat/codex-protocol.js +1 -1
  16. package/dist/chat/codex-thread.js +4 -5
  17. package/dist/chat/composer-preferences.js +2 -3
  18. package/dist/chat/limit-resume.js +2 -7
  19. package/dist/chat/readable-text.d.ts +2 -0
  20. package/dist/chat/readable-text.js +16 -0
  21. package/dist/chat/ui-queries.d.ts +12 -1
  22. package/dist/chat/ui-queries.js +107 -60
  23. package/dist/chat/visual-store.js +8 -6
  24. package/dist/context/refusal.js +3 -7
  25. package/dist/context/verb.js +1 -5
  26. package/dist/host/agent-host.js +2 -2
  27. package/dist/host/handlers.js +1 -2
  28. package/dist/host/wiring.js +1 -1
  29. package/dist/lineage.js +2 -3
  30. package/dist/messages/deliver-message.js +6 -11
  31. package/dist/messages/deliver-notice.js +8 -12
  32. package/dist/messages/notice-store.js +3 -5
  33. package/dist/outbox/outbox-worker.js +5 -2
  34. package/dist/providers/accounts/service.d.ts +2 -1
  35. package/dist/providers/accounts/service.js +11 -13
  36. package/dist/providers/claude.js +0 -1
  37. package/dist/providers/detect.js +2 -10
  38. package/dist/record-directory.js +0 -1
  39. package/dist/tasks/task-coordinator.d.ts +1 -0
  40. package/dist/tasks/task-coordinator.js +40 -36
  41. package/dist/tasks/task-store.js +1 -1
  42. package/dist/tasks/task-work.js +2 -2
  43. package/dist/tasks/waiting-child.js +5 -3
  44. package/dist/tasks/wake-parent.js +15 -10
  45. package/dist/tasks/wiring.js +32 -37
  46. package/dist/usage/aggregate.js +62 -46
  47. package/dist/usage/exchange.js +1 -1
  48. package/dist/usage/limits/monitor.d.ts +2 -2
  49. package/dist/usage/limits/monitor.js +2 -2
  50. package/dist/usage/limits/probe.js +1 -1
  51. package/dist/usage/pricing.js +1 -1
  52. package/dist/usage/roots.js +1 -2
  53. package/dist/usage/scanner.js +7 -9
  54. package/dist/usage/usage-service.d.ts +1 -2
  55. package/dist/usage/usage-service.js +3 -6
  56. package/package.json +3 -3
  57. package/src/async.ts +3 -9
  58. package/src/chat/chat-core.ts +29 -9
  59. package/src/chat/chat-log.ts +2 -2
  60. package/src/chat/chat-session.ts +85 -5
  61. package/src/chat/chat-store.ts +54 -16
  62. package/src/chat/chat-title.ts +4 -8
  63. package/src/chat/claude-backend.ts +9 -6
  64. package/src/chat/claude-title.ts +1 -1
  65. package/src/chat/claude-transcript.ts +1 -1
  66. package/src/chat/codex-backend.ts +5 -8
  67. package/src/chat/codex-protocol.ts +1 -1
  68. package/src/chat/codex-thread.ts +6 -5
  69. package/src/chat/composer-preferences.ts +2 -3
  70. package/src/chat/limit-resume.ts +2 -7
  71. package/src/chat/readable-text.ts +18 -0
  72. package/src/chat/thread.ts +1 -1
  73. package/src/chat/ui-queries.ts +120 -64
  74. package/src/chat/visual-store.ts +9 -6
  75. package/src/chat/wake-chat.ts +4 -1
  76. package/src/context/refusal.ts +3 -7
  77. package/src/context/verb.ts +1 -5
  78. package/src/host/agent-host.ts +2 -2
  79. package/src/host/handlers.ts +1 -2
  80. package/src/host/wiring.ts +1 -1
  81. package/src/lineage.ts +2 -3
  82. package/src/messages/deliver-message.ts +6 -11
  83. package/src/messages/deliver-notice.ts +8 -12
  84. package/src/messages/notice-store.ts +3 -5
  85. package/src/outbox/outbox-worker.ts +5 -2
  86. package/src/providers/accounts/service.ts +12 -13
  87. package/src/providers/accounts/variables.ts +1 -0
  88. package/src/providers/claude.ts +0 -1
  89. package/src/providers/detect.ts +2 -11
  90. package/src/record-directory.ts +0 -1
  91. package/src/skills.ts +0 -1
  92. package/src/tasks/task-coordinator.ts +41 -35
  93. package/src/tasks/task-store.ts +1 -1
  94. package/src/tasks/task-work.ts +2 -2
  95. package/src/tasks/waiting-child.ts +6 -3
  96. package/src/tasks/wake-parent.ts +21 -16
  97. package/src/tasks/wiring.ts +33 -41
  98. package/src/usage/aggregate.ts +80 -50
  99. package/src/usage/exchange.ts +1 -1
  100. package/src/usage/limits/monitor.ts +4 -4
  101. package/src/usage/limits/probe.ts +2 -2
  102. package/src/usage/pricing.ts +1 -1
  103. package/src/usage/roots.ts +1 -2
  104. package/src/usage/scanner.ts +8 -10
  105. package/src/usage/usage-service.ts +5 -9
@@ -7,10 +7,9 @@ import type {
7
7
  ChatInfo,
8
8
  ChatUiChoicePayload,
9
9
  ChatUiQueryPayload,
10
- ChatUiQueryReading,
11
- ChatUiQueryState
10
+ ChatUiQueryReading
12
11
  } from '@adecore/agent-contracts';
13
- import { copyUiValue, UiBudget, type UiValue } from '@adecore/intelligent-ui';
12
+ import { copyUiValue, UI_HOST_LIMITS, UiBudget, uiMayReferenceHost, type UiValue } from '@adecore/intelligent-ui';
14
13
  import { UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget } from '@adecore/intelligent-ui/links';
15
14
  import { uiQueryArguments, uiQueryFallback } from '@adecore/intelligent-ui/query';
16
15
  import type { UiBlock } from '@adecore/intelligent-ui/compiler';
@@ -36,17 +35,42 @@ interface CachedReading {
36
35
  reading: ChatUiQueryReading;
37
36
  }
38
37
 
38
+ /*
39
+ * A failure a client can say in its own words: `reason` is a code that stays the same across versions,
40
+ * the message is the fallback for a client that does not know the code.
41
+ */
42
+ class UiQueryError extends ChatError<string> {
43
+ readonly reason: string;
44
+
45
+ constructor(code: string, reason: string, message: string) {
46
+ super(code, message);
47
+ this.reason = reason;
48
+ }
49
+ }
50
+
51
+ /*
52
+ * What a host's `authorize`, `read` or `link` throws to say why with a code: `code` reaches the reading's
53
+ * `code` and should stay the same across versions, `reason` is the plain sentence a client shows without it.
54
+ */
55
+ export class ChatUiRefusal extends UiQueryError {
56
+ constructor(code: string, reason: string) {
57
+ super('refused-query', code, reason);
58
+ }
59
+ }
60
+
39
61
  const MAX_QUERY_BYTES = 64 * 1024;
40
62
  const MAX_SNAPSHOT_BYTES = 128 * 1024;
41
- const MAX_QUERIES = 8;
42
- const REFRESH_MS = 10_000;
43
63
  const READ_TIMEOUT_MS = 8_000;
64
+ // A changed input reads at once, but a query never reads more often than this across its inputs.
65
+ const INPUT_READ_MS = 250;
44
66
 
45
67
  export class ChatUiQueries {
46
68
  readonly schemas: Record<string, z.ZodType>;
47
- private readonly captures = new Map<string, Promise<ChatUiQueryState>>();
69
+ private readonly captures = new Map<string, Promise<unknown>>();
48
70
  private readonly freezing = new Set<string>();
49
71
  private readonly cache = new Map<string, CachedReading>();
72
+ // The read id of the newest reading per cache key, which a remount or a second client is answered with.
73
+ private readonly latest = new Map<string, string>();
50
74
  private readonly reading = new Map<string, Promise<ChatUiQueryReading>>();
51
75
  private readonly attempts = new Map<string, number>();
52
76
  private readonly active = new Map<string, number>();
@@ -60,14 +84,16 @@ export class ChatUiQueries {
60
84
  }
61
85
 
62
86
  observe(session: ChatSession, item: ChatAssistantItem, final: boolean): void {
63
- if (!item.text.includes('@Query') && !/<(?:File|Diff|Commit|Node)\b/.test(item.text) && !item.ui?.some((block) => Object.keys(block.queries).length)) {
87
+ const key = `${session.id}:${item.id}`;
88
+ // Once a capture started, every delta of a streaming reply would scan its whole text again for nothing.
89
+ const captured = this.captures.has(key) || item.uiQueries?.authorChatId === session.id;
90
+ if (!captured && !uiMayReferenceHost(item.text) && !item.ui?.some((block) => Object.keys(block.queries).length)) {
64
91
  return;
65
92
  }
66
93
  const capture = this.access(session, item);
67
94
  if (!final || !item.ui?.length) {
68
95
  return;
69
96
  }
70
- const key = `${session.id}:${item.id}`;
71
97
  if (this.freezing.has(key)) {
72
98
  return;
73
99
  }
@@ -84,7 +110,7 @@ export class ChatUiQueries {
84
110
  }
85
111
  const readings: Record<string, ChatUiQueryReading> = Object.create(null);
86
112
  const values: Record<string, unknown> = Object.create(null);
87
- for (const name of Object.keys(block.queries).slice(0, MAX_QUERIES)) {
113
+ for (const name of Object.keys(block.queries).slice(0, UI_HOST_LIMITS.queries)) {
88
114
  const reading = await this.query(session, {
89
115
  chatId: session.id,
90
116
  itemId: item.id,
@@ -101,11 +127,11 @@ export class ChatUiQueries {
101
127
  if (this.host.link) {
102
128
  const targets = uiLinkTargets(block, {}, values);
103
129
  const access = await this.access(session, current);
104
- for (const [id, target] of Object.entries(targets).slice(0, 64)) {
130
+ for (const [id, target] of Object.entries(targets).slice(0, UI_HOST_LIMITS.links)) {
105
131
  try {
106
- links[id] = this.linkReading(await this.host.link(session.info, access.access, target));
132
+ links[id] = this.linkReading(await this.host.link(session.info, access, target));
107
133
  } catch (error) {
108
- links[id] = { state: 'plain', reason: this.reason(error) };
134
+ links[id] = { state: 'plain', ...this.failure(error) };
109
135
  }
110
136
  }
111
137
  }
@@ -131,10 +157,12 @@ export class ChatUiQueries {
131
157
  frozen.readings = Object.fromEntries(
132
158
  Object.keys(readings).map((name) => [
133
159
  name,
134
- { state: 'failed', readAt: Date.now(), reason: 'The frozen query values are too large.' }
160
+ { state: 'failed', readAt: Date.now(), code: 'snapshot-too-large', reason: 'The frozen query values are too large.' }
135
161
  ])
136
162
  );
137
- frozen.links = Object.fromEntries(Object.keys(links).map((id) => [id, { state: 'plain', reason: 'This link must be checked again.' }]));
163
+ frozen.links = Object.fromEntries(
164
+ Object.keys(links).map((id) => [id, { state: 'plain', code: 'link-unchecked', reason: 'This link must be checked again.' }])
165
+ );
138
166
  frozen.fallback = `${block.fallback.slice(0, 2048)}\nSources: ${names}\nThe frozen query values are too large.`;
139
167
  }
140
168
  if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
@@ -147,17 +175,19 @@ export class ChatUiQueries {
147
175
  .finally(() => this.freezing.delete(key));
148
176
  }
149
177
 
150
- private access(session: ChatSession, item: ChatAssistantItem): Promise<ChatUiQueryState> {
178
+ /* The writer's access for a reply, captured once; null where it was denied or is unavailable. */
179
+ private access(session: ChatSession, item: ChatAssistantItem): Promise<unknown> {
151
180
  const key = `${session.id}:${item.id}`;
152
181
  const existing = this.captures.get(key);
153
182
  if (existing) {
154
183
  return existing;
155
184
  }
156
185
  if (item.uiQueries?.authorChatId === session.id) {
157
- return Promise.resolve(item.uiQueries);
186
+ return Promise.resolve(session.uiAccess(item.id) ?? null);
158
187
  }
159
188
  // A pending capture is persisted as denied, so a restart cannot widen the writer's rights.
160
- session.updateUiQueries(item.id, { authorChatId: session.id, access: null, blocks: {} });
189
+ session.setUiAccess(item.id, null);
190
+ session.updateUiQueries(item.id, { authorChatId: session.id, blocks: {} });
161
191
  const captured = this.host
162
192
  .capture(session.info)
163
193
  .then((access) => {
@@ -165,11 +195,10 @@ export class ChatUiQueries {
165
195
  if (Buffer.byteLength(JSON.stringify(captured)) > 32 * 1024) {
166
196
  throw new Error('The UI access snapshot is too large.');
167
197
  }
168
- const state: ChatUiQueryState = { authorChatId: session.id, access: captured, blocks: {} };
169
- session.updateUiQueries(item.id, state);
170
- return state;
198
+ session.setUiAccess(item.id, captured);
199
+ return captured as unknown;
171
200
  })
172
- .catch(() => ({ authorChatId: session.id, access: null, blocks: {} }))
201
+ .catch(() => null)
173
202
  .finally(() => this.captures.delete(key));
174
203
  this.captures.set(key, captured);
175
204
  return captured;
@@ -183,19 +212,19 @@ export class ChatUiQueries {
183
212
  const block =
184
213
  item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
185
214
  if (item?.kind !== 'assistant' || !block?.complete || block.revision !== payload.revision) {
186
- throw new ChatError('stale-ui-block', 'This completed UI block is no longer available.');
215
+ throw new UiQueryError('stale-ui-block', 'block-stale', 'This completed UI block is no longer available.');
187
216
  }
188
217
  return { item, block };
189
218
  }
190
219
 
191
220
  private stored(session: ChatSession, payload: ChatUiQueryPayload): { item: ChatAssistantItem; block: UiBlock; source: ChatUiSource } {
192
221
  const { item, block } = this.storedBlock(session, payload);
193
- if (!Object.hasOwn(block.queries, payload.query) || Object.keys(block.queries).length > MAX_QUERIES) {
194
- throw new ChatError('refused-query', 'This block does not declare an allowed query.');
222
+ if (!Object.hasOwn(block.queries, payload.query) || Object.keys(block.queries).length > UI_HOST_LIMITS.queries) {
223
+ throw new UiQueryError('refused-query', 'query-undeclared', 'This block does not declare an allowed query.');
195
224
  }
196
225
  const name = block.queries[payload.query].source;
197
226
  if (!Object.hasOwn(this.host.sources, name)) {
198
- throw new ChatError('refused-query', 'This query source is not registered.');
227
+ throw new UiQueryError('refused-query', 'source-unregistered', 'This query source is not registered.');
199
228
  }
200
229
  return { item, block, source: this.host.sources[name] };
201
230
  }
@@ -210,18 +239,17 @@ export class ChatUiQueries {
210
239
  ({ source, block, item } = this.stored(session, payload));
211
240
  args = uiQueryArguments(block, payload.query, payload.values);
212
241
  args = source.args.parse(args) as Record<string, UiValue>;
213
- const access = await this.access(session, item);
214
- if (access.access === null) {
215
- throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
242
+ authorAccess = await this.access(session, item);
243
+ if (authorAccess === null) {
244
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
216
245
  }
217
- authorAccess = access.access;
218
246
  await source.authorize(session.info, authorAccess, args);
219
247
  } catch (error) {
220
- return { state: 'refused', readAt: Date.now(), reason: this.reason(error) };
248
+ return { state: 'refused', readAt: Date.now(), ...this.failure(error) };
221
249
  }
222
250
  const key = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query, args]);
223
- const cached = [...this.cache.values()].find((entry) => entry.key === key);
224
- if (cached && Date.now() - cached.reading.readAt < REFRESH_MS) {
251
+ const cached = this.cache.get(this.latest.get(key) ?? '');
252
+ if (cached && Date.now() - cached.reading.readAt < UI_HOST_LIMITS.refreshMilliseconds) {
225
253
  return cached.reading;
226
254
  }
227
255
  const pending = this.reading.get(key);
@@ -229,14 +257,19 @@ export class ChatUiQueries {
229
257
  return pending;
230
258
  }
231
259
  const identity = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query]);
232
- if (Date.now() - (this.attempts.get(identity) ?? 0) < REFRESH_MS) {
233
- return { state: 'failed', readAt: Date.now(), reason: 'This query may refresh once every ten seconds.' };
260
+ const now = Date.now();
261
+ if (
262
+ now - (this.attempts.get(key) ?? -Infinity) < UI_HOST_LIMITS.refreshMilliseconds ||
263
+ now - (this.attempts.get(identity) ?? -Infinity) < INPUT_READ_MS
264
+ ) {
265
+ return { state: 'failed', readAt: now, code: 'refresh-limit', reason: 'This query may refresh once every ten seconds.' };
234
266
  }
235
267
  if ((this.active.get(session.id) ?? 0) >= 2 || this.totalActive >= 16) {
236
- return { state: 'failed', readAt: Date.now(), reason: 'Too many UI queries are being read.' };
268
+ return { state: 'failed', readAt: now, code: 'busy', reason: 'Too many UI queries are being read.' };
237
269
  }
238
- this.attempts.set(identity, Date.now());
239
- while (this.attempts.size > 128) {
270
+ this.attempts.set(key, now);
271
+ this.attempts.set(identity, now);
272
+ while (this.attempts.size > 256) {
240
273
  this.attempts.delete(this.attempts.keys().next().value!);
241
274
  }
242
275
  this.active.set(session.id, (this.active.get(session.id) ?? 0) + 1);
@@ -247,21 +280,26 @@ export class ChatUiQueries {
247
280
  timeout.unref?.();
248
281
  const read = Promise.race([
249
282
  work,
250
- new Promise<never>((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('The UI query timed out.')), { once: true }))
283
+ new Promise<never>((_, reject) =>
284
+ controller.signal.addEventListener('abort', () => reject(new UiQueryError('refused-query', 'timed-out', 'The UI query timed out.')), {
285
+ once: true
286
+ })
287
+ )
251
288
  ])
252
289
  .then((result): ChatUiQueryReading => {
253
290
  const value = copyUiValue(source.result.parse(result), new UiBudget());
254
291
  if (Buffer.byteLength(JSON.stringify(value)) > MAX_QUERY_BYTES) {
255
- throw new Error('The UI query result is too large.');
292
+ throw new UiQueryError('refused-query', 'result-too-large', 'The UI query result is too large.');
256
293
  }
257
294
  const reading: ChatUiQueryReading = { state: 'fresh', value, readId: randomUUID(), readAt: Date.now() };
258
295
  this.cache.set(reading.readId!, { key, chatId: session.id, reading });
296
+ this.latest.set(key, reading.readId!);
259
297
  while (this.cache.size > 128) {
260
- this.cache.delete(this.cache.keys().next().value!);
298
+ this.evict(this.cache.keys().next().value!);
261
299
  }
262
300
  return reading;
263
301
  })
264
- .catch((error): ChatUiQueryReading => ({ state: 'failed', readAt: Date.now(), reason: this.reason(error) }))
302
+ .catch((error): ChatUiQueryReading => ({ state: 'failed', readAt: Date.now(), ...this.failure(error) }))
265
303
  .finally(() => {
266
304
  clearTimeout(timeout);
267
305
  this.reading.delete(key);
@@ -282,32 +320,41 @@ export class ChatUiQueries {
282
320
  return read;
283
321
  }
284
322
 
323
+ /* The issued value of every query the block declares, each checked against the read id the person saw. */
285
324
  async choiceValues(session: ChatSession, payload: ChatUiChoicePayload | ChatUiLinkPayload): Promise<Record<string, unknown>> {
286
325
  const item = session.thread.get(payload.itemId);
287
326
  const block = item?.kind === 'assistant' ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
288
327
  if (!block || item?.kind !== 'assistant' || ('choiceId' in payload && item.uiAnswers?.[block.id]?.revision === payload.revision)) {
289
328
  return {};
290
329
  }
330
+ const names = Object.keys(block.queries);
291
331
  const values: Record<string, unknown> = Object.create(null);
292
- for (const name of Object.keys(payload.reads ?? {})) {
293
- const identity = { ...payload, query: name };
294
- const { source } = this.stored(session, identity);
332
+ if (names.length === 0) {
333
+ return values;
334
+ }
335
+ const access = await this.access(session, item);
336
+ if (access === null) {
337
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
338
+ }
339
+ for (const name of names) {
340
+ const { source } = this.stored(session, { ...payload, query: name });
295
341
  const args = source.args.parse(uiQueryArguments(block, name, payload.values)) as Record<string, UiValue>;
296
- const access = await this.access(session, item);
297
- await source.authorize(session.info, access.access, args);
342
+ await source.authorize(session.info, access, args);
343
+ const readId = payload.reads !== undefined && Object.hasOwn(payload.reads, name) ? payload.reads[name] : undefined;
298
344
  const key = JSON.stringify([session.id, item.id, block.id, block.revision, name, args]);
299
- const readId = payload.reads![name];
300
- const cached = this.cache.get(readId);
345
+ const cached = readId === undefined ? undefined : this.cache.get(readId);
301
346
  const frozen = item.uiQueries?.blocks[block.id];
302
347
  const snapshot = frozen?.revision === block.revision ? frozen?.readings[name] : undefined;
303
348
  const reading =
304
- cached?.key === key
305
- ? cached.reading
306
- : snapshot?.readId === readId && JSON.stringify(args) === JSON.stringify(source.args.parse(uiQueryArguments(block, name)))
307
- ? snapshot
308
- : undefined;
349
+ readId === undefined
350
+ ? undefined
351
+ : cached?.key === key
352
+ ? cached.reading
353
+ : snapshot?.readId === readId && JSON.stringify(args) === JSON.stringify(source.args.parse(uiQueryArguments(block, name)))
354
+ ? snapshot
355
+ : undefined;
309
356
  if (!reading || reading.state !== 'fresh') {
310
- throw new ChatError('stale-ui-query', 'Refresh this query before sending its choice.');
357
+ throw new UiQueryError('stale-ui-query', 'stale-read', 'Refresh this query before sending its choice.');
311
358
  }
312
359
  values[name] = reading.value;
313
360
  }
@@ -317,35 +364,43 @@ export class ChatUiQueries {
317
364
  async link(session: ChatSession, payload: ChatUiLinkPayload): Promise<ChatUiLinkReading> {
318
365
  try {
319
366
  if (!this.host.link) {
320
- throw new ChatError('refused-query', 'This host does not provide UI links.');
367
+ throw new UiQueryError('refused-query', 'links-unsupported', 'This host does not provide UI links.');
321
368
  }
322
369
  const { item, block } = this.storedBlock(session, payload);
323
370
  if (!item.uiQueries) {
324
- throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
371
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
325
372
  }
326
373
  const access = await this.access(session, item);
327
- if (access.access === null) {
328
- throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
374
+ if (access === null) {
375
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
329
376
  }
330
377
  const queries = await this.choiceValues(session, payload);
331
378
  const target = uiLinkTargets(block, payload.values, queries)[payload.nodeId];
332
379
  if (!target) {
333
- throw new ChatError('refused-query', 'This visible node is not a supported link.');
380
+ throw new UiQueryError('refused-query', 'link-unsupported', 'This visible node is not a supported link.');
334
381
  }
335
- return this.linkReading(await this.host.link(session.info, access.access, target));
382
+ return this.linkReading(await this.host.link(session.info, access, target));
336
383
  } catch (error) {
337
- return { state: 'plain', reason: this.reason(error) };
384
+ return { state: 'plain', ...this.failure(error) };
338
385
  }
339
386
  }
340
387
 
341
388
  forget(chatId: string): void {
342
389
  for (const [id, cached] of this.cache) {
343
390
  if (cached.chatId === chatId) {
344
- this.cache.delete(id);
391
+ this.evict(id);
345
392
  }
346
393
  }
347
394
  }
348
395
 
396
+ private evict(readId: string): void {
397
+ const cached = this.cache.get(readId);
398
+ this.cache.delete(readId);
399
+ if (cached && this.latest.get(cached.key) === readId) {
400
+ this.latest.delete(cached.key);
401
+ }
402
+ }
403
+
349
404
  private linkReading(value: unknown): ChatUiLinkReading {
350
405
  const reading = UiLinkResolutionSchema.parse(copyUiValue(value, new UiBudget()));
351
406
  if (reading.state === 'chip' && !reading.target) {
@@ -354,7 +409,8 @@ export class ChatUiQueries {
354
409
  return reading;
355
410
  }
356
411
 
357
- private reason(error: unknown): string {
358
- return (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
412
+ private failure(error: unknown): { reason: string; code?: string } {
413
+ const reason = (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
414
+ return error instanceof UiQueryError ? { reason, code: error.reason } : { reason };
359
415
  }
360
416
  }
@@ -113,6 +113,13 @@ function tooLarge(bytes: number): never {
113
113
  );
114
114
  }
115
115
 
116
+ function refuseOversized(html: string): void {
117
+ const bytes = Buffer.byteLength(html, 'utf8');
118
+ if (bytes > VISUAL_LIMITS.bytes) {
119
+ tooLarge(bytes);
120
+ }
121
+ }
122
+
116
123
  /*
117
124
  * The visuals of every chat. The list of a chat is one file beside its record under `<home>/chats`;
118
125
  * each page is a file in the chat's attachment folder, `<id>.html`, so whatever serves an attachment
@@ -164,9 +171,7 @@ export class VisualStore {
164
171
  if (html.trim() === '') {
165
172
  refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
166
173
  }
167
- if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
168
- tooLarge(Buffer.byteLength(html, 'utf8'));
169
- }
174
+ refuseOversized(html);
170
175
  return this.writeWorkspaceFile(chatId, name, html);
171
176
  }
172
177
 
@@ -178,9 +183,7 @@ export class VisualStore {
178
183
  async publish(chatId: string, input: VisualInput): Promise<ChatVisual> {
179
184
  const { title, html, maxHeight, layout, heights } = checked(input);
180
185
  // Before the bootstrap is added, so a page far too large is never copied first.
181
- if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
182
- tooLarge(Buffer.byteLength(html, 'utf8'));
183
- }
186
+ refuseOversized(html);
184
187
  const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
185
188
  if (page.byteLength > VISUAL_LIMITS.bytes) {
186
189
  tooLarge(page.byteLength);
@@ -2,7 +2,10 @@ import type { ChatItem } from '@adecore/agent-contracts';
2
2
  import type { ChatCore } from './chat-core.ts';
3
3
  import type { ChatSession } from './chat-session.ts';
4
4
 
5
- /* A chat the host may open a turn in, as the outbox handlers see it: what its thread holds, and the one call that opens one, false while a turn or an owed resume is in the way. */
5
+ /*
6
+ * A chat the host may open a turn in, as the outbox handlers see it. `wake` answers false while a
7
+ * turn or an owed resume is in the way.
8
+ */
6
9
  export interface WakeChat {
7
10
  items(): ChatItem[];
8
11
  persist?(): Promise<void>;
@@ -1,8 +1,7 @@
1
1
  /*
2
2
  * How every no reads: `refused`, the code a script branches on and the sentence a model reads, and
3
- * under it a line per thing the actor can do instead. One row per line, and a line is a row of
4
- * tab-separated fields of its own, so only a newline is taken out of it: that would split the row in
5
- * two, and everything under the first row is read as advice.
3
+ * under it a line per thing the actor can do instead. A line keeps its tabs but loses its newlines,
4
+ * since everything under the first row is read as advice.
6
5
  */
7
6
 
8
7
  /* A field that goes into a tab-separated line; a tab or a newline in a title would split the row. */
@@ -24,10 +23,7 @@ export function refusalBody(code: string, message: string, lines: readonly strin
24
23
  return [`refused\t${first}`, ...rest].join('\n');
25
24
  }
26
25
 
27
- /*
28
- * The same without the word that names it, for a host that adds the prefix in its CLI:
29
- * The context CLI puts the prefix on, so a CLI of an older build still prints one refusal.
30
- */
26
+ /* Without the `refused` prefix, for a host whose CLI adds it, so a CLI of an older build still prints one refusal. */
31
27
  export function refusalRows(code: string, message: string, lines: readonly string[] = []): string {
32
28
  return rows(code, message, lines).join('\n');
33
29
  }
@@ -382,11 +382,7 @@ export function requiredField(needs: string): z.ZodString {
382
382
  return z.string({ error: needs }).min(1, needs);
383
383
  }
384
384
 
385
- /*
386
- * What a refusal lists, or one line saying the set is empty. A refusal that promises the groups of a
387
- * canvas and then prints nothing reads as a daemon that lost them, where the truth is that there are
388
- * none, which is a different thing to do something about.
389
- */
385
+ /* A refusal that promises a list and prints nothing reads as a host that lost it, where there is none. */
390
386
  export function orNote(lines: string[], note: string): string[] {
391
387
  return lines.length === 0 ? [`note\t${note}`] : lines;
392
388
  }
@@ -24,8 +24,8 @@ function isAgentRequest(type: string): type is AgentRequestType {
24
24
 
25
25
  /*
26
26
  * The chats of an app that has no daemon: it answers the agent requests over any `FramePort` and
27
- * sends the agent events back on it, frames checked on arrival. One port is one client. Closing the host writes every thread and ends every CLI; a chat
28
- * goes on at the next start through its CLI's own session.
27
+ * sends the agent events back on it, frames checked on arrival. One port is one client. A chat
28
+ * closed with the host goes on at the next start through its CLI's own session.
29
29
  */
30
30
  export class AgentHost<Core extends ChatCore = ChatCore> {
31
31
  readonly chats: Core;
@@ -66,7 +66,7 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
66
66
  { delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats },
67
67
  payload.attachments
68
68
  ),
69
- 'chat.uiChoice': (payload) => chats.choose(payload),
69
+ 'chat.uiChoice': (payload, clientId) => chats.choose(payload, clientId),
70
70
  'ui.link': (payload, clientId) => chats.linkUi(payload, clientId),
71
71
  'ui.query': (payload, clientId) => chats.queryUi(payload, clientId),
72
72
  'chat.unqueue': (payload) => ({ message: chats.unqueue(payload.chatId, payload.messageId) }),
@@ -116,7 +116,6 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
116
116
  };
117
117
  }
118
118
 
119
- /* The accounts are the person's settings of this host. */
120
119
  export function accountHandlers(accounts: ProviderAccountsService): { [T in AccountRequestType]: AgentHandler<T> } {
121
120
  return {
122
121
  'accounts.list': () => accounts.snapshot(),
@@ -9,12 +9,12 @@ import { VisualStore } from '../chat/visual-store.ts';
9
9
  import type { ClaudeBackendOptions } from '../chat/claude-backend.ts';
10
10
  import { ClaudeTitleReader } from '../chat/claude-title.ts';
11
11
  import { DEFAULT_CODEX_CLIENT, type CodexClientInfo } from '../chat/codex-transport.ts';
12
+ import { errorText } from '../error-text.ts';
12
13
  import type { AgentEvent } from '../events.ts';
13
14
  import { definedEnv } from '../providers/accounts/launch.ts';
14
15
  import { ProviderAccountsService } from '../providers/accounts/service.ts';
15
16
  import type { AccountsHost } from '../providers/accounts/variables.ts';
16
17
  import { createClaudeProvider } from '../providers/claude-provider.ts';
17
- import { errorText } from '../error-text.ts';
18
18
  import { createCodexProvider } from '../providers/codex-provider.ts';
19
19
  import { codexRulesPathIn, defaultCodexHome, installCodexRules, type CodexRules } from '../providers/codex-rules.ts';
20
20
  import { ProviderRegistry, type ProviderRegistryOptions } from '../providers/registry.ts';
package/src/lineage.ts CHANGED
@@ -30,8 +30,8 @@ function openedByAgent(entry: Lineage): boolean {
30
30
  }
31
31
 
32
32
  /*
33
- * Lineage lives in `lineage` under the host's data folder, outside the agent-writable project, so agents
34
- * cannot reset their own depth. Persisting it also prevents a restart of the host from resetting recursion limits.
33
+ * Lives under the host's data folder, outside the agent-writable project, so an agent cannot reset its
34
+ * own depth; on disk, so a restart of the host does not reset it either.
35
35
  */
36
36
  export class AgentLineageStore {
37
37
  readonly dir: string;
@@ -140,7 +140,6 @@ export class AgentLineageStore {
140
140
  return entry?.relation === 'fork' ? entry.openedBy : null;
141
141
  }
142
142
 
143
- /* The forks made from a chat. */
144
143
  forksOf(nodeId: string): string[] {
145
144
  return [...this.opened.all()].filter((entry) => entry.relation === 'fork' && entry.openedBy === nodeId).map((entry) => entry.nodeId);
146
145
  }
@@ -24,13 +24,11 @@ export interface DeliverMessageDeps {
24
24
  }
25
25
 
26
26
  /*
27
- * Opens the turn a message earns a chat. The messages themselves are not carried here: they wait in
28
- * the notice store and the turn's preamble takes them (`NoticeNotes`), the same channel a person's
29
- * own prompt reads them through, so one message is heard once whichever of the two opens the turn.
27
+ * Opens the turn a message earns a chat. The messages wait in the notice store and the turn's preamble
28
+ * takes them (`NoticeNotes`), as a person's own prompt does, so a message is heard once either way.
30
29
  *
31
- * One attempt, never `wait`. A chat that turned out to be in a turn reads the message in front of its
32
- * next one, which is what a message to a busy chat has always done; holding the entry instead would
33
- * make the host start turns nobody asked for long after the news was worth anything.
30
+ * One attempt, never `wait`: a chat already in a turn reads the message in front of its next one, and
31
+ * holding the entry would start turns nobody asked for long after the news mattered.
34
32
  */
35
33
  export function deliverMessageHandler(deps: DeliverMessageDeps) {
36
34
  return async (entry: DeliverMessageEntry): Promise<OutboxOutcome> => {
@@ -46,11 +44,8 @@ export function deliverMessageHandler(deps: DeliverMessageDeps) {
46
44
  if (chat === null) {
47
45
  return;
48
46
  }
49
- /*
50
- * No note of its own, and the preamble stays off the thread. A person read the message as it
51
- * landed (`showNotices`), so anything the turn adds about it only says the same thing again;
52
- * the label on the turn already names who it came from.
53
- */
47
+ // No note and no preamble on the thread: a person read the message as it landed (`showNotices`),
48
+ // and the label on the turn names who it came from.
54
49
  chat.wake({
55
50
  text: deps.words.prompt(waiting.length),
56
51
  label: deps.words.label(waiting),
@@ -33,11 +33,10 @@ export interface ChatDelivery {
33
33
  }
34
34
 
35
35
  /*
36
- * Whether the turn a chat is running was itself opened by a message. This is the whole of the stop:
37
- * two agents that read each other would otherwise wake each other for as long as they kept writing,
38
- * and nothing about that started with a person. It rides on the turn, so it survives a restart.
36
+ * Whether the turn a chat is running was itself opened by a message, which stops two agents that
37
+ * read each other from waking each other forever. It rides on the turn, so it survives a restart.
39
38
  * Between turns the last one counts, since work it left running (a background subagent) may still
40
- * write, and a turn the CLI opened by itself goes on with the step of the turn before it.
39
+ * write, and a turn the CLI opened by itself takes the step of the turn before it.
41
40
  */
42
41
  export function turnFromMessage(items: readonly ChatItem[], activeTurnId: string | null): boolean {
43
42
  const from = activeTurnId === null ? items.findLastIndex((item) => item.kind === 'turn') : items.findIndex((item) => item.id === activeTurnId);
@@ -76,10 +75,9 @@ export function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): C
76
75
 
77
76
  /*
78
77
  * One message on its way to a chat, and whether the chat owes a turn on it. A chat between turns gets
79
- * one, the way a settled task gives one: a person watching two agents cannot tell a message from an
80
- * assignment, and a message nobody starts a turn for sits there until someone happens to prompt that
81
- * chat. One step deep, so the turn a message opened wakes nobody with a message of its own. A node the
82
- * host runs something else in, such as a terminal, is the host's to deliver to before this.
78
+ * one, as for a settled task, or the message sits there until someone happens to prompt that chat.
79
+ * One step deep: the turn a message opened wakes nobody with a message of its own. A node that runs
80
+ * something else, such as a terminal, is the host's to deliver to before this.
83
81
  */
84
82
  export async function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery> {
85
83
  const waiting = await store.put(notice);
@@ -105,10 +103,8 @@ export interface NoticeChat {
105
103
  }
106
104
 
107
105
  /*
108
- * What a chat has to show a person, in its thread, the moment a message lands and not when the model
109
- * gets round to it: without this a message left for a busy node is a file on disk and nothing else.
110
- * An id no chat holds shows nothing and marks nothing, so the chat that opens on that id later still
111
- * has all of it.
106
+ * Shows a person the message in the chat's thread the moment it lands, not when the model gets round
107
+ * to it. An id no chat holds shows and marks nothing, so a chat that opens on it later still has all of it.
112
108
  */
113
109
  export async function showNotices(store: Pick<NoticeStore, 'show'>, chat: NoticeChat, words: Pick<MessageWords, 'shown'>, targetId: string): Promise<void> {
114
110
  if (!(await chat.has(targetId))) {
@@ -7,10 +7,8 @@ import { RecordDirectory } from '../record-directory.ts';
7
7
  export const MAX_NOTICES = 10;
8
8
 
9
9
  /*
10
- * A message nobody picked up in this long is dropped rather than delivered. It is news about work
11
- * happening now ("the build is green"), and a model reading it six hours late would act on
12
- * something that has moved on. The cap alone would not do it: one message in a queue of one waits
13
- * for ever.
10
+ * A message is news about work happening now ("the build is green"), and a model reading it six hours
11
+ * late would act on something that moved on. The cap alone would leave a queue of one waiting forever.
14
12
  */
15
13
  export const NOTICE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
16
14
 
@@ -131,7 +129,7 @@ export class NoticeStore {
131
129
  return this.now() - notice.createdAt < NOTICE_MAX_AGE_MS;
132
130
  }
133
131
 
134
- /* The queue of one node, emptied here and on disk both; an empty one leaves no file behind. */
132
+ /* In memory and on disk both; an empty queue leaves no file behind. */
135
133
  private async persist(targetId: string, queue: Notice[]): Promise<void> {
136
134
  if (queue.length === 0) {
137
135
  await this.queues.remove(targetId);