@adecore/agents 0.20.1 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/chat/attachment-store.d.ts +2 -0
  2. package/dist/chat/attachment-store.js +38 -2
  3. package/dist/chat/backend.d.ts +12 -1
  4. package/dist/chat/chat-core.d.ts +57 -1
  5. package/dist/chat/chat-core.js +67 -14
  6. package/dist/chat/chat-session.d.ts +35 -2
  7. package/dist/chat/chat-session.js +261 -11
  8. package/dist/chat/chat-store.d.ts +3 -2
  9. package/dist/chat/chat-store.js +42 -11
  10. package/dist/chat/codex-backend.d.ts +2 -0
  11. package/dist/chat/codex-backend.js +51 -1
  12. package/dist/chat/codex-protocol.js +30 -3
  13. package/dist/chat/delta-coalescer.js +1 -1
  14. package/dist/chat/errors.d.ts +1 -1
  15. package/dist/chat/fake-codex.js +33 -0
  16. package/dist/chat/generated-image.d.ts +6 -0
  17. package/dist/chat/generated-image.js +74 -0
  18. package/dist/chat/projector.js +9 -2
  19. package/dist/chat/readable-text.d.ts +2 -0
  20. package/dist/chat/readable-text.js +16 -0
  21. package/dist/chat/thread.js +6 -0
  22. package/dist/chat/ui-queries.d.ts +49 -0
  23. package/dist/chat/ui-queries.js +358 -0
  24. package/dist/host/handlers.js +3 -0
  25. package/dist/tasks/task-coordinator.js +6 -2
  26. package/package.json +5 -4
  27. package/src/chat/attachment-store.ts +38 -2
  28. package/src/chat/backend.ts +14 -2
  29. package/src/chat/chat-core.ts +84 -13
  30. package/src/chat/chat-session.ts +304 -12
  31. package/src/chat/chat-store.ts +53 -11
  32. package/src/chat/codex-backend.ts +50 -1
  33. package/src/chat/codex-protocol.ts +31 -3
  34. package/src/chat/delta-coalescer.ts +1 -1
  35. package/src/chat/errors.ts +2 -0
  36. package/src/chat/fake-codex.ts +34 -0
  37. package/src/chat/generated-image.ts +88 -0
  38. package/src/chat/projector.ts +9 -2
  39. package/src/chat/readable-text.ts +18 -0
  40. package/src/chat/thread.ts +6 -0
  41. package/src/chat/ui-queries.ts +416 -0
  42. package/src/host/handlers.ts +3 -0
  43. package/src/tasks/task-coordinator.ts +6 -2
@@ -1,14 +1,19 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { notResumedNote } from '@adecore/agent-contracts';
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';
3
5
  import { commandLabel, isBackgroundWork, reportsOnBackgroundWork, runningInBackground } from './background-work.js';
4
6
  import { ChatError } from './errors.js';
5
7
  import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.js';
6
8
  import { isMainAgentOutput, ThreadProjector } from './projector.js';
9
+ import { readableAssistantText } from './readable-text.js';
7
10
  import { ChatThread } from './thread.js';
8
11
  import { errorText } from '../error-text.js';
9
12
  import { systemClock } from '../outbox/outbox-worker.js';
10
13
  // Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
11
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;
12
17
  /* One per limited turn, so a second fork of the same turn writes no second note. */
