@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,5 +1,7 @@
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, UiFailure } 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';
@@ -69,12 +71,31 @@ export class ChatSession {
69
71
  orphans = new Set();
70
72
  pendingPreambles;
71
73
  submission = null;
74
+ steering = new Set();
75
+ uiStreams = new Map();
76
+ uiCompiled = new Map();
72
77
  constructor(options) {
73
78
  this.options = options;
74
79
  this.pendingPreambles = options.preambles ?? [];
75
80
  this.thread = new ChatThread(options.info, options.items);
76
81
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
77
82
  }
83
+ get uiPreviews() {
84
+ const events = [];
85
+ for (const [itemId, preview] of this.uiCompiled) {
86
+ const item = this.thread.get(itemId);
87
+ if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
88
+ events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
89
+ }
90
+ }
91
+ return events;
92
+ }
93
+ previewItems(items) {
94
+ return items.map((item) => {
95
+ const preview = this.uiCompiled.get(item.id);
96
+ return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
97
+ });
98
+ }
78
99
  get id() {
79
100
  return this.thread.info.chatId;
80
101
  }
@@ -133,9 +154,7 @@ export class ChatSession {
133
154
  return this.thread.info;
134
155
  }
135
156
  /*
136
- * A message while a turn runs joins the queue instead of being refused; the host sends it when
137
- * that turn settles. One queue for both providers: Claude's steer and Codex's own queue have
138
- * different semantics, and one rule is easier to reason about than a rule per CLI.
157
+ * Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
139
158
  */
