@adecore/agents 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) 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 +13 -1
  4. package/dist/chat/chat-core.d.ts +53 -1
  5. package/dist/chat/chat-core.js +53 -10
  6. package/dist/chat/chat-session.d.ts +29 -2
  7. package/dist/chat/chat-session.js +258 -15
  8. package/dist/chat/claude-backend.d.ts +4 -0
  9. package/dist/chat/claude-backend.js +39 -0
  10. package/dist/chat/claude-protocol.d.ts +2 -0
  11. package/dist/chat/claude-protocol.js +12 -1
  12. package/dist/chat/codex-backend.d.ts +4 -0
  13. package/dist/chat/codex-backend.js +77 -5
  14. package/dist/chat/codex-protocol.d.ts +1 -0
  15. package/dist/chat/codex-protocol.js +74 -5
  16. package/dist/chat/delta-coalescer.js +1 -1
  17. package/dist/chat/errors.d.ts +1 -1
  18. package/dist/chat/fake-codex.js +33 -0
  19. package/dist/chat/generated-image.d.ts +6 -0
  20. package/dist/chat/generated-image.js +74 -0
  21. package/dist/chat/projector.js +9 -2
  22. package/dist/chat/thread.js +6 -0
  23. package/dist/chat/ui-queries.d.ts +38 -0
  24. package/dist/chat/ui-queries.js +311 -0
  25. package/dist/host/handlers.js +4 -1
  26. package/package.json +5 -4
  27. package/src/chat/attachment-store.ts +38 -2
  28. package/src/chat/backend.ts +16 -2
  29. package/src/chat/chat-core.ts +60 -9
  30. package/src/chat/chat-session.ts +294 -16
  31. package/src/chat/claude-backend.ts +40 -0
  32. package/src/chat/claude-protocol.ts +16 -1
  33. package/src/chat/codex-backend.ts +77 -5
  34. package/src/chat/codex-protocol.ts +78 -6
  35. package/src/chat/delta-coalescer.ts +1 -1
  36. package/src/chat/errors.ts +2 -0
  37. package/src/chat/fake-codex.ts +34 -0
  38. package/src/chat/generated-image.ts +88 -0
  39. package/src/chat/projector.ts +9 -2
  40. package/src/chat/thread.ts +6 -0
  41. package/src/chat/ui-queries.ts +360 -0
  42. package/src/host/handlers.ts +9 -1