13
18
  function continuedNoteId(turnId) {
14
19
  return `continued-${turnId}`;
@@ -70,12 +75,38 @@ export class ChatSession {
70
75
  pendingPreambles;
71
76
  submission = null;
72
77
  steering = new Set();
78
+ uiStreams = new Map();
79
+ uiCompiled = new Map();
80
+ capturedUiAccess;
81
+ // How much of a streaming reply's text the fence and host scans already covered, so a delta scans only its tail.
82
+ uiScanned = new Map();
83
+ uiReferencing = new Set();
84
+ uiAttachments = new Map();
85
+ uiFenceLanguage;
73
86
  constructor(options) {
74
87
  this.options = options;
88
+ this.uiFenceLanguage = options.uiFenceLanguage ?? UI_FENCE_LANGUAGE;
75
89
  this.pendingPreambles = options.preambles ?? [];
90
+ this.capturedUiAccess = new Map(Object.entries(options.uiAccess ?? {}));
76
91
  this.thread = new ChatThread(options.info, options.items);
77
92
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
78
93
  }
94
+ get uiPreviews() {
95
+ const events = [];
96
+ for (const [itemId, preview] of this.uiCompiled) {
97
+ const item = this.thread.get(itemId);
98
+ if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
99
+ events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
100
+ }
101
+ }
102
+ return events;
103
+ }
104
+ previewItems(items) {
105
+ return items.map((item) => {
106
+ const preview = this.uiCompiled.get(item.id);
107
+ return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
108
+ });
109
+ }
79
110
  get id() {
80
111
  return this.thread.info.chatId;
81
112
  }
@@ -148,6 +179,7 @@ export class ChatSession {
148
179
  turnId,
149
180
  text,
150
181
  createdAt: Date.now(),
182
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
151
183
  ...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
152
184
  ...(extras.skills?.length ? { skills: extras.skills } : {}),
153
185
  ...(extras.chats?.length ? { chats: extras.chats } : {}),
@@ -161,6 +193,91 @@ export class ChatSession {
161
193
  this.dispatch(text, extras, turnId);
162
194
  return { queued: false, turnId };
163
195
  }
196
+ /* What a UI host captured for a reply; null where the capture was denied or is still out, undefined where none started. */
197
+ uiAccess(itemId) {
198
+ return this.capturedUiAccess.get(itemId);
199
+ }
200
+ setUiAccess(itemId, access) {
201
+ if (this.frozen) {
202
+ return;
203
+ }
204
+ this.capturedUiAccess.set(itemId, access);
205
+ this.options.persistSoon();
206
+ }
207
+ /* The captured access of the replies still in the thread, for the record. */
208
+ get uiAccessRecord() {
209
+ return Object.fromEntries([...this.capturedUiAccess].filter(([itemId]) => this.thread.get(itemId) !== undefined));
210
+ }
211
+ updateUiQueries(itemId, uiQueries) {
212
+ const item = this.thread.get(itemId);
213
+ if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
214
+ return;
215
+ }
216
+ this.thread.upsert({ ...item, uiQueries });
217
+ this.options.emit({ type: 'delta', itemId, text: '', uiQueries });
218
+ this.options.persistSoon();
219
+ }
220
+ choose(payload, queries = {}) {
221
+ const item = this.thread.get(payload.itemId);
222
+ const block = item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((block) => block.id === payload.blockId) : undefined;
223
+ if (item?.kind !== 'assistant' || !block?.revision || block.revision !== payload.revision) {
224
+ throw new ChatError('stale-ui-block', 'This completed UI block is no longer available at the requested revision');
225
+ }
226
+ const sameBlock = (origin) => origin?.itemId === item.id && origin.blockId === block.id && origin.revision === block.revision;
227
+ let previous = item.uiAnswers?.[block.id];
228
+ if (previous?.revision !== block.revision) {
229
+ previous = undefined;
230
+ }
231
+ // The message is logged first: a crash before its answer marker must not send it a second time.
232
+ if (!previous) {
233
+ const sent = this.thread.list().find((entry) => entry.kind === 'user' && sameBlock(entry.uiChoice));
234
+ const queued = this.queue.find((entry) => sameBlock(entry.uiChoice));
235
+ const origin = sent?.kind === 'user' ? sent.uiChoice : queued?.uiChoice;
236
+ const turnId = sent?.turnId ?? queued?.turnId;
237
+ if (origin && turnId) {
238
+ previous = { ...origin, turnId, queued: !sent };
239
+ this.recordUiAnswer(previous);
240
+ }
241
+ }
242
+ if (previous) {
243
+ if (previous.choiceId !== payload.choiceId) {
244
+ throw new ChatError('ui-already-answered', 'This UI block was already answered');
245
+ }
246
+ return { queued: previous.queued, turnId: previous.turnId };
247
+ }
248
+ let selected;
249
+ try {
250
+ selected = resolveUiChoice(block, payload.choiceId, payload.values, queries);
251
+ }
252
+ catch (error) {
253
+ throw new ChatError(error instanceof UiFailure ? error.code : 'refused-choice', error instanceof Error ? error.message : 'This choice could not be read');
254
+ }
255
+ const origin = {
256
+ itemId: item.id,
257
+ blockId: block.id,
258
+ revision: block.revision,
259
+ choiceId: payload.choiceId,
260
+ label: selected.label,
261
+ sourceAt: item.createdAt,
262
+ older: this.thread.list().findLast((entry) => entry.kind === 'assistant' && !entry.parentToolUseId)?.id !== item.id,
263
+ values: selected.values,
264
+ at: Date.now()
265
+ };
266
+ const result = this.send(selected.context, { uiChoice: origin });
267
+ this.recordUiAnswer({ ...origin, ...result });
268
+ return result;
269
+ }
270
+ recordUiAnswer(answer) {
271
+ const item = this.thread.get(answer.itemId);
272
+ if (item?.kind !== 'assistant') {
273
+ return;
274
+ }
275
+ if (JSON.stringify(item.uiAnswers?.[answer.blockId]) === JSON.stringify(answer)) {
276
+ return;
277
+ }
278
+ this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
279
+ this.options.persist();
280
+ }
164
281
  sendInput(text, extras = {}) {
165
282
  return this.submitInput(text, extras);
166
283
  }
@@ -289,7 +406,7 @@ export class ChatSession {
289
406
  this.sendAfterReplacement = false;
290
407
  this.pauseQueue(false);
291
408
  this.setQueue(rest);
292
- this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments }, next.turnId);
409
+ this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice }, next.turnId);
293
410
  }