140
159
  send(text, extras = {}) {
141
160
  const turnId = newId('turn');
@@ -149,6 +168,7 @@ export class ChatSession {
149
168
  turnId,
150
169
  text,
151
170
  createdAt: Date.now(),
171
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
152
172
  ...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
153
173
  ...(extras.skills?.length ? { skills: extras.skills } : {}),
154
174
  ...(extras.chats?.length ? { chats: extras.chats } : {}),
@@ -162,11 +182,144 @@ export class ChatSession {
162
182
  this.dispatch(text, extras, turnId);
163
183
  return { queued: false, turnId };
164
184
  }
185
+ updateUiQueries(itemId, uiQueries) {
186
+ const item = this.thread.get(itemId);
187
+ if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
188
+ return;
189
+ }
190
+ this.thread.upsert({ ...item, uiQueries });
191
+ this.options.emit({ type: 'delta', itemId, text: '', uiQueries });
192
+ this.options.persistSoon();
193
+ }
194
+ choose(payload, queries = {}) {
195
+ const item = this.thread.get(payload.itemId);
196
+ const block = item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((block) => block.id === payload.blockId) : undefined;
197
+ if (item?.kind !== 'assistant' || !block?.revision || block.revision !== payload.revision) {
198
+ throw new ChatError('stale-ui-block', 'This completed UI block is no longer available at the requested revision');
199
+ }
200
+ const sameBlock = (origin) => origin?.itemId === item.id && origin.blockId === block.id && origin.revision === block.revision;
201
+ let previous = item.uiAnswers?.[block.id];
202
+ if (previous?.revision !== block.revision) {
203
+ previous = undefined;
204
+ }
205
+ // The message is logged first: a crash before its answer marker must not send it a second time.
206
+ if (!previous) {
207
+ const sent = this.thread.list().find((entry) => entry.kind === 'user' && sameBlock(entry.uiChoice));
208
+ const queued = this.queue.find((entry) => sameBlock(entry.uiChoice));
209
+ const origin = sent?.kind === 'user' ? sent.uiChoice : queued?.uiChoice;
210
+ const turnId = sent?.turnId ?? queued?.turnId;
211
+ if (origin && turnId) {
212
+ previous = { ...origin, turnId, queued: !sent };
213
+ this.recordUiAnswer(previous);
214
+ }
215
+ }
216
+ if (previous) {
217
+ if (previous.choiceId !== payload.choiceId) {
218
+ throw new ChatError('ui-already-answered', 'This UI block was already answered');
219
+ }
220
+ return { queued: previous.queued, turnId: previous.turnId };
221
+ }
222
+ let selected;
223
+ try {
224
+ selected = resolveUiChoice(block, payload.choiceId, payload.values, queries);
225
+ }
226
+ catch (error) {
227
+ throw new ChatError(error instanceof UiFailure ? error.code : 'refused-choice', error instanceof Error ? error.message : 'This choice could not be read');
228
+ }
229
+ const origin = {
230
+ itemId: item.id,
231
+ blockId: block.id,
232
+ revision: block.revision,
233
+ choiceId: payload.choiceId,
234
+ label: selected.label,
235
+ sourceAt: item.createdAt,
236
+ older: this.thread.list().findLast((entry) => entry.kind === 'assistant' && !entry.parentToolUseId)?.id !== item.id,
237
+ values: selected.values,
238
+ at: Date.now()
239
+ };
240
+ const result = this.send(selected.context, { uiChoice: origin });
241
+ this.recordUiAnswer({ ...origin, ...result });
242
+ return result;
243
+ }
244
+ recordUiAnswer(answer) {
245
+ const item = this.thread.get(answer.itemId);
246
+ if (item?.kind !== 'assistant') {
247
+ return;
248
+ }
249
+ if (JSON.stringify(item.uiAnswers?.[answer.blockId]) === JSON.stringify(answer)) {
250
+ return;
251
+ }
252
+ this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
253
+ this.options.persist();
254
+ }
255
+ sendInput(text, extras = {}) {
256
+ return this.submitInput(text, extras);
257
+ }
258
+ async submitInput(text, extras) {
259
+ const backend = this.backend;
260
+ const turnId = this.info.activeTurnId;
261
+ if (extras.delivery !== 'steer' ||
262
+ this.info.queuePaused ||
263
+ text.startsWith('/') ||
264
+ !this.busy ||
265
+ !turnId ||
266
+ !backend?.steerTurn ||
267
+ this.pendingStart ||
268
+ this.replacementWaits()) {
269
+ return this.send(text, extras);
270
+ }
271
+ const queued = this.send(text, extras);
272
+ const message = this.queue.find((entry) => entry.turnId === queued.turnId);
273
+ if (!message) {
274
+ return queued;
275
+ }
276
+ this.steering.add(message.id);
277
+ try {
278
+ const references = this.options.references?.(extras.chats) ?? { ids: extras.chats ?? [], note: null };
279
+ const accepted = await backend.steerTurn({
280
+ promptId: message.id,
281
+ text,
282
+ preamble: references.note,
283
+ attachments: extras.attachments ?? [],
284
+ mentions: extras.mentions ?? [],
285
+ skills: extras.skills ?? []
286
+ });
287
+ if (!accepted) {
288
+ return queued;
289
+ }
290
+ this.emit([
291
+ this.thread.upsert({
292
+ id: `user-${message.id}`,
293
+ kind: 'user',
294
+ createdAt: message.createdAt,
295
+ turnId,
296
+ text,
297
+ mentions: extras.mentions,
298
+ skills: extras.skills,
299
+ chats: references.ids,
300
+ attachments: extras.attachments
301
+ }),
302
+ this.thread.patchInfo({ queue: this.queue.filter((entry) => entry.id !== message.id) })
303
+ ]);
304
+ this.options.persist();
305
+ return { queued: false, turnId };
306
+ }
307
+ catch (error) {
308
+ // A lost acknowledgement does not prove rejection. Keep the message for an explicit retry.
309
+ this.pauseQueue(true);
310
+ this.options.persist();
311
+ throw error;
312
+ }
313
+ finally {
314
+ this.steering.delete(message.id);
315
+ this.drainQueue();
316
+ }
317
+ }
165
318
  /* Drops a queued message and hands it back; null when nothing waits under that id, as once it went out. */
166
319
  unqueue(messageId) {
167
320
  const queue = this.queue;
168
321
  const message = queue.find((entry) => entry.id === messageId);
169
- if (!message) {
322
+ if (!message || this.steering.has(message.id)) {
170
323
  return null;
171
324
  }
172
325
  this.setQueue(queue.filter((entry) => entry.id !== messageId));
@@ -176,7 +329,7 @@ export class ChatSession {
176
329
  sendNow(messageId) {
177
330
  const queue = this.queue;
178
331
  const message = queue.find((entry) => entry.id === messageId);
179
- if (!message) {
332
+ if (!message || this.steering.has(message.id)) {
180
333
  return false;
181
334
  }
182
335
  this.setQueue([message, ...queue.filter((entry) => entry.id !== messageId)]);
@@ -201,6 +354,7 @@ export class ChatSession {
201
354
  const limited = limitedTurn(this.thread.list());
202
355
  const explicit = asked || this.sendAfterReplacement;
203
356
  if (!next ||
357
+ this.steering.size > 0 ||
204
358
  this.busy ||
205
359
  (!explicit && (this.info.queuePaused || (limited !== null && (limited.limit.kind === 'usage' || this.info.resumeAt !== undefined))))) {
206
360
  return;
@@ -226,7 +380,7 @@ export class ChatSession {
226
380
  this.sendAfterReplacement = false;
227
381
  this.pauseQueue(false);
228
382
  this.setQueue(rest);
229
- this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments }, next.turnId);
383
+ this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice }, next.turnId);
230
384
  }
231
385
  dispatch(text, extras, requestedTurnId) {
232
386
  this.pauseQueue(false);
@@ -235,7 +389,10 @@ export class ChatSession {
235
389
  // A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
236
390
  const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
237
391
  const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
238
- const said = [preamble, references.note].filter((part) => part !== null);
392
+ const choiceNote = extras.uiChoice
393
+ ? `The person chose ${JSON.stringify(extras.uiChoice.label)} from your UI reply. Their message is the visible context of that choice.`
394
+ : null;
395
+ const said = [preamble, references.note, choiceNote].filter((part) => part !== null);
239
396
  const input = {
240
397
  text,
241
398
  preamble: said.length === 0 ? null : said.join('\n\n'),
@@ -347,14 +504,14 @@ export class ChatSession {
347
504
  }
348
505
  /*
349
506
  * Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
350
- * A turn in the way is refused unless forced, and a forced clear does not wait for it to end,
351
- * since the turn disappears with the thread anyway. Writing the empty thread is the caller's.
507
+ * The thread resets at once; the returned promise lets the host wait for pending attachment
508
+ * writes before removing their files. Writing the empty thread is the caller's.
352
509
  */
353
510
  clear(force) {
354
511
  if (this.busy && !force) {
355
512
  throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
356
513
  }
357
- void this.dispose();
514
+ const ended = this.dispose();
358
515
  this.generation = nextGeneration();
359
516
  this.projector.reset();
360
517
  // The fresh CLI hears what the host tells it at launch, as it would on a first turn.
@@ -393,6 +550,7 @@ export class ChatSession {
393
550
  }
394
551
  })
395
552
  ]);
553
+ return ended;
396
554
  }
397
555
  /*
398
556
  * A thread read from disk cannot still be streaming or waiting, so whatever was open is closed,
@@ -773,6 +931,7 @@ export class ChatSession {
773
931
  }
774
932
  /* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
775
933
  freeze() {
934
+ this.clearUi();
776
935
  this.clearStartWarning();
777
936
  this.clearWorkflowWarnings();
778
937
  this.frozen = true;
@@ -780,6 +939,7 @@ export class ChatSession {
780
939
  }
781
940
  /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
782
941
  dispose() {
942
+ this.clearUi();
783
943
  this.clearStartWarning();
784
944
  this.clearWorkflowWarnings();
785
945
  if (this.titleTimer !== null) {
@@ -925,10 +1085,24 @@ export class ChatSession {
925
1085
  const skills = extras.skills?.length ? extras.skills : undefined;
926
1086
  const chats = extras.chats?.length ? extras.chats : undefined;
927
1087
  const attachments = extras.attachments?.length ? extras.attachments : undefined;
928
- events.push(this.thread.upsert({ id: newId('user'), kind: 'user', createdAt: now, turnId, text, mentions, skills, chats, attachments }));
1088
+ events.push(this.thread.upsert({
1089
+ id: newId('user'),
1090
+ kind: 'user',
1091
+ createdAt: now,
1092
+ turnId,
1093
+ text,
1094
+ mentions,
1095
+ skills,
1096
+ chats,
1097
+ attachments,
1098
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
1099
+ }));
929
1100
  }
930
1101
  events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
931
1102
  this.emit(events);
1103
+ if (extras.uiChoice) {
1104
+ this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
1105
+ }
932
1106
  // On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
933
1107
  this.options.persist();
934
1108
  return turnId;
@@ -1143,6 +1317,7 @@ export class ChatSession {
1143
1317
  runtimeMode: info.runtimeMode,
1144
1318
  resume: info.agentSessionId,
1145
1319
  generation,
1320
+ saveGeneratedImage: this.options.saveGeneratedImage,
1146
1321
  instructions: this.options.instructions?.() ?? null,
1147
1322
  resumeNote: this.options.resumeNote?.() ?? null,
1148
1323
  ...(folders.length > 0 ? { folders } : {}),
@@ -1473,9 +1648,77 @@ export class ChatSession {
1473
1648
  }
1474
1649
  emit(events) {
1475
1650
  const delegating = this.delegatingChange(events);
1476
- for (const event of delegating === null ? events : [...events, delegating]) {
1477
- this.options.emit(event);
1651
+ for (let event of delegating === null ? events : [...events, delegating]) {
1652
+ if (event.type === 'reset') {
1653
+ this.clearUi();
1654
+ }
1655
+ const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
1656
+ if (item?.kind !== 'assistant' || item.parentToolUseId) {
1657
+ this.options.emit(event);
1658
+ continue;
1659
+ }
1660
+ let stream = this.uiStreams.get(item.id);
1661
+ if (!stream && !item.text.includes('ruimte-ui')) {
1662
+ this.options.emit(event);
1663
+ continue;
1664
+ }
1665
+ if (!stream) {
1666
+ stream = new UiStream({
1667
+ querySchemas: this.options.ui?.schemas,
1668
+ id: item.id,
1669
+ clock: this.options.clock ?? systemClock,
1670
+ emit: (preview) => {
1671
+ this.uiCompiled.set(item.id, preview);
1672
+ this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
1673
+ }
1674
+ });
1675
+ this.uiStreams.set(item.id, stream);
1676
+ }
1677
+ this.options.ui?.observe(item, false);
1678
+ const latestAttachment = this.latestUiAttachment(item.turnId);
1679
+ if (event.type === 'item' && !item.streaming) {
1680
+ const ui = stream
1681
+ .finish(item.text, latestAttachment)
1682
+ .map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
1683
+ this.uiStreams.delete(item.id);
1684
+ this.uiCompiled.delete(item.id);
1685
+ const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
1686
+ if (diagnostics.length > 0) {
1687
+ const repair = `Your last ruimte-ui block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(0, 1200);
1688
+ if (!this.pendingPreambles.includes(repair)) {
1689
+ this.pendingPreambles = [...this.pendingPreambles, repair];
1690
+ }
1691
+ }
1692
+ const current = this.thread.get(item.id);
1693
+ const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
1694
+ event = this.thread.upsert(completed);
1695
+ this.options.emit(event);
1696
+ this.options.ui?.observe(completed, true);
1697
+ }
1698
+ else {
1699
+ this.options.emit(event);
1700
+ stream.update(item.text, latestAttachment);
1701
+ }
1702
+ }
1703
+ }
1704
+ latestUiAttachment(turnId) {
1705
+ for (const item of this.thread.list().toReversed()) {
1706
+ if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
1707
+ continue;
1708
+ }
1709
+ const input = GeneratedImageInputSchema.safeParse(item.input);
1710
+ if (input.success && input.data.attachment?.mime.startsWith('image/')) {
1711
+ return input.data.attachment.id;
1712
+ }
1713
+ }
1714
+ return undefined;
1715
+ }
1716
+ clearUi() {
1717
+ for (const stream of this.uiStreams.values()) {
1718
+ stream.dispose();
1478
1719
  }
1720
+ this.uiStreams.clear();
1721
+ this.uiCompiled.clear();
1479
1722
  }
1480
1723
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
1481
1724
  delegatingChange(events) {
@@ -11,11 +11,14 @@ export declare class ClaudeBackend implements ChatBackend {
11
11
  private child;
12
12
  private stdinClosed;
13
13
  private interrupted;
14
+ private readonly steers;
15
+ private active;
14
16
  constructor(launch: BackendLaunch, host: BackendHost, options?: ClaudeBackendOptions);
15
17
  get running(): boolean;
16
18
  get pid(): number | null;
17
19
  start(): Promise<void>;
18
20
  sendTurn(input: TurnInput): void;
21
+ steerTurn(input: TurnInput): Promise<boolean>;
19
22
  compact(): void;
20
23
  interrupt(): void;
21
24
  stopTask(taskId: string): void;
@@ -27,6 +30,7 @@ export declare class ClaudeBackend implements ChatBackend {
27
30
  private readLines;
28
31
  private handleLine;
29
32
  private readLine;
33
+ private rejectSteers;
30
34
  private failed;
31
35
  private respond;
32
36
  private write;
@@ -20,6 +20,8 @@ export class ClaudeBackend {
20
20
  stdinClosed = false;
21
21
  // The person asked to stop; the result frame that follows closes the turn as aborted.
22
22
  interrupted = false;
23
+ steers = new Map();
24
+ active = false;
23
25
  constructor(launch, host, options = {}) {
24
26
  this.launch = launch;
25
27
  this.host = host;
@@ -60,9 +62,28 @@ export class ClaudeBackend {
60
62
  // The prefix is what the CLI must see first (ultrathink), then the note about the links, then what was typed.
61
63
  const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
62
64
  const promptId = input.promptId ?? randomUUID();
65
+ this.active = true;
63
66
  this.protocol.beginPrompt(promptId);
64
67
  this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
65
68
  }
69
+ steerTurn(input) {
70
+ if (!this.child || !this.active || this.stdinClosed) {
71
+ return Promise.resolve(false);
72
+ }
73
+ const promptId = input.promptId ?? randomUUID();
74
+ return new Promise((resolve, reject) => {
75
+ this.steers.set(promptId, { resolve, reject });
76
+ this.protocol.beginSteer(promptId);
77
+ try {
78
+ const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
79
+ this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
80
+ }
81
+ catch (error) {
82
+ this.steers.delete(promptId);
83
+ reject(error instanceof Error ? error : new Error(errorText(error)));
84
+ }
85
+ });
86
+ }
66
87
  /* Claude Code folds its context through a slash command, so the session sends it as a turn. */
67
88
  compact() {
68
89
  this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
@@ -100,6 +121,7 @@ export class ClaudeBackend {
100
121
  this.child?.endAfterGrace();
101
122
  }
102
123
  dispose() {
124
+ this.rejectSteers('Claude stopped before acknowledging your message');
103
125
  const child = this.child;
104
126
  this.child = null;
105
127
  return child?.end() ?? Promise.resolve();
@@ -146,7 +168,15 @@ export class ClaudeBackend {
146
168
  catch {
147
169
  return;
148
170
  }
171
+ const echoed = typeof frame === 'object' && frame !== null ? frame : null;
172
+ if (echoed?.type === 'user' && typeof echoed.uuid === 'string' && echoed.parent_tool_use_id == null) {
173
+ this.steers.get(echoed.uuid)?.resolve(true);
174
+ this.steers.delete(echoed.uuid);
175
+ }
149
176
  for (const event of this.protocol.handle(frame)) {
177
+ if (event.type === 'turn.done') {
178
+ this.active = false;
179
+ }
150
180
  if (event.type === 'turn.done' && this.interrupted) {
151
181
  this.interrupted = false;
152
182
  this.host.onEvent({ ...event, state: 'aborted' });
@@ -163,7 +193,15 @@ export class ClaudeBackend {
163
193
  this.failed(error);
164
194
  }
165
195
  }
196
+ rejectSteers(reason) {
197
+ this.active = false;
198
+ for (const pending of this.steers.values()) {
199
+ pending.reject(new Error(reason));
200
+ }
201
+ this.steers.clear();
202
+ }
166
203
  failed(error) {
204
+ this.rejectSteers(`Claude transport failed: ${errorText(error)}`);
167
205
  this.protocol.forgetPending();
168
206
  try {
169
207
  this.host.onEvent({ type: 'failed', message: `Claude transport failed: ${errorText(error)}`, processAlive: this.running });
@@ -208,6 +246,7 @@ export class ClaudeBackend {
208
246
  return;
209
247
  }
210
248
  this.child = null;
249
+ this.rejectSteers('Claude exited before acknowledging your message');
211
250
  this.protocol.forgetPending();
212
251
  this.host.onEvent({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
213
252
  }
@@ -15,6 +15,8 @@ export declare class ClaudeProtocol {
15
15
  private readonly subagentModels;
16
16
  private apiError;
17
17
  private readonly refusedWindows;
18
+ private readonly steerIds;
19
+ beginSteer(promptId: string): void;
18
20
  beginPrompt(promptId: string): void;
19
21
  handle(frame: unknown): BackendEvent[];
20
22
  approvalResponse(requestId: string, decision: ApprovalDecision, message?: string): unknown | null;
@@ -117,7 +117,12 @@ export class ClaudeProtocol {
117
117
  // Only main-chain errors classify the turn; each plan window can refuse it independently.
118
118
  apiError = null;
119
119
  refusedWindows = new Map();
120
+ steerIds = new Set();
121
+ beginSteer(promptId) {
122
+ this.steerIds.add(promptId);
123
+ }
120
124
  beginPrompt(promptId) {
125
+ this.steerIds.clear();
121
126
  this.promptId = promptId;
122
127
  this.apiError = null;
123
128
  this.refusedWindows.clear();
@@ -538,7 +543,10 @@ export class ClaudeProtocol {
538
543
  handleResult(frame, events) {
539
544
  const echoed = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids.filter((value) => typeof value === 'string') : [];
540
545
  const origin = str(frame.origin) ?? (isRecord(frame.origin) ? str(frame.origin.kind) : null);
541
- if (this.promptId !== null && (echoed.length > 0 ? !echoed.includes(this.promptId) : origin !== null && origin !== 'human' && origin !== 'user')) {
546
+ if (this.promptId !== null &&
547
+ (echoed.length > 0
548
+ ? !echoed.includes(this.promptId) && !echoed.some((id) => this.steerIds.has(id))
549
+ : origin !== null && origin !== 'human' && origin !== 'user')) {
542
550
  if (typeof frame.total_cost_usd === 'number') {
543
551
  events.push({ type: 'usage', costUsd: num(frame.total_cost_usd) });
544
552
  }
@@ -549,6 +557,9 @@ export class ClaudeProtocol {
549
557
  }
550
558
  const promptId = this.promptId !== null && echoed.includes(this.promptId) ? this.promptId : null;
551
559
  this.promptId = null;
560
+ for (const id of echoed) {
561
+ this.steerIds.delete(id);
562
+ }
552
563
  const failed = frame.is_error === true || (typeof frame.subtype === 'string' && frame.subtype.startsWith('error'));
553
564
  const errors = Array.isArray(frame.errors) ? frame.errors.filter((error) => typeof error === 'string') : [];
554
565
  const lastUuid = this.lastUuid;
@@ -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;
@@ -22,7 +23,9 @@ export declare class CodexBackend implements ChatBackend {
22
23
  get running(): boolean;
23
24
  get pid(): number | null;
24
25
  start(): Promise<void>;
26
+ private turnInput;
25
27
  sendTurn(input: TurnInput): void;
28
+ steerTurn(input: TurnInput): Promise<boolean>;
26
29
  private acceptPrompt;
27
30
  private fail;
28
31
  private respond;
@@ -48,4 +51,5 @@ export declare class CodexBackend implements ChatBackend {
48
51
  private handleFrame;
49
52
  private handleExit;
50
53
  private emit;
54
+ private storeImage;
51
55
  }