@@ -1,6 +1,7 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import type {
3
3
  ChatAttachment,
4
+ ChatAssistantItem,
4
5
  ChatCheckpointDiff,
5
6
  ChatEvent,
6
7
  ChatInfo,
@@ -9,10 +10,17 @@ import type {
9
10
  ChatSkill,
10
11
  ChatSubagentItem,
11
12
  ChatTurnItem,
13
+ ChatUiAnswer,
14
+ ChatUiChoiceOrigin,
15
+ ChatUiChoicePayload,
16
+ ChatUiQueryState,
12
17
  ModelSelection,
13
18
  RuntimeMode
14
19
  } from '@adecore/agent-contracts';
15
- import { notResumedNote } from '@adecore/agent-contracts';
20
+ import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
21
+ import { resolveUiChoice, UiFailure } from '@adecore/intelligent-ui';
22
+ import type { UiCompileOptions } from '@adecore/intelligent-ui/compiler';
23
+ import { UiStream, type UiStreamPreview } from '@adecore/intelligent-ui/stream';
16
24
  import type { ChatProvider } from '../providers/provider.ts';
17
25
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
18
26
  import type { BackendEvent, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
@@ -58,6 +66,7 @@ export interface ChatSessionOptions {
58
66
  /* The environment of the CLI under the chat's account, asked at every start; throws for an account that cannot start. */
59
67
  env(account: string | undefined): Record<string, string>;
60
68
  spawn?: SpawnChatProcess;
69
+ saveGeneratedImage?: BackendLaunch['saveGeneratedImage'];
61
70
  // What the agent is told at the start of every process, and what of it a resumed thread hears again; see `BackendLaunch`.
62
71
  instructions?(): string | null;
63
72
  resumeNote?(): string | null;
@@ -67,6 +76,10 @@ export interface ChatSessionOptions {
67
76
  references?: ChatReferences;
68
77
  // Trees per turn, so a settled turn can show what the working tree holds against its start.
69
78
  checkpoints?: TurnCheckpoints;
79
+ ui?: {
80
+ schemas: UiCompileOptions['querySchemas'];
81
+ observe(item: ChatAssistantItem, final: boolean): void;
82
+ };
70
83
  emit(event: ChatEvent): void;
71
84
  /* What a turn said about the plan it runs on. It belongs to the machine, so it leaves the chat. */
72
85
  onLimits?(update: LimitsUpdate): void;
@@ -98,6 +111,8 @@ export interface LimitResumeHooks {
98
111
  const TITLE_RECHECK_MS = 10_000;
99
112
 
100
113
  export interface ChatSendExtras {
114
+ uiChoice?: ChatUiChoiceOrigin;
115
+ delivery?: 'steer' | 'queue';
101
116
  mentions?: string[];
102
117
  skills?: string[];
103
118
  chats?: string[];
@@ -175,6 +190,9 @@ export class ChatSession {
175
190
  private readonly orphans = new Set<string>();
176
191
  private pendingPreambles: readonly string[];
177
192
  private submission: { turnId: string; input: TurnInput } | null = null;
193
+ private readonly steering = new Set<string>();
194
+ private readonly uiStreams = new Map<string, UiStream>();
195
+ private readonly uiCompiled = new Map<string, UiStreamPreview>();
178
196
 
179
197
  constructor(options: ChatSessionOptions) {
180
198
  this.options = options;
@@ -183,6 +201,24 @@ export class ChatSession {
183
201
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
184
202
  }
185
203
 
204
+ get uiPreviews(): ChatEvent[] {
205
+ const events: ChatEvent[] = [];
206
+ for (const [itemId, preview] of this.uiCompiled) {
207
+ const item = this.thread.get(itemId);
208
+ if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
209
+ events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
210
+ }
211
+ }
212
+ return events;
213
+ }
214
+
215
+ previewItems(items: ChatItem[]): ChatItem[] {
216
+ return items.map((item) => {
217
+ const preview = this.uiCompiled.get(item.id);
218
+ return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
219
+ });
220
+ }
221
+
186
222
  get id(): string {
187
223
  return this.thread.info.chatId;
188
224
  }
@@ -249,9 +285,7 @@ export class ChatSession {
249
285
  }
250
286
 
251
287
  /*
252
- * A message while a turn runs joins the queue instead of being refused; the host sends it when
253
- * that turn settles. One queue for both providers: Claude's steer and Codex's own queue have
254
- * different semantics, and one rule is easier to reason about than a rule per CLI.
288
+ * Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
255
289
  */
256
290
  send(text: string, extras: ChatSendExtras = {}): { queued: boolean; turnId: string } {
257
291
  const turnId = newId('turn');
@@ -265,6 +299,7 @@ export class ChatSession {
265
299
  turnId,
266
300
  text,
267
301
  createdAt: Date.now(),
302
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
268
303
  ...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
269
304
  ...(extras.skills?.length ? { skills: extras.skills } : {}),
270
305
  ...(extras.chats?.length ? { chats: extras.chats } : {}),
@@ -279,11 +314,153 @@ export class ChatSession {
279
314
  return { queued: false, turnId };
280
315
  }
281
316
 
317
+ updateUiQueries(itemId: string, uiQueries: ChatUiQueryState): void {
318
+ const item = this.thread.get(itemId);
319
+ if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
320
+ return;
321
+ }
322
+ this.thread.upsert({ ...item, uiQueries });
323
+ this.options.emit({ type: 'delta', itemId, text: '', uiQueries });
324
+ this.options.persistSoon();
325
+ }
326
+
327
+ choose(payload: ChatUiChoicePayload, queries: Readonly<Record<string, unknown>> = {}): { queued: boolean; turnId: string } {
328
+ const item = this.thread.get(payload.itemId);
329
+ const block =
330
+ item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((block) => block.id === payload.blockId) : undefined;
331
+ if (item?.kind !== 'assistant' || !block?.revision || block.revision !== payload.revision) {
332
+ throw new ChatError('stale-ui-block', 'This completed UI block is no longer available at the requested revision');
333
+ }
334
+ const sameBlock = (origin: ChatUiChoiceOrigin | undefined) =>
335
+ origin?.itemId === item.id && origin.blockId === block.id && origin.revision === block.revision;
336
+ let previous = item.uiAnswers?.[block.id];
337
+ if (previous?.revision !== block.revision) {
338
+ previous = undefined;
339
+ }
340
+ // The message is logged first: a crash before its answer marker must not send it a second time.
341
+ if (!previous) {
342
+ const sent = this.thread.list().find((entry) => entry.kind === 'user' && sameBlock(entry.uiChoice));
343
+ const queued = this.queue.find((entry) => sameBlock(entry.uiChoice));
344
+ const origin = sent?.kind === 'user' ? sent.uiChoice : queued?.uiChoice;
345
+ const turnId = sent?.turnId ?? queued?.turnId;
346
+ if (origin && turnId) {
347
+ previous = { ...origin, turnId, queued: !sent };
348
+ this.recordUiAnswer(previous);
349
+ }
350
+ }
351
+ if (previous) {
352
+ if (previous.choiceId !== payload.choiceId) {
353
+ throw new ChatError('ui-already-answered', 'This UI block was already answered');
354
+ }
355
+ return { queued: previous.queued, turnId: previous.turnId };
356
+ }
357
+ let selected;
358
+ try {
359
+ selected = resolveUiChoice(block, payload.choiceId, payload.values, queries);
360
+ } catch (error) {
361
+ throw new ChatError(
362
+ error instanceof UiFailure ? error.code : 'refused-choice',
363
+ error instanceof Error ? error.message : 'This choice could not be read'
364
+ );
365
+ }
366
+ const origin: ChatUiChoiceOrigin = {
367
+ itemId: item.id,
368
+ blockId: block.id,
369
+ revision: block.revision,
370
+ choiceId: payload.choiceId,
371
+ label: selected.label,
372
+ sourceAt: item.createdAt,
373
+ older: this.thread.list().findLast((entry) => entry.kind === 'assistant' && !entry.parentToolUseId)?.id !== item.id,
374
+ values: selected.values,
375
+ at: Date.now()
376
+ };
377
+ const result = this.send(selected.context, { uiChoice: origin });
378
+ this.recordUiAnswer({ ...origin, ...result });
379
+ return result;
380
+ }
381
+
382
+ private recordUiAnswer(answer: ChatUiAnswer): void {
383
+ const item = this.thread.get(answer.itemId);
384
+ if (item?.kind !== 'assistant') {
385
+ return;
386
+ }
387
+ if (JSON.stringify(item.uiAnswers?.[answer.blockId]) === JSON.stringify(answer)) {
388
+ return;
389
+ }
390
+ this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
391
+ this.options.persist();
392
+ }
393
+
394
+ sendInput(text: string, extras: ChatSendExtras = {}): Promise<{ queued: boolean; turnId: string }> {
395
+ return this.submitInput(text, extras);
396
+ }
397
+
398
+ private async submitInput(text: string, extras: ChatSendExtras): Promise<{ queued: boolean; turnId: string }> {
399
+ const backend = this.backend;
400
+ const turnId = this.info.activeTurnId;
401
+ if (
402
+ extras.delivery !== 'steer' ||
403
+ this.info.queuePaused ||
404
+ text.startsWith('/') ||
405
+ !this.busy ||
406
+ !turnId ||
407
+ !backend?.steerTurn ||
408
+ this.pendingStart ||
409
+ this.replacementWaits()
410
+ ) {
411
+ return this.send(text, extras);
412
+ }
413
+ const queued = this.send(text, extras);
414
+ const message = this.queue.find((entry) => entry.turnId === queued.turnId);
415
+ if (!message) {
416
+ return queued;
417
+ }
418
+ this.steering.add(message.id);
419
+ try {
420
+ const references = this.options.references?.(extras.chats) ?? { ids: extras.chats ?? [], note: null };
421
+ const accepted = await backend.steerTurn({
422
+ promptId: message.id,
423
+ text,
424
+ preamble: references.note,
425
+ attachments: extras.attachments ?? [],
426
+ mentions: extras.mentions ?? [],
427
+ skills: extras.skills ?? []
428
+ });
429
+ if (!accepted) {
430
+ return queued;
431
+ }
432
+ this.emit([
433
+ this.thread.upsert({
434
+ id: `user-${message.id}`,
435
+ kind: 'user',
436
+ createdAt: message.createdAt,
437
+ turnId,
438
+ text,
439
+ mentions: extras.mentions,
440
+ skills: extras.skills,
441
+ chats: references.ids,
442
+ attachments: extras.attachments
443
+ }),
444
+ this.thread.patchInfo({ queue: this.queue.filter((entry) => entry.id !== message.id) })
445
+ ]);
446
+ this.options.persist();
447
+ return { queued: false, turnId };
448
+ } catch (error) {
449
+ // A lost acknowledgement does not prove rejection. Keep the message for an explicit retry.
450
+ this.pauseQueue(true);
451
+ this.options.persist();
452
+ throw error;
453
+ } finally {
454
+ this.steering.delete(message.id);
455
+ this.drainQueue();
456
+ }
457
+ }
458
+
282
459
  /* Drops a queued message and hands it back; null when nothing waits under that id, as once it went out. */
283
460
  unqueue(messageId: string): ChatQueuedMessage | null {
284
461
  const queue = this.queue;
285
462
  const message = queue.find((entry) => entry.id === messageId);
286
- if (!message) {
463
+ if (!message || this.steering.has(message.id)) {
287
464
  return null;
288
465
  }
289
466
  this.setQueue(queue.filter((entry) => entry.id !== messageId));
@@ -294,7 +471,7 @@ export class ChatSession {
294
471
  sendNow(messageId: string): boolean {
295
472
  const queue = this.queue;
296
473
  const message = queue.find((entry) => entry.id === messageId);
297
- if (!message) {
474
+ if (!message || this.steering.has(message.id)) {
298
475
  return false;
299
476
  }
300
477
  this.setQueue([message, ...queue.filter((entry) => entry.id !== messageId)]);
@@ -323,6 +500,7 @@ export class ChatSession {
323
500
  const explicit = asked || this.sendAfterReplacement;
324
501
  if (
325
502
  !next ||
503
+ this.steering.size > 0 ||
326
504
  this.busy ||
327
505
  (!explicit && (this.info.queuePaused || (limited !== null && (limited.limit.kind === 'usage' || this.info.resumeAt !== undefined))))
328
506
  ) {
@@ -349,7 +527,11 @@ export class ChatSession {
349
527
  this.sendAfterReplacement = false;
350
528
  this.pauseQueue(false);
351
529
  this.setQueue(rest);
352
- this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments }, next.turnId);
530
+ this.dispatch(
531
+ next.text,
532
+ { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice },
533
+ next.turnId
534
+ );
353
535
  }
354
536
 
355
537
  private dispatch(text: string, extras: ChatSendExtras, requestedTurnId?: string): void {
@@ -359,7 +541,10 @@ export class ChatSession {
359
541
  // A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
360
542
  const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
361
543
  const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
362
- const said = [preamble, references.note].filter((part): part is string => part !== null);
544
+ const choiceNote = extras.uiChoice
545
+ ? `The person chose ${JSON.stringify(extras.uiChoice.label)} from your UI reply. Their message is the visible context of that choice.`
546
+ : null;
547
+ const said = [preamble, references.note, choiceNote].filter((part): part is string => part !== null);
363
548
  const input = {
364
549
  text,
365
550
  preamble: said.length === 0 ? null : said.join('\n\n'),
@@ -478,14 +663,14 @@ export class ChatSession {
478
663
 
479
664
  /*
480
665
  * Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
481
- * A turn in the way is refused unless forced, and a forced clear does not wait for it to end,
482
- * since the turn disappears with the thread anyway. Writing the empty thread is the caller's.
666
+ * The thread resets at once; the returned promise lets the host wait for pending attachment
667
+ * writes before removing their files. Writing the empty thread is the caller's.
483
668
  */
484
- clear(force: boolean): void {
669
+ clear(force: boolean): Promise<void> {
485
670
  if (this.busy && !force) {
486
671
  throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
487
672
  }
488
- void this.dispose();
673
+ const ended = this.dispose();
489
674
  this.generation = nextGeneration();
490
675
  this.projector.reset();
491
676
  // The fresh CLI hears what the host tells it at launch, as it would on a first turn.
@@ -524,6 +709,7 @@ export class ChatSession {
524
709
  }
525
710
  })
526
711
  ]);
712
+ return ended;
527
713
  }
528
714
 
529
715
  /*
@@ -928,6 +1114,7 @@ export class ChatSession {
928
1114
 
929
1115
  /* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
930
1116
  freeze(): void {
1117
+ this.clearUi();
931
1118
  this.clearStartWarning();
932
1119
  this.clearWorkflowWarnings();
933
1120
  this.frozen = true;
@@ -936,6 +1123,7 @@ export class ChatSession {
936
1123
 
937
1124
  /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
938
1125
  dispose(): Promise<void> {
1126
+ this.clearUi();
939
1127
  this.clearStartWarning();
940
1128
  this.clearWorkflowWarnings();
941
1129
  if (this.titleTimer !== null) {
@@ -1093,10 +1281,26 @@ export class ChatSession {
1093
1281
  const skills = extras.skills?.length ? extras.skills : undefined;
1094
1282
  const chats = extras.chats?.length ? extras.chats : undefined;
1095
1283
  const attachments = extras.attachments?.length ? extras.attachments : undefined;
1096
- events.push(this.thread.upsert({ id: newId('user'), kind: 'user', createdAt: now, turnId, text, mentions, skills, chats, attachments }));
1284
+ events.push(
1285
+ this.thread.upsert({
1286
+ id: newId('user'),
1287
+ kind: 'user',
1288
+ createdAt: now,
1289
+ turnId,
1290
+ text,
1291
+ mentions,
1292
+ skills,
1293
+ chats,
1294
+ attachments,
1295
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
1296
+ })
1297
+ );
1097
1298
  }
1098
1299
  events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
1099
1300
  this.emit(events);
1301
+ if (extras.uiChoice) {
1302
+ this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
1303
+ }
1100
1304
  // On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
1101
1305
  this.options.persist();
1102
1306
  return turnId;
@@ -1326,6 +1530,7 @@ export class ChatSession {
1326
1530
  runtimeMode: info.runtimeMode,
1327
1531
  resume: info.agentSessionId,
1328
1532
  generation,
1533
+ saveGeneratedImage: this.options.saveGeneratedImage,
1329
1534
  instructions: this.options.instructions?.() ?? null,
1330
1535
  resumeNote: this.options.resumeNote?.() ?? null,
1331
1536
  ...(folders.length > 0 ? { folders } : {}),
@@ -1677,9 +1882,82 @@ export class ChatSession {
1677
1882
 
1678
1883
  private emit(events: ChatEvent[]): void {
1679
1884
  const delegating = this.delegatingChange(events);
1680
- for (const event of delegating === null ? events : [...events, delegating]) {
1681
- this.options.emit(event);
1885
+ for (let event of delegating === null ? events : [...events, delegating]) {
1886
+ if (event.type === 'reset') {
1887
+ this.clearUi();
1888
+ }
1889
+ const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
1890
+ if (item?.kind !== 'assistant' || item.parentToolUseId) {
1891
+ this.options.emit(event);
1892
+ continue;
1893
+ }
1894
+ let stream = this.uiStreams.get(item.id);
1895
+ if (!stream && !item.text.includes('ruimte-ui')) {
1896
+ this.options.emit(event);
1897
+ continue;
1898
+ }
1899
+ if (!stream) {
1900
+ stream = new UiStream({
1901
+ querySchemas: this.options.ui?.schemas,
1902
+ id: item.id,
1903
+ clock: this.options.clock ?? systemClock,
1904
+ emit: (preview) => {
1905
+ this.uiCompiled.set(item.id, preview);
1906
+ this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
1907
+ }
1908
+ });
1909
+ this.uiStreams.set(item.id, stream);
1910
+ }
1911
+ this.options.ui?.observe(item, false);
1912
+ const latestAttachment = this.latestUiAttachment(item.turnId);
1913
+ if (event.type === 'item' && !item.streaming) {
1914
+ const ui = stream
1915
+ .finish(item.text, latestAttachment)
1916
+ .map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
1917
+ this.uiStreams.delete(item.id);
1918
+ this.uiCompiled.delete(item.id);
1919
+ const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
1920
+ if (diagnostics.length > 0) {
1921
+ const repair =
1922
+ `Your last ruimte-ui block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(
1923
+ 0,
1924
+ 1200
1925
+ );
1926
+ if (!this.pendingPreambles.includes(repair)) {
1927
+ this.pendingPreambles = [...this.pendingPreambles, repair];
1928
+ }
1929
+ }
1930
+ const current = this.thread.get(item.id);
1931
+ const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
1932
+ event = this.thread.upsert(completed);
1933
+ this.options.emit(event);
1934
+ this.options.ui?.observe(completed, true);
1935
+ } else {
1936
+ this.options.emit(event);
1937
+ stream.update(item.text, latestAttachment);
1938
+ }
1939
+ }
1940
+ }
1941
+
1942
+ private latestUiAttachment(turnId: string | null): string | undefined {
1943
+ for (const item of this.thread.list().toReversed()) {
1944
+ if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
1945
+ continue;
1946
+ }
1947
+ const input = GeneratedImageInputSchema.safeParse(item.input);
1948
+ if (input.success && input.data.attachment?.mime.startsWith('image/')) {
1949
+ return input.data.attachment.id;
1950
+ }
1951
+ }
1952
+ return undefined;
1953
+ }
1954
+
1955
+ private clearUi(): void {
1956
+ for (const stream of this.uiStreams.values()) {
1957
+ stream.dispose();
1682
1958
  }
1959
+ this.uiStreams.clear();
1960
+ this.uiCompiled.clear();
1683
1961
  }
1684
1962
 
1685
1963
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
@@ -27,6 +27,8 @@ export class ClaudeBackend implements ChatBackend {
27
27
  private stdinClosed = false;
28
28
  // The person asked to stop; the result frame that follows closes the turn as aborted.
29
29
  private interrupted = false;
30
+ private readonly steers = new Map<string, { resolve(accepted: boolean): void; reject(reason: Error): void }>();
31
+ private active = false;
30
32
 
31
33
  constructor(launch: BackendLaunch, host: BackendHost, options: ClaudeBackendOptions = {}) {
32
34
  this.launch = launch;
@@ -72,10 +74,29 @@ export class ClaudeBackend implements ChatBackend {
72
74
  // The prefix is what the CLI must see first (ultrathink), then the note about the links, then what was typed.
73
75
  const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
74
76
  const promptId = input.promptId ?? randomUUID();
77
+ this.active = true;
75
78
  this.protocol.beginPrompt(promptId);
76
79
  this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
77
80
  }
78
81
 
82
+ steerTurn(input: TurnInput): Promise<boolean> {
83
+ if (!this.child || !this.active || this.stdinClosed) {
84
+ return Promise.resolve(false);
85
+ }
86
+ const promptId = input.promptId ?? randomUUID();
87
+ return new Promise((resolve, reject) => {
88
+ this.steers.set(promptId, { resolve, reject });
89
+ this.protocol.beginSteer(promptId);
90
+ 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 }));
93
+ } catch (error) {
94
+ this.steers.delete(promptId);
95
+ reject(error instanceof Error ? error : new Error(errorText(error)));
96
+ }
97
+ });
98
+ }
99
+
79
100
  /* Claude Code folds its context through a slash command, so the session sends it as a turn. */
80
101
  compact(): void {
81
102
  this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
@@ -120,6 +141,7 @@ export class ClaudeBackend implements ChatBackend {
120
141
  }
121
142
 
122
143
  dispose(): Promise<void> {
144
+ this.rejectSteers('Claude stopped before acknowledging your message');
123
145
  const child = this.child;
124
146
  this.child = null;
125
147
  return child?.end() ?? Promise.resolve();
@@ -165,7 +187,15 @@ export class ClaudeBackend implements ChatBackend {
165
187
  } catch {
166
188
  return;
167
189
  }
190
+ const echoed = typeof frame === 'object' && frame !== null ? (frame as Record<string, unknown>) : null;
191
+ if (echoed?.type === 'user' && typeof echoed.uuid === 'string' && echoed.parent_tool_use_id == null) {
192
+ this.steers.get(echoed.uuid)?.resolve(true);
193
+ this.steers.delete(echoed.uuid);
194
+ }
168
195
  for (const event of this.protocol.handle(frame)) {
196
+ if (event.type === 'turn.done') {
197
+ this.active = false;
198
+ }
169
199
  if (event.type === 'turn.done' && this.interrupted) {
170
200
  this.interrupted = false;
171
201
  this.host.onEvent({ ...event, state: 'aborted' });
@@ -183,7 +213,16 @@ export class ClaudeBackend implements ChatBackend {
183
213
  }
184
214
  }
185
215
 
216
+ private rejectSteers(reason: string): void {
217
+ this.active = false;
218
+ for (const pending of this.steers.values()) {
219
+ pending.reject(new Error(reason));
220
+ }
221
+ this.steers.clear();
222
+ }
223
+
186
224
  private failed(error: unknown): void {
225
+ this.rejectSteers(`Claude transport failed: ${errorText(error)}`);
187
226
  this.protocol.forgetPending();
188
227
  try {
189
228
  this.host.onEvent({ type: 'failed', message: `Claude transport failed: ${errorText(error)}`, processAlive: this.running });
@@ -229,6 +268,7 @@ export class ClaudeBackend implements ChatBackend {
229
268
  return;
230
269
  }
231
270
  this.child = null;
271
+ this.rejectSteers('Claude exited before acknowledging your message');
232
272
  this.protocol.forgetPending();
233
273
  this.host.onEvent({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
234
274
  }
@@ -147,7 +147,14 @@ export class ClaudeProtocol {
147
147
  private apiError: string | null = null;
148
148
  private readonly refusedWindows = new Map<string, number | null>();
149
149
 
150
+ private readonly steerIds = new Set<string>();
151
+
152
+ beginSteer(promptId: string): void {
153
+ this.steerIds.add(promptId);
154
+ }
155
+
150
156
  beginPrompt(promptId: string): void {
157
+ this.steerIds.clear();
151
158
  this.promptId = promptId;
152
159
  this.apiError = null;
153
160
  this.refusedWindows.clear();
@@ -575,7 +582,12 @@ export class ClaudeProtocol {
575
582
  private handleResult(frame: Frame, events: BackendEvent[]): void {
576
583
  const echoed = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids.filter((value): value is string => typeof value === 'string') : [];
577
584
  const origin = str(frame.origin) ?? (isRecord(frame.origin) ? str(frame.origin.kind) : null);
578
- if (this.promptId !== null && (echoed.length > 0 ? !echoed.includes(this.promptId) : origin !== null && origin !== 'human' && origin !== 'user')) {
585
+ if (
586
+ this.promptId !== null &&
587
+ (echoed.length > 0
588
+ ? !echoed.includes(this.promptId) && !echoed.some((id) => this.steerIds.has(id))
589
+ : origin !== null && origin !== 'human' && origin !== 'user')
590
+ ) {
579
591
  if (typeof frame.total_cost_usd === 'number') {
580
592
  events.push({ type: 'usage', costUsd: num(frame.total_cost_usd) });
581
593
  }
@@ -586,6 +598,9 @@ export class ClaudeProtocol {
586
598
  }
587
599
  const promptId = this.promptId !== null && echoed.includes(this.promptId) ? this.promptId : null;
588
600
  this.promptId = null;
601
+ for (const id of echoed) {
602
+ this.steerIds.delete(id);
603
+ }
589
604
  const failed = frame.is_error === true || (typeof frame.subtype === 'string' && frame.subtype.startsWith('error'));
590
605
  const errors = Array.isArray(frame.errors) ? frame.errors.filter((error): error is string => typeof error === 'string') : [];
591
606
  const lastUuid = this.lastUuid;