294
411
  dispatch(text, extras, requestedTurnId) {
295
412
  this.pauseQueue(false);
@@ -298,7 +415,10 @@ export class ChatSession {
298
415
  // A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
299
416
  const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
300
417
  const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
301
- const said = [preamble, references.note].filter((part) => part !== null);
418
+ const choiceNote = extras.uiChoice
419
+ ? `The person chose ${JSON.stringify(extras.uiChoice.label)} from your UI reply. Their message is the visible context of that choice.`
420
+ : null;
421
+ const said = [preamble, references.note, choiceNote].filter((part) => part !== null);
302
422
  const input = {
303
423
  text,
304
424
  preamble: said.length === 0 ? null : said.join('\n\n'),
@@ -410,14 +530,14 @@ export class ChatSession {
410
530
  }
411
531
  /*
412
532
  * Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
413
- * A turn in the way is refused unless forced, and a forced clear does not wait for it to end,
414
- * since the turn disappears with the thread anyway. Writing the empty thread is the caller's.
533
+ * The thread resets at once; the returned promise lets the host wait for pending attachment
534
+ * writes before removing their files. Writing the empty thread is the caller's.
415
535
  */
416
536
  clear(force) {
417
537
  if (this.busy && !force) {
418
538
  throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
419
539
  }
420
- void this.dispose();
540
+ const ended = this.dispose();
421
541
  this.generation = nextGeneration();
422
542
  this.projector.reset();
423
543
  // The fresh CLI hears what the host tells it at launch, as it would on a first turn.
@@ -456,6 +576,7 @@ export class ChatSession {
456
576
  }
457
577
  })
458
578
  ]);
579
+ return ended;
459
580
  }
