@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.
- package/dist/chat/attachment-store.d.ts +2 -0
- package/dist/chat/attachment-store.js +38 -2
- package/dist/chat/backend.d.ts +13 -1
- package/dist/chat/chat-core.d.ts +53 -1
- package/dist/chat/chat-core.js +53 -10
- package/dist/chat/chat-session.d.ts +29 -2
- package/dist/chat/chat-session.js +258 -15
- package/dist/chat/claude-backend.d.ts +4 -0
- package/dist/chat/claude-backend.js +39 -0
- package/dist/chat/claude-protocol.d.ts +2 -0
- package/dist/chat/claude-protocol.js +12 -1
- package/dist/chat/codex-backend.d.ts +4 -0
- package/dist/chat/codex-backend.js +77 -5
- package/dist/chat/codex-protocol.d.ts +1 -0
- package/dist/chat/codex-protocol.js +74 -5
- package/dist/chat/delta-coalescer.js +1 -1
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/fake-codex.js +33 -0
- package/dist/chat/generated-image.d.ts +6 -0
- package/dist/chat/generated-image.js +74 -0
- package/dist/chat/projector.js +9 -2
- package/dist/chat/thread.js +6 -0
- package/dist/chat/ui-queries.d.ts +38 -0
- package/dist/chat/ui-queries.js +311 -0
- package/dist/host/handlers.js +4 -1
- package/package.json +5 -4
- package/src/chat/attachment-store.ts +38 -2
- package/src/chat/backend.ts +16 -2
- package/src/chat/chat-core.ts +60 -9
- package/src/chat/chat-session.ts +294 -16
- package/src/chat/claude-backend.ts +40 -0
- package/src/chat/claude-protocol.ts +16 -1
- package/src/chat/codex-backend.ts +77 -5
- package/src/chat/codex-protocol.ts +78 -6
- package/src/chat/delta-coalescer.ts +1 -1
- package/src/chat/errors.ts +2 -0
- package/src/chat/fake-codex.ts +34 -0
- package/src/chat/generated-image.ts +88 -0
- package/src/chat/projector.ts +9 -2
- package/src/chat/thread.ts +6 -0
- package/src/chat/ui-queries.ts +360 -0
- 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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
351
|
-
*
|
|
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
|
-
|
|
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({
|
|
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 (
|
|
1477
|
-
|
|
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 &&
|
|
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
|
}
|