@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
@@ -18,7 +18,7 @@ import type {
18
18
  RuntimeMode
19
19
  } from '@adecore/agent-contracts';
20
20
  import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
21
- import { resolveUiChoice, UiFailure } from '@adecore/intelligent-ui';
21
+ import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure, uiHasFence, uiMayReferenceHost } from '@adecore/intelligent-ui';
22
22
  import type { UiCompileOptions } from '@adecore/intelligent-ui/compiler';
23
23
  import { UiStream, type UiStreamPreview } from '@adecore/intelligent-ui/stream';
24
24
  import type { ChatProvider } from '../providers/provider.ts';
@@ -30,6 +30,7 @@ import type { ChatTitleInput } from './chat-title.ts';
30
30
  import { ChatError } from './errors.ts';
31
31
  import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.ts';
32
32
  import { isMainAgentOutput, ThreadProjector } from './projector.ts';
33
+ import { readableAssistantText } from './readable-text.ts';
33
34
  import type { SubagentSettlement } from './subagent-settlement.ts';
34
35
  import { ChatThread } from './thread.ts';
35
36
  import { errorText } from '../error-text.ts';
@@ -60,6 +61,8 @@ export interface ChatSessionOptions {
60
61
  items?: ChatItem[];
61
62
  // Said once, in front of the next real prompt, and kept in the record until then (a fork's note for its agent).
62
63
  preambles?: string[];
64
+ // The access a UI host captured per reply, from the record; never on the wire.
65
+ uiAccess?: Readonly<Record<string, unknown>>;
63
66
  provider: ChatProvider;
64
67
  // The executable and leading arguments; a test points this at a fake CLI.
65
68
  command: string[];
@@ -76,6 +79,8 @@ export interface ChatSessionOptions {
76
79
  references?: ChatReferences;
77
80
  // Trees per turn, so a settled turn can show what the working tree holds against its start.
78
81
  checkpoints?: TurnCheckpoints;
82
+ // The info string of the host's UI fences; `UI_FENCE_LANGUAGE` without one.
83
+ uiFenceLanguage?: string;
79
84
  ui?: {
80
85
  schemas: UiCompileOptions['querySchemas'];
81
86
  observe(item: ChatAssistantItem, final: boolean): void;
@@ -109,6 +114,8 @@ export interface LimitResumeHooks {
109
114
 
110
115
  // Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
111
116
  const TITLE_RECHECK_MS = 10_000;
117
+ // Longer than any fence line or the name of a tag a scan looks for, so a match never falls between two deltas.
118
+ const UI_SCAN_OVERLAP = 64;
112
119
 
113
120
  export interface ChatSendExtras {
114
121
  uiChoice?: ChatUiChoiceOrigin;
@@ -193,10 +200,18 @@ export class ChatSession {
193
200
  private readonly steering = new Set<string>();
194
201
  private readonly uiStreams = new Map<string, UiStream>();
195
202
  private readonly uiCompiled = new Map<string, UiStreamPreview>();
203
+ private readonly capturedUiAccess: Map<string, unknown>;
204
+ // How much of a streaming reply's text the fence and host scans already covered, so a delta scans only its tail.
205
+ private readonly uiScanned = new Map<string, number>();
206
+ private readonly uiReferencing = new Set<string>();
207
+ private readonly uiAttachments = new Map<string | null, string | undefined>();
208
+ private readonly uiFenceLanguage: string;
196
209
 
197
210
  constructor(options: ChatSessionOptions) {
198
211
  this.options = options;
212
+ this.uiFenceLanguage = options.uiFenceLanguage ?? UI_FENCE_LANGUAGE;
199
213
  this.pendingPreambles = options.preambles ?? [];
214
+ this.capturedUiAccess = new Map(Object.entries(options.uiAccess ?? {}));
200
215
  this.thread = new ChatThread(options.info, options.items);
201
216
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
202
217
  }
@@ -314,6 +329,24 @@ export class ChatSession {
314
329
  return { queued: false, turnId };
315
330
  }
316
331
 
332
+ /* What a UI host captured for a reply; null where the capture was denied or is still out, undefined where none started. */
333
+ uiAccess(itemId: string): unknown {
334
+ return this.capturedUiAccess.get(itemId);
335
+ }
336
+
337
+ setUiAccess(itemId: string, access: unknown): void {
338
+ if (this.frozen) {
339
+ return;
340
+ }
341
+ this.capturedUiAccess.set(itemId, access);
342
+ this.options.persistSoon();
343
+ }
344
+
345
+ /* The captured access of the replies still in the thread, for the record. */
346
+ get uiAccessRecord(): Record<string, unknown> {
347
+ return Object.fromEntries([...this.capturedUiAccess].filter(([itemId]) => this.thread.get(itemId) !== undefined));
348
+ }
349
+
317
350
  updateUiQueries(itemId: string, uiQueries: ChatUiQueryState): void {
318
351
  const item = this.thread.get(itemId);
319
352
  if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
@@ -1828,7 +1861,7 @@ export class ChatSession {
1828
1861
  this.naming = true;
1829
1862
  const turnId = done[0]!.id;
1830
1863
  const answer = items
1831
- .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? item.text : ''))
1864
+ .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? readableAssistantText(item) : ''))
1832
1865
  .filter((text) => text !== '')
1833
1866
  .join('\n\n');
1834
1867
  const agentSessionId = this.thread.info.agentSessionId;
@@ -1886,19 +1919,28 @@ export class ChatSession {
1886
1919
  if (event.type === 'reset') {
1887
1920
  this.clearUi();
1888
1921
  }
1922
+ if (event.type === 'item' && event.item.kind === 'tool') {
1923
+ this.uiAttachments.delete(event.item.turnId);
1924
+ }
1889
1925
  const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
1890
1926
  if (item?.kind !== 'assistant' || item.parentToolUseId) {
1891
1927
  this.options.emit(event);
1892
1928
  continue;
1893
1929
  }
1930
+ const tail = this.unscannedUiText(item, event.type === 'item');
1931
+ if (uiMayReferenceHost(tail)) {
1932
+ this.uiReferencing.add(item.id);
1933
+ }
1894
1934
  let stream = this.uiStreams.get(item.id);
1895
- if (!stream && !item.text.includes('ruimte-ui')) {
1935
+ if (!stream && !uiHasFence(tail, this.uiFenceLanguage)) {
1936
+ this.forgetUiScan(item);
1896
1937
  this.options.emit(event);
1897
1938
  continue;
1898
1939
  }
1899
1940
  if (!stream) {
1900
1941
  stream = new UiStream({
1901
1942
  querySchemas: this.options.ui?.schemas,
1943
+ fenceLanguage: this.uiFenceLanguage,
1902
1944
  id: item.id,
1903
1945
  clock: this.options.clock ?? systemClock,
1904
1946
  emit: (preview) => {
@@ -1908,7 +1950,10 @@ export class ChatSession {
1908
1950
  });
1909
1951
  this.uiStreams.set(item.id, stream);
1910
1952
  }
1911
- this.options.ui?.observe(item, false);
1953
+ if (this.uiReferencing.has(item.id)) {
1954
+ this.options.ui?.observe(item, false);
1955
+ }
1956
+ this.forgetUiScan(item);
1912
1957
  const latestAttachment = this.latestUiAttachment(item.turnId);
1913
1958
  if (event.type === 'item' && !item.streaming) {
1914
1959
  const ui = stream
@@ -1919,7 +1964,7 @@ export class ChatSession {
1919
1964
  const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
1920
1965
  if (diagnostics.length > 0) {
1921
1966
  const repair =
1922
- `Your last ruimte-ui block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(
1967
+ `Your last ${this.uiFenceLanguage} block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(
1923
1968
  0,
1924
1969
  1200
1925
1970
  );
@@ -1939,7 +1984,39 @@ export class ChatSession {
1939
1984
  }
1940
1985
  }
1941
1986
 
1987
+ /*
1988
+ * The text a scan has not covered yet, from the start of the line the last scan ended in, since a fence
1989
+ * is one line; a long line is cut short, behind a character no fence or tag can follow.
1990
+ */
1991
+ private unscannedUiText(item: ChatAssistantItem, whole: boolean): string {
1992
+ const scanned = whole ? 0 : (this.uiScanned.get(item.id) ?? 0);
1993
+ this.uiScanned.set(item.id, item.text.length);
1994
+ if (scanned === 0 || scanned > item.text.length) {
1995
+ return item.text;
1996
+ }
1997
+ const line = item.text.lastIndexOf('\n', scanned - 1) + 1;
1998
+ const from = Math.max(line, scanned - UI_SCAN_OVERLAP);
1999
+ return from === line ? item.text.slice(from) : `\0${item.text.slice(from)}`;
2000
+ }
2001
+
2002
+ private forgetUiScan(item: ChatAssistantItem): void {
2003
+ if (!item.streaming) {
2004
+ this.uiScanned.delete(item.id);
2005
+ this.uiReferencing.delete(item.id);
2006
+ }
2007
+ }
2008
+
2009
+ /* Looked up once per turn, and again only after one of its tool calls changed. */
1942
2010
  private latestUiAttachment(turnId: string | null): string | undefined {
2011
+ if (this.uiAttachments.has(turnId)) {
2012
+ return this.uiAttachments.get(turnId);
2013
+ }
2014
+ const attachment = this.findUiAttachment(turnId);
2015
+ this.uiAttachments.set(turnId, attachment);
2016
+ return attachment;
2017
+ }
2018
+
2019
+ private findUiAttachment(turnId: string | null): string | undefined {
1943
2020
  for (const item of this.thread.list().toReversed()) {
1944
2021
  if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
1945
2022
  continue;
@@ -1958,6 +2035,9 @@ export class ChatSession {
1958
2035
  }
1959
2036
  this.uiStreams.clear();
1960
2037
  this.uiCompiled.clear();
2038
+ this.uiScanned.clear();
2039
+ this.uiReferencing.clear();
2040
+ this.uiAttachments.clear();
1961
2041
  }
1962
2042
 
1963
2043
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
@@ -4,12 +4,12 @@ import { join } from 'node:path';
4
4
  import { ChatInfoSchema, ChatItemSchema, type ChatInfo, type ChatItem } from '@adecore/agent-contracts';
5
5
  import { z } from 'zod';
6
6
  import { isNotFound, writeAtomic, writeAtomicSync } from '../fs.ts';
7
- import { isBookmarkFileName } from './bookmark-store.ts';
7
+ import { recordFileName } from '../record-directory.ts';
8
8
  import { migrateInlineAttachments, type AttachmentStore } from './attachment-store.ts';
9
+ import { isBookmarkFileName } from './bookmark-store.ts';
9
10
  import { parseLog, type ChatLogLine } from './chat-log.ts';
10
11
  import { ChatThread } from './thread.ts';
11
12
  import { isVisualFileName } from './visual-store.ts';
12
- import { recordFileName } from '../record-directory.ts';
13
13
 
14
14
  // zod strips what it does not know inside the info and the items, so a file written by an older build
15
15
  // (with `interactionMode`, say) still parses and loses only the dropped field. A record without `seq`
@@ -20,15 +20,17 @@ const RecordSchema = z.looseObject({
20
20
  seq: z.number().int().nonnegative().optional(),
21
21
  resetSeq: z.number().int().nonnegative().optional(),
22
22
  // Never on the wire: what goes in front of the next real prompt, once (a fork's note for its agent).
23
- preambles: z.array(z.string()).optional()
23
+ preambles: z.array(z.string()).optional(),
24
+ // Never on the wire: the access a UI host captured for each reply that reads it, by item id.
25
+ uiAccess: z.record(z.string(), z.unknown()).optional()
24
26
  });
25
27
 
26
- const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles']);
28
+ const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles', 'uiAccess']);
27
29
 
28
30
  /* What a host keeps in a chat's record beside the thread; it never reaches the wire. */
29
31
  export type ChatRecordExtras = Record<string, unknown>;
30
32
 
31
- function extrasOf(record: Record<string, unknown>): ChatRecordExtras {
33
+ function withoutOwnKeys(record: Record<string, unknown>): ChatRecordExtras {
32
34
  return Object.fromEntries(Object.entries(record).filter(([key]) => !OWN_KEYS.has(key)));
33
35
  }
34
36
 
@@ -39,6 +41,7 @@ export interface ChatRecord {
39
41
  seq: number;
40
42
  resetSeq: number;
41
43
  preambles: string[];
44
+ uiAccess: Record<string, unknown>;
42
45
  extras: ChatRecordExtras;
43
46
  // The log as it is on disk, for the lines an attach with `since` may still be answered from.
44
47
  lines: ChatLogLine[];
@@ -54,12 +57,37 @@ function logName(chatId: string): string {
54
57
  return `${encodeURIComponent(chatId)}.log`;
55
58
  }
56
59
 
57
- function recordBody(info: ChatInfo, items: ChatItem[], at: ChatSeq, preambles: readonly string[], extras: ChatRecordExtras): string {
58
- return JSON.stringify({ info, items, ...at, ...(preambles.length === 0 ? {} : { preambles }), ...withoutOwnKeys(extras) });
60
+ function recordBody(
61
+ info: ChatInfo,
62
+ items: ChatItem[],
63
+ at: ChatSeq,
64
+ preambles: readonly string[],
65
+ extras: ChatRecordExtras,
66
+ uiAccess: Readonly<Record<string, unknown>>
67
+ ): string {
68
+ return JSON.stringify({
69
+ info,
70
+ items,
71
+ ...at,
72
+ ...(preambles.length === 0 ? {} : { preambles }),
73
+ ...(Object.keys(uiAccess).length === 0 ? {} : { uiAccess }),
74
+ ...withoutOwnKeys(extras)
75
+ });
59
76
  }
60
77
 
61
- function withoutOwnKeys(extras: ChatRecordExtras): ChatRecordExtras {
62
- return Object.fromEntries(Object.entries(extras).filter(([key]) => !OWN_KEYS.has(key)));
78
+ /* A record from before `uiAccess` kept the access inside each reply's `uiQueries`, which the item schema now drops. */
79
+ function liftUiAccess(record: unknown): unknown {
80
+ if (typeof record !== 'object' || record === null || 'uiAccess' in record || !Array.isArray((record as { items?: unknown }).items)) {
81
+ return record;
82
+ }
83
+ const uiAccess: Record<string, unknown> = {};
84
+ for (const item of (record as { items: unknown[] }).items) {
85
+ const queries = (item as { uiQueries?: unknown } | null)?.uiQueries;
86
+ if (typeof queries === 'object' && queries !== null && 'access' in queries) {
87
+ uiAccess[String((item as { id: unknown }).id)] = queries.access;
88
+ }
89
+ }
90
+ return Object.keys(uiAccess).length === 0 ? record : { ...record, uiAccess };
63
91
  }
64
92
 
65
93
  export interface ChatStoreOptions {
@@ -127,9 +155,10 @@ export class ChatStore {
127
155
  items: ChatItem[],
128
156
  at: ChatSeq = { seq: 0, resetSeq: 0 },
129
157
  preambles: readonly string[] = [],
130
- extras: ChatRecordExtras = {}
158
+ extras: ChatRecordExtras = {},
159
+ uiAccess: Readonly<Record<string, unknown>> = {}
131
160
  ): Promise<number> {
132
- const body = recordBody(info, items, at, preambles, extras);
161
+ const body = recordBody(info, items, at, preambles, extras, uiAccess);
133
162
  await mkdir(this.dir, { recursive: true, mode: 0o700 });
134
163
  await writeAtomic(join(this.dir, recordFileName(chatId)), body);
135
164
  return body.length;
@@ -145,10 +174,11 @@ export class ChatStore {
145
174
  items: ChatItem[],
146
175
  at: ChatSeq = { seq: 0, resetSeq: 0 },
147
176
  preambles: readonly string[] = [],
148
- extras: ChatRecordExtras = {}
177
+ extras: ChatRecordExtras = {},
178
+ uiAccess: Readonly<Record<string, unknown>> = {}
149
179
  ): void {
150
180
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
151
- writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras));
181
+ writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras, uiAccess));
152
182
  }
153
183
 
154
184
  /* Null for a chat with nothing on disk to make it of, and for one whose record does not parse (see `unreadable`). */
@@ -174,7 +204,15 @@ export class ChatStore {
174
204
  resetSeq = line.seq;
175
205
  }
176
206
  }
177
- return { ...thread.snapshot(), seq, resetSeq, preambles: snapshot.preambles ?? [], extras: extrasOf(snapshot), lines };
207
+ return {
208
+ ...thread.snapshot(),
209
+ seq,
210
+ resetSeq,
211
+ preambles: snapshot.preambles ?? [],
212
+ uiAccess: snapshot.uiAccess ?? {},
213
+ extras: withoutOwnKeys(snapshot),
214
+ lines
215
+ };
178
216
  }
179
217
 
180
218
  /*
@@ -204,7 +242,7 @@ export class ChatStore {
204
242
  await writeAtomic(join(this.dir, recordFileName(chatId)), JSON.stringify(migrated));
205
243
  }
206
244
  }
207
- const parsed = RecordSchema.safeParse(record);
245
+ const parsed = RecordSchema.safeParse(liftUiAccess(record));
208
246
  return parsed.success ? parsed.data : UNREADABLE;
209
247
  }
210
248
 
@@ -260,5 +298,5 @@ function fromLogAlone(lines: ChatLogLine[]): ChatRecord | null {
260
298
  resetSeq = line.seq;
261
299
  }
262
300
  }
263
- return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], extras: {}, lines };
301
+ return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], uiAccess: {}, extras: {}, lines };
264
302
  }
@@ -1,7 +1,7 @@
1
1
  import type { AgentKind } from '@adecore/agent-contracts';
2
- import { cleanTitle } from '../title-file.ts';
3
2
  import type { ProviderRegistry } from '../providers/registry.ts';
4
3
  import { runProcess } from '../run-process.ts';
4
+ import { cleanTitle } from '../title-file.ts';
5
5
 
6
6
  // Enough of a conversation to name it; the rest of a long prompt or answer says nothing a title needs.
7
7
  const MAX_PROMPT_CHARS = 2000;
@@ -19,10 +19,7 @@ function cap(text: string, limit: number): string {
19
19
  return text.length <= limit ? text : `${text.slice(0, limit)}...`;
20
20
  }
21
21
 
22
- /*
23
- * What the CLI is asked. JSON again, as for a commit message: a model left to write freely opens
24
- * with a sentence, and that sentence would become the name of the node.
25
- */
22
+ /* JSON, since a model left to write freely opens with a sentence, and that sentence would become the name. */
26
23
  export function buildTitlePrompt(prompt: string, answer: string): string {
27
24
  return [
28
25
  'Write a short title for the conversation below, as a person would name it in a list of chats.',
@@ -42,9 +39,8 @@ export function buildTitlePrompt(prompt: string, answer: string): string {
42
39
  }
43
40
 
44
41
  /*
45
- * The title out of what the CLI printed, or null. Strict on purpose, unlike a commit message: a run
46
- * that holds no `{"title": "..."}` object printed something other than a title, and a derived name
47
- * is better than a stray line of it.
42
+ * Strict on purpose: a run that holds no `{"title": "..."}` object printed something other than a
43
+ * title, and a derived name is better than a stray line of it.
48
44
  */
49
45
  export function parseTitle(output: string): string | null {
50
46
  const start = [...output.matchAll(/\{\s*"title"/g)].at(-1)?.index;
@@ -1,5 +1,5 @@
1
- import { errorText } from '../error-text.ts';
2
1
  import { randomUUID } from 'node:crypto';
2
+ import { errorText } from '../error-text.ts';
3
3
  import { claudeArgs, claudeEnv, promptPrefix } from '../providers/claude.ts';
4
4
  import type { ApprovalDecision, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
5
5
  import { ChatChild } from './chat-process.ts';
@@ -71,12 +71,10 @@ export class ClaudeBackend implements ChatBackend {
71
71
  }
72
72
 
73
73
  sendTurn(input: TurnInput): void {
74
- // The prefix is what the CLI must see first (ultrathink), then the note about the links, then what was typed.
75
- const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
76
74
  const promptId = input.promptId ?? randomUUID();
77
75
  this.active = true;
78
76
  this.protocol.beginPrompt(promptId);
79
- this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
77
+ this.write(this.userMessage(input, promptId));
80
78
  }
81
79
 
82
80
  steerTurn(input: TurnInput): Promise<boolean> {
@@ -88,8 +86,7 @@ export class ClaudeBackend implements ChatBackend {
88
86
  this.steers.set(promptId, { resolve, reject });
89
87
  this.protocol.beginSteer(promptId);
90
88
  try {
91
- const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
92
- this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
89
+ this.write(this.userMessage(input, promptId));
93
90
  } catch (error) {
94
91
  this.steers.delete(promptId);
95
92
  reject(error instanceof Error ? error : new Error(errorText(error)));
@@ -147,6 +144,12 @@ export class ClaudeBackend implements ChatBackend {
147
144
  return child?.end() ?? Promise.resolve();
148
145
  }
149
146
 
147
+ private userMessage(input: TurnInput, promptId: string): Record<string, unknown> {
148
+ // What the CLI must see first (ultrathink), then the preamble, then what was typed.
149
+ const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
150
+ return buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId });
151
+ }
152
+
150
153
  private async readLines(child: ChatChild): Promise<void> {
151
154
  const reader = child.process.stdout.getReader();
152
155
  const decoder = new TextDecoder();
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { usageRoots } from '../usage/roots.ts';
5
4
  import { cleanTitle, readLines } from '../title-file.ts';
5
+ import { usageRoots } from '../usage/roots.ts';
6
6
 
7
7
  interface Progress {
8
8
  offset: number;
@@ -5,8 +5,8 @@ import type { ChatHistoryResult, ChatItem } from '@adecore/agent-contracts';
5
5
  import { readLines } from '../title-file.ts';
6
6
  import { ClaudeProtocol } from './claude-protocol.ts';
7
7
  import { readingThread, settledReading } from './subagent-projection.ts';
8
- import type { ChatThread } from './thread.ts';
9
8
  import type { ThreadProjector } from './projector.ts';
9
+ import type { ChatThread } from './thread.ts';
10
10
 
11
11
  const CHUNK_BYTES = 4 * 1024 * 1024;
12
12
 
@@ -1,11 +1,11 @@
1
1
  import { accessSync, constants } from 'node:fs';
2
2
  import { attachmentImageMime, type ChatSkill } from '@adecore/agent-contracts';
3
+ import { errorText } from '../error-text.ts';
3
4
  import { codexServiceTier, codexThreadOptions } from '../providers/codex.ts';
4
5
  import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
5
6
  import { CodexProtocol } from './codex-protocol.ts';
6
- import { attachmentNote } from './input.ts';
7
7
  import { CodexTransport, DEFAULT_CODEX_CLIENT, type CodexClientInfo, type CodexFrame } from './codex-transport.ts';
8
- import { errorText } from '../error-text.ts';
8
+ import { attachmentNote } from './input.ts';
9
9
 
10
10
  function textInput(text: string) {
11
11
  return [{ type: 'text', text, text_elements: [] }];
@@ -46,18 +46,15 @@ export interface CodexBackendOptions {
46
46
  */
47
47
  export class CodexBackend implements ChatBackend {
48
48
  readonly acknowledgesTurns = true;
49
- private promptId: string | null = null;
50
49
  private readonly launch: BackendLaunch;
51
- private imageWrites: Promise<void> | null = null;
52
50
  private readonly host: BackendHost;
53
51
  private readonly protocol: CodexProtocol;
54
52
  private readonly client: CodexClientInfo;
53
+ private promptId: string | null = null;
54
+ private imageWrites: Promise<void> | null = null;
55
55
  private transport: CodexTransport | null = null;
56
56
  private threadId = '';
57
- /*
58
- * Codex 0.154 ignores developer instructions on `thread/resume` (measured), so a thread started
59
- * before anything was linked would never hear of its links; the first prompt of a resumed process says so instead.
60
- */
57
+ // Codex 0.154 ignores developer instructions on `thread/resume` (measured), so the first prompt of a resumed process carries them.
61
58
  private contextPending: string | null = null;
62
59
  private imageInputSupported: boolean | null = null;
63
60
  /* A turn asked for that Codex has not named yet; `turn/interrupt` needs that name. */
@@ -172,7 +172,7 @@ function elicitationInput(meta: Frame): Frame {
172
172
  return input;
173
173
  }
174
174
 
175
- // Every spent window must reset; one unknown reset keeps resuming a person's choice.
175
+ // The latest reset of every spent window; one without a known reset leaves resuming to a person.
176
176
  function spentUntil(windows: readonly UsageWindow[]): number | null {
177
177
  const spent = windows.filter((window) => window.used >= 1);
178
178
  if (spent.length === 0 || spent.some((window) => window.resetsAt === null)) {
@@ -1,10 +1,10 @@
1
1
  import type { ChatItem } from '@adecore/agent-contracts';
2
+ import { errorText } from '../error-text.ts';
2
3
  import type { SpawnChatProcess } from './chat-process.ts';
3
4
  import { CodexProtocol } from './codex-protocol.ts';
4
5
  import { CodexTransport, DEFAULT_CODEX_CLIENT, type CodexClientInfo } from './codex-transport.ts';
5
6
  import { ChatError } from './errors.ts';
6
7
  import { readingThread, settledReading } from './subagent-projection.ts';
7
- import { errorText } from '../error-text.ts';
8
8
 
9
9
  function isRecord(value: unknown): value is Record<string, unknown> {
10
10
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -116,14 +116,15 @@ async function nthTurnId(transport: CodexTransport, threadId: string, turns: num
116
116
  let seen = 0;
117
117
  let cursor: string | null = null;
118
118
  do {
119
- const page = await transport.request('thread/turns/list', { threadId, limit: 100, sortDirection: 'asc', ...(cursor === null ? {} : { cursor }) });
120
- const data = isRecord(page) && Array.isArray(page.data) ? page.data.filter(isRecord) : [];
121
- for (const turn of data) {
119
+ const page = parseThreadItemsPage(
120
+ await transport.request('thread/turns/list', { threadId, limit: 100, sortDirection: 'asc', ...(cursor === null ? {} : { cursor }) })
121
+ );
122
+ for (const turn of page.entries) {
122
123
  if (++seen === turns) {
123
124
  return typeof turn.id === 'string' ? turn.id : null;
124
125
  }
125
126
  }
126
- cursor = isRecord(page) && typeof page.nextCursor === 'string' && page.nextCursor !== '' ? page.nextCursor : null;
127
+ cursor = page.nextCursor;
127
128
  } while (cursor !== null);
128
129
  return null;
129
130
  }
@@ -15,9 +15,8 @@ interface Held {
15
15
 
16
16
  /*
17
17
  * What each connected client says a new chat starts with, for the chats the host starts with no
18
- * client mounting them. Held per socket like the approval switch: a machine never remembers a
19
- * person's pick after that person's client is gone, so with nobody connected a chat gets the
20
- * host's own defaults.
18
+ * client mounting them. Held per client only, so with nobody connected a chat gets the host's own
19
+ * defaults rather than the pick of a person who left.
21
20
  */
22
21
  export class ComposerPreferences {
23
22
  private readonly byClient = new Map<string, Held>();
@@ -5,13 +5,8 @@ export const LIMIT_RETRY_DELAYS_MS: readonly number[] = [60_000, 5 * 60_000, 15
5
5
 
6
6
  /* The last turn of a thread, when it stopped on a limit. */
7
7
  export function limitedTurn(items: readonly ChatItem[]): (ChatTurnItem & { limit: ChatTurnLimit }) | null {
8
- for (let i = items.length - 1; i >= 0; i--) {
9
- const item = items[i]!;
10
- if (item.kind === 'turn') {
11
- return item.state === 'error' && item.limit !== undefined ? (item as ChatTurnItem & { limit: ChatTurnLimit }) : null;
12
- }
13
- }
14
- return null;
8
+ const last = items.findLast((item) => item.kind === 'turn');
9
+ return last?.kind === 'turn' && last.state === 'error' && last.limit !== undefined ? (last as ChatTurnItem & { limit: ChatTurnLimit }) : null;
15
10
  }
16
11
 
17
12
  /* How many turns in a row, up to and including this one, stopped on a limit: the tries a person did not step in between. */
@@ -0,0 +1,18 @@
1
+ import type { ChatAssistantItem } from '@adecore/agent-contracts';
2
+ import { uiFallbackText } from '@adecore/intelligent-ui/text';
3
+
4
+ /*
5
+ * An assistant's words as another agent or a person outside the thread reads them: each UI block becomes
6
+ * its text fallback, with the first query values when they were frozen for the block as stored. It works
7
+ * from the stored block ranges, so it holds whatever fence language the host compiled.
8
+ */
9
+ export function readableAssistantText(item: Pick<ChatAssistantItem, 'text' | 'ui' | 'uiQueries'>): string {
10
+ if (!item.ui?.length) {
11
+ return item.text;
12
+ }
13
+ const blocks = item.ui.map((block) => {
14
+ const frozen = item.uiQueries?.blocks[block.id];
15
+ return frozen && frozen.revision === block.revision ? { ...block, fallback: frozen.fallback } : block;
16
+ });
17
+ return uiFallbackText(item.text, blocks);
18
+ }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import type { AgentStatus, ChatApprovalItem, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem } from '@adecore/agent-contracts';
2
3
  import { ChatError } from './errors.ts';
3
4
  import { requestSummaries } from './request-summary.ts';
4
- import type { AgentStatus, ChatApprovalItem, ChatEvent, ChatHistoryResult, ChatInfo, ChatItem, ChatQuestionItem } from '@adecore/agent-contracts';
5
5
 
6
6
  /*
7
7
  * The state of one chat as the client sees it. Every mutation answers the event that describes