460
581
  /*
461
582
  * A thread read from disk cannot still be streaming or waiting, so whatever was open is closed,
@@ -836,6 +957,7 @@ export class ChatSession {
836
957
  }
837
958
  /* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
838
959
  freeze() {
960
+ this.clearUi();
839
961
  this.clearStartWarning();
840
962
  this.clearWorkflowWarnings();
841
963
  this.frozen = true;
@@ -843,6 +965,7 @@ export class ChatSession {
843
965
  }
844
966
  /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
845
967
  dispose() {
968
+ this.clearUi();
846
969
  this.clearStartWarning();
847
970
  this.clearWorkflowWarnings();
848
971
  if (this.titleTimer !== null) {
@@ -988,10 +1111,24 @@ export class ChatSession {
988
1111
  const skills = extras.skills?.length ? extras.skills : undefined;
989
1112
  const chats = extras.chats?.length ? extras.chats : undefined;
990
1113
  const attachments = extras.attachments?.length ? extras.attachments : undefined;
991
- events.push(this.thread.upsert({ id: newId('user'), kind: 'user', createdAt: now, turnId, text, mentions, skills, chats, attachments }));
1114
+ events.push(this.thread.upsert({
1115
+ id: newId('user'),
1116
+ kind: 'user',
1117
+ createdAt: now,
1118
+ turnId,
1119
+ text,
1120
+ mentions,
1121
+ skills,
1122
+ chats,
1123
+ attachments,
1124
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
1125
+ }));
992
1126
  }
993
1127
  events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
994
1128
  this.emit(events);
1129
+ if (extras.uiChoice) {
1130
+ this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
1131
+ }
995
1132
  // On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
996
1133
  this.options.persist();
997
1134
  return turnId;
@@ -1206,6 +1343,7 @@ export class ChatSession {
1206
1343
  runtimeMode: info.runtimeMode,
1207
1344
  resume: info.agentSessionId,
1208
1345
  generation,
1346
+ saveGeneratedImage: this.options.saveGeneratedImage,
1209
1347
  instructions: this.options.instructions?.() ?? null,
1210
1348
  resumeNote: this.options.resumeNote?.() ?? null,
1211
1349
  ...(folders.length > 0 ? { folders } : {}),
@@ -1486,7 +1624,7 @@ export class ChatSession {
1486
1624
  this.naming = true;
1487
1625
  const turnId = done[0].id;
1488
1626
  const answer = items
1489
- .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? item.text : ''))
1627
+ .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? readableAssistantText(item) : ''))
1490
1628
  .filter((text) => text !== '')
1491
1629
  .join('\n\n');
1492
1630
  const agentSessionId = this.thread.info.agentSessionId;
@@ -1536,9 +1674,121 @@ export class ChatSession {
1536
1674
  }
1537
1675
  emit(events) {
1538
1676
  const delegating = this.delegatingChange(events);
1539
- for (const event of delegating === null ? events : [...events, delegating]) {
1540
- this.options.emit(event);
1677
+ for (let event of delegating === null ? events : [...events, delegating]) {
1678
+ if (event.type === 'reset') {
1679
+ this.clearUi();
1680
+ }
1681
+ if (event.type === 'item' && event.item.kind === 'tool') {
1682
+ this.uiAttachments.delete(event.item.turnId);
1683
+ }
1684
+ const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
1685
+ if (item?.kind !== 'assistant' || item.parentToolUseId) {
1686
+ this.options.emit(event);
1687
+ continue;
1688
+ }
1689
+ const tail = this.unscannedUiText(item, event.type === 'item');
1690
+ if (uiMayReferenceHost(tail)) {
1691
+ this.uiReferencing.add(item.id);
1692
+ }
1693
+ let stream = this.uiStreams.get(item.id);
1694
+ if (!stream && !uiHasFence(tail, this.uiFenceLanguage)) {
1695
+ this.forgetUiScan(item);
1696
+ this.options.emit(event);
1697
+ continue;
1698
+ }
1699
+ if (!stream) {
1700
+ stream = new UiStream({
1701
+ querySchemas: this.options.ui?.schemas,
1702
+ fenceLanguage: this.uiFenceLanguage,
1703
+ id: item.id,
1704
+ clock: this.options.clock ?? systemClock,
1705
+ emit: (preview) => {
1706
+ this.uiCompiled.set(item.id, preview);
1707
+ this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
1708
+ }
1709
+ });
1710
+ this.uiStreams.set(item.id, stream);
1711
+ }
1712
+ if (this.uiReferencing.has(item.id)) {
1713
+ this.options.ui?.observe(item, false);
1714
+ }
1715
+ this.forgetUiScan(item);
1716
+ const latestAttachment = this.latestUiAttachment(item.turnId);
1717
+ if (event.type === 'item' && !item.streaming) {
1718
+ const ui = stream
1719
+ .finish(item.text, latestAttachment)
1720
+ .map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
1721
+ this.uiStreams.delete(item.id);
1722
+ this.uiCompiled.delete(item.id);
1723
+ const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
1724
+ if (diagnostics.length > 0) {
1725
+ const repair = `Your last ${this.uiFenceLanguage} block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(0, 1200);
1726
+ if (!this.pendingPreambles.includes(repair)) {
1727
+ this.pendingPreambles = [...this.pendingPreambles, repair];
1728
+ }
1729
+ }
1730
+ const current = this.thread.get(item.id);
1731
+ const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
1732
+ event = this.thread.upsert(completed);
1733
+ this.options.emit(event);
1734
+ this.options.ui?.observe(completed, true);
1735
+ }
1736
+ else {
1737
+ this.options.emit(event);
1738
+ stream.update(item.text, latestAttachment);
1739
+ }
1740
+ }
1741
+ }
1742
+ /*
1743
+ * The text a scan has not covered yet, from the start of the line the last scan ended in, since a fence
1744
+ * is one line; a long line is cut short, behind a character no fence or tag can follow.
1745
+ */
1746
+ unscannedUiText(item, whole) {
1747
+ const scanned = whole ? 0 : (this.uiScanned.get(item.id) ?? 0);
1748
+ this.uiScanned.set(item.id, item.text.length);
1749
+ if (scanned === 0 || scanned > item.text.length) {
1750
+ return item.text;
1751
+ }
1752
+ const line = item.text.lastIndexOf('\n', scanned - 1) + 1;
1753
+ const from = Math.max(line, scanned - UI_SCAN_OVERLAP);
1754
+ return from === line ? item.text.slice(from) : `\0${item.text.slice(from)}`;
1755
+ }
1756
+ forgetUiScan(item) {
1757
+ if (!item.streaming) {
1758
+ this.uiScanned.delete(item.id);
1759
+ this.uiReferencing.delete(item.id);
1760
+ }
1761
+ }
1762
+ /* Looked up once per turn, and again only after one of its tool calls changed. */
1763
+ latestUiAttachment(turnId) {
1764
+ if (this.uiAttachments.has(turnId)) {
1765
+ return this.uiAttachments.get(turnId);
1766
+ }
1767
+ const attachment = this.findUiAttachment(turnId);
1768
+ this.uiAttachments.set(turnId, attachment);
1769
+ return attachment;
1770
+ }
1771
+ findUiAttachment(turnId) {
1772
+ for (const item of this.thread.list().toReversed()) {
1773
+ if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
1774
+ continue;
1775
+ }
1776
+ const input = GeneratedImageInputSchema.safeParse(item.input);
1777
+ if (input.success && input.data.attachment?.mime.startsWith('image/')) {
1778
+ return input.data.attachment.id;
1779
+ }
1780
+ }
1781
+ return undefined;
1782
+ }
1783
+ clearUi() {
1784
+ for (const stream of this.uiStreams.values()) {
1785
+ stream.dispose();
1541
1786
  }
1787
+ this.uiStreams.clear();
1788
+ this.uiCompiled.clear();
1789
+ this.uiScanned.clear();
1790
+ this.uiReferencing.clear();
1791
+ this.uiAttachments.clear();
1542
1792
  }
1543
1793
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
1544
1794
  delegatingChange(events) {
@@ -8,6 +8,7 @@ export interface ChatRecord {
8
8
  seq: number;
9
9
  resetSeq: number;
10
10
  preambles: string[];
11
+ uiAccess: Record<string, unknown>;
11
12
  extras: ChatRecordExtras;
12
13
  lines: ChatLogLine[];
13
14
  }
@@ -28,8 +29,8 @@ export declare class ChatStore {
28
29
  list(): Promise<string[]>;
29
30
  has(chatId: string): Promise<boolean>;
30
31
  discardLog(chatId: string): Promise<number>;
31
- write(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras): Promise<number>;
32
- writeSync(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras): void;
32
+ write(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras, uiAccess?: Readonly<Record<string, unknown>>): Promise<number>;
33
+ writeSync(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras, uiAccess?: Readonly<Record<string, unknown>>): void;
33
34
  read(chatId: string): Promise<ChatRecord | null>;
34
35
  unreadable(chatId: string): Promise<boolean>;
35
36
  private readSnapshot;
@@ -19,17 +19,40 @@ const RecordSchema = z.looseObject({
19
19
  seq: z.number().int().nonnegative().optional(),
20
20
  resetSeq: z.number().int().nonnegative().optional(),
21
21
  // Never on the wire: what goes in front of the next real prompt, once (a fork's note for its agent).
22
- preambles: z.array(z.string()).optional()
22
+ preambles: z.array(z.string()).optional(),
23
+ // Never on the wire: the access a UI host captured for each reply that reads it, by item id.
24
+ uiAccess: z.record(z.string(), z.unknown()).optional()
23
25
  });
24
- const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles']);
26
+ const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles', 'uiAccess']);
25
27
  function extrasOf(record) {
26
28
  return Object.fromEntries(Object.entries(record).filter(([key]) => !OWN_KEYS.has(key)));
27
29
  }
28
30
  function logName(chatId) {
29
31
  return `${encodeURIComponent(chatId)}.log`;
30
32
  }
31
- function recordBody(info, items, at, preambles, extras) {
32
- return JSON.stringify({ info, items, ...at, ...(preambles.length === 0 ? {} : { preambles }), ...withoutOwnKeys(extras) });
33
+ function recordBody(info, items, at, preambles, extras, uiAccess) {
34
+ return JSON.stringify({
35
+ info,
36
+ items,
37
+ ...at,
38
+ ...(preambles.length === 0 ? {} : { preambles }),
39
+ ...(Object.keys(uiAccess).length === 0 ? {} : { uiAccess }),
40
+ ...withoutOwnKeys(extras)
41
+ });
42
+ }
43
+ /* A record from before `uiAccess` kept the access inside each reply's `uiQueries`, which the item schema now drops. */
44
+ function liftUiAccess(record) {
45
+ if (typeof record !== 'object' || record === null || 'uiAccess' in record || !Array.isArray(record.items)) {
46
+ return record;
47
+ }
48
+ const uiAccess = {};
49
+ for (const item of record.items) {
50
+ const queries = item?.uiQueries;
51
+ if (typeof queries === 'object' && queries !== null && 'access' in queries) {
52
+ uiAccess[String(item.id)] = queries.access;
53
+ }
54
+ }
55
+ return Object.keys(uiAccess).length === 0 ? record : { ...record, uiAccess };
33
56
  }
34
57
  function withoutOwnKeys(extras) {
35
58
  return Object.fromEntries(Object.entries(extras).filter(([key]) => !OWN_KEYS.has(key)));
@@ -80,8 +103,8 @@ export class ChatStore {
80
103
  return lines.at(-1)?.seq ?? 0;
81
104
  }
82
105
  /* Answers how many bytes the record took, which is what tells a caller whether writing it is cheap. */
83
- async write(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}) {
84
- const body = recordBody(info, items, at, preambles, extras);
106
+ async write(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}, uiAccess = {}) {
107
+ const body = recordBody(info, items, at, preambles, extras, uiAccess);
85
108
  await mkdir(this.dir, { recursive: true, mode: 0o700 });
86
109
  await writeAtomic(join(this.dir, recordFileName(chatId)), body);
87
110
  return body.length;
@@ -90,9 +113,9 @@ export class ChatStore {
90
113
  * The same write without a turn of the event loop. A `bun --watch` reload restarts the module
91
114
  * before an awaited write comes back, so a shutdown has to put the threads down synchronously.
92
115
  */
93
- writeSync(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}) {
116
+ writeSync(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}, uiAccess = {}) {
94
117
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
95
- writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras));
118
+ writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras, uiAccess));
96
119
  }
97
120
  /* Null for a chat with nothing on disk to make it of, and for one whose record does not parse (see `unreadable`). */
98
121
  async read(chatId) {
@@ -117,7 +140,15 @@ export class ChatStore {
117
140
  resetSeq = line.seq;
118
141
  }
119
142
  }
120
- return { ...thread.snapshot(), seq, resetSeq, preambles: snapshot.preambles ?? [], extras: extrasOf(snapshot), lines };
143
+ return {
144
+ ...thread.snapshot(),
145
+ seq,
146
+ resetSeq,
147
+ preambles: snapshot.preambles ?? [],
148
+ uiAccess: snapshot.uiAccess ?? {},
149
+ extras: extrasOf(snapshot),
150
+ lines
151
+ };
121
152
  }
122
153
  /*
123
154
  * Whether a record is on disk that this host cannot read: one a newer version wrote, one cut short,
@@ -146,7 +177,7 @@ export class ChatStore {
146
177
  await writeAtomic(join(this.dir, recordFileName(chatId)), JSON.stringify(migrated));
147
178
  }
148
179
  }
149
- const parsed = RecordSchema.safeParse(record);
180
+ const parsed = RecordSchema.safeParse(liftUiAccess(record));
150
181
  return parsed.success ? parsed.data : UNREADABLE;
151
182
  }
152
183
  async delete(chatId) {
@@ -199,5 +230,5 @@ function fromLogAlone(lines) {
199
230
  resetSeq = line.seq;
200
231
  }
201
232
  }
202
- return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], extras: {}, lines };
233
+ return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], uiAccess: {}, extras: {}, lines };
203
234
  }
@@ -9,6 +9,7 @@ export declare class CodexBackend implements ChatBackend {
9
9
  readonly acknowledgesTurns = true;
10
10
  private promptId;
11
11
  private readonly launch;
12
+ private imageWrites;
12
13
  private readonly host;
13
14
  private readonly protocol;
14
15
  private readonly client;
@@ -50,4 +51,5 @@ export declare class CodexBackend implements ChatBackend {
50
51
  private handleFrame;
51
52
  private handleExit;
52
53
  private emit;
54
+ private storeImage;
53
55
  }
@@ -39,6 +39,7 @@ export class CodexBackend {
39
39
  acknowledgesTurns = true;
40
40
  promptId = null;
41
41
  launch;
42
+ imageWrites = null;
42
43
  host;
43
44
  protocol;
44
45
  client;
@@ -321,7 +322,7 @@ export class CodexBackend {
321
322
  dispose() {
322
323
  const transport = this.transport;
323
324
  this.transport = null;
324
- return transport?.dispose() ?? Promise.resolve();
325
+ return Promise.all([transport?.dispose(), this.imageWrites]).then(() => undefined);
325
326
  }
326
327
  /* A request whose failure the person has to hear about, since it is the turn that cannot go on. */
327
328
  request(method, params, accepted) {
@@ -381,6 +382,55 @@ export class CodexBackend {
381
382
  this.interruptOwed = false;
382
383
  this.promptId = null;
383
384
  }
385
+ if (event.type === 'image.generated' || this.imageWrites !== null) {
386
+ const pending = (this.imageWrites ?? Promise.resolve()).then(async () => {
387
+ if (event.type === 'image.generated') {
388
+ await this.storeImage(event);
389
+ }
390
+ else {
391
+ this.host.onEvent(event);
392
+ }
393
+ });
394
+ this.imageWrites = pending;
395
+ void pending
396
+ .finally(() => {
397
+ if (this.imageWrites === pending) {
398
+ this.imageWrites = null;
399
+ }
400
+ })
401
+ .catch(() => undefined);
402
+ return;
403
+ }
384
404
  this.host.onEvent(event);
385
405
  }
406
+ async storeImage(event) {
407
+ const input = { revisedPrompt: event.revisedPrompt, transparentBackground: event.transparentBackground };
408
+ let attachment;
409
+ try {
410
+ if (!this.launch.saveGeneratedImage) {
411
+ throw new Error('This machine cannot save generated images');
412
+ }
413
+ attachment = await this.launch.saveGeneratedImage(event.ref, event.upload);
414
+ }
415
+ catch (error) {
416
+ this.host.onEvent({ type: 'tool.started', ref: event.ref, name: 'ImageGeneration', input, parentRef: null });
417
+ this.host.onEvent({ type: 'tool.done', ref: event.ref, output: errorText(error), state: 'error' });
418
+ return;
419
+ }
420
+ this.host.onEvent({
421
+ type: 'tool.started',
422
+ ref: event.ref,
423
+ name: 'ImageGeneration',
424
+ input: {
425
+ ...input,
426
+ attachment: {
427
+ ...attachment,
428
+ ...(event.width === undefined ? {} : { width: event.width }),
429
+ ...(event.height === undefined ? {} : { height: event.height })
430
+ }
431
+ },
432
+ parentRef: null
433
+ });
434
+ this.host.onEvent({ type: 'tool.done', ref: event.ref, output: null, state: 'done' });
435
+ }
386
436
  }
@@ -1,3 +1,4 @@
1
+ import { generatedImage } from './generated-image.js';
1
2
  import { readCodexLimits } from '../usage/limits/normalize.js';
2
3
  import { codexRuntimeMode } from '../providers/codex.js';
3
4
  import { cleanTitle } from '../title-file.js';
@@ -567,7 +568,7 @@ export class CodexProtocol {
567
568
  events.push({ type: 'question.requested', requestId: ref, questions, async: true });
568
569
  return;
569
570
  }
570
- this.text(ref, str(item.text) ?? '', completed, events);
571
+ this.text(ref, str(item.text) ?? '', completed, events, str(item.phase) ?? undefined);
571
572
  return;
572
573
  }
573
574
  case 'plan':
@@ -616,6 +617,32 @@ export class CodexProtocol {
616
617
  case 'dynamicToolCall':
617
618
  this.tool(ref, str(item.tool) ?? 'tool', item.arguments ?? {}, completed, textOf(item.contentItems), item.success !== false, events);
618
619
  return;
620
+ case 'imageGeneration': {
621
+ const revisedPrompt = str(item.revisedPrompt) ?? undefined;
622
+ const transparentBackground = typeof item.transparentBackground === 'boolean' ? item.transparentBackground : undefined;
623
+ const input = {
624
+ ...(revisedPrompt === undefined ? {} : { revisedPrompt }),
625
+ ...(transparentBackground === undefined ? {} : { transparentBackground })
626
+ };
627
+ if (!completed) {
628
+ this.tool(ref, 'ImageGeneration', input, false, '', true, events);
629
+ return;
630
+ }
631
+ try {
632
+ if (item.failure || item.status !== 'completed') {
633
+ throw new Error(isRecord(item.failure) ? (str(item.failure.message) ?? 'Image generation failed') : (str(item.failure) ?? 'Image generation failed'));
634
+ }
635
+ const image = generatedImage(str(item.result) ?? '', revisedPrompt);
636
+ events.push({ type: 'image.generated', ref, ...image, ...input });
637
+ }
638
+ catch (error) {
639
+ this.tool(ref, 'ImageGeneration', input, true, error instanceof Error ? error.message : 'Image generation failed', false, events);
640
+ }
641
+ return;
642
+ }
643
+ case 'imageView':
644
+ this.tool(ref, 'ImageView', { path: str(item.path) ?? '' }, completed, '', true, events);
645
+ return;
619
646
  case 'webSearch': {
620
647
  const action = isRecord(item.action) ? item.action : {};
621
648
  this.tool(ref, 'WebSearch', { query: str(item.query) ?? str(action.query) ?? '' }, completed, '', true, events);
@@ -683,8 +710,8 @@ export class CodexProtocol {
683
710
  }
684
711
  return null;
685
712
  }
686
- text(ref, text, completed, events) {
687
- events.push(completed ? { type: 'text.done', ref, text } : { type: 'text.delta', ref, text });
713
+ text(ref, text, completed, events, phase) {
714
+ events.push({ type: completed ? 'text.done' : 'text.delta', ref, text, ...(phase === undefined ? {} : { phase }) });
688
715
  }
689
716
  /*
690
717
  * An agent Codex spawned, as a spawnAgent call reports it. It works in a thread of its own, so this row
@@ -16,7 +16,7 @@ export class DeltaCoalescer {
16
16
  this.tickMs = tickMs;
17
17
  }
18
18
  push(event) {
19
- if (event.type !== 'delta') {
19
+ if (event.type !== 'delta' || event.uiQueries !== undefined) {
20
20
  this.flush();
21
21
  this.send(event);
22
22
  return;
@@ -1,4 +1,4 @@
1
1
  import { CodedError } from '../coded-error.ts';
2
- export type ChatErrorCode = 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
2
+ export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
3
3
  export declare class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {
4
4
  }