@adecore/agents 0.20.0 → 0.20.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.
- package/dist/chat/backend.d.ts +1 -0
- package/dist/chat/chat-core.js +1 -1
- package/dist/chat/chat-session.d.ts +7 -0
- package/dist/chat/chat-session.js +68 -5
- 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 +2 -0
- package/dist/chat/codex-backend.js +26 -4
- package/dist/chat/codex-protocol.d.ts +1 -0
- package/dist/chat/codex-protocol.js +44 -2
- package/dist/host/handlers.js +1 -1
- package/package.json +2 -2
- package/src/chat/backend.ts +2 -0
- package/src/chat/chat-core.ts +1 -1
- package/src/chat/chat-session.ts +71 -5
- package/src/chat/claude-backend.ts +40 -0
- package/src/chat/claude-protocol.ts +16 -1
- package/src/chat/codex-backend.ts +27 -4
- package/src/chat/codex-protocol.ts +47 -3
- package/src/host/handlers.ts +6 -1
package/dist/chat/backend.d.ts
CHANGED
|
@@ -194,6 +194,7 @@ export interface ChatBackend {
|
|
|
194
194
|
readonly pid: number | null;
|
|
195
195
|
start(): Promise<void>;
|
|
196
196
|
sendTurn(input: TurnInput): void;
|
|
197
|
+
steerTurn?(input: TurnInput): Promise<boolean>;
|
|
197
198
|
compact(): void;
|
|
198
199
|
interrupt(): void;
|
|
199
200
|
respondApproval(requestId: string, decision: ApprovalDecision, message?: string): boolean;
|
package/dist/chat/chat-core.js
CHANGED
|
@@ -546,7 +546,7 @@ export class ChatCore {
|
|
|
546
546
|
throw new ChatError('invalid-attachments', checked.error.issues[0].message);
|
|
547
547
|
}
|
|
548
548
|
const attachments = await Promise.all(uploads.map((upload) => this.attachments.save(chatId, upload)));
|
|
549
|
-
return session.
|
|
549
|
+
return session.sendInput(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
|
|
550
550
|
}
|
|
551
551
|
/* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
|
|
552
552
|
attachment(chatId, id) {
|
|
@@ -58,6 +58,7 @@ export interface LimitResumeHooks {
|
|
|
58
58
|
owed(): boolean;
|
|
59
59
|
}
|
|
60
60
|
export interface ChatSendExtras {
|
|
61
|
+
delivery?: 'steer' | 'queue';
|
|
61
62
|
mentions?: string[];
|
|
62
63
|
skills?: string[];
|
|
63
64
|
chats?: string[];
|
|
@@ -93,6 +94,7 @@ export declare class ChatSession {
|
|
|
93
94
|
private readonly orphans;
|
|
94
95
|
private pendingPreambles;
|
|
95
96
|
private submission;
|
|
97
|
+
private readonly steering;
|
|
96
98
|
constructor(options: ChatSessionOptions);
|
|
97
99
|
get id(): string;
|
|
98
100
|
get info(): ChatInfo;
|
|
@@ -110,6 +112,11 @@ export declare class ChatSession {
|
|
|
110
112
|
queued: boolean;
|
|
111
113
|
turnId: string;
|
|
112
114
|
};
|
|
115
|
+
sendInput(text: string, extras?: ChatSendExtras): Promise<{
|
|
116
|
+
queued: boolean;
|
|
117
|
+
turnId: string;
|
|
118
|
+
}>;
|
|
119
|
+
private submitInput;
|
|
113
120
|
unqueue(messageId: string): ChatQueuedMessage | null;
|
|
114
121
|
sendNow(messageId: string): boolean;
|
|
115
122
|
private get queue();
|
|
@@ -69,6 +69,7 @@ export class ChatSession {
|
|
|
69
69
|
orphans = new Set();
|
|
70
70
|
pendingPreambles;
|
|
71
71
|
submission = null;
|
|
72
|
+
steering = new Set();
|
|
72
73
|
constructor(options) {
|
|
73
74
|
this.options = options;
|
|
74
75
|
this.pendingPreambles = options.preambles ?? [];
|
|
@@ -133,9 +134,7 @@ export class ChatSession {
|
|
|
133
134
|
return this.thread.info;
|
|
134
135
|
}
|
|
135
136
|
/*
|
|
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.
|
|
137
|
+
* Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
|
|
139
138
|
*/
|
|
140
139
|
send(text, extras = {}) {
|
|
141
140
|
const turnId = newId('turn');
|
|
@@ -162,11 +161,74 @@ export class ChatSession {
|
|
|
162
161
|
this.dispatch(text, extras, turnId);
|
|
163
162
|
return { queued: false, turnId };
|
|
164
163
|
}
|
|
164
|
+
sendInput(text, extras = {}) {
|
|
165
|
+
return this.submitInput(text, extras);
|
|
166
|
+
}
|
|
167
|
+
async submitInput(text, extras) {
|
|
168
|
+
const backend = this.backend;
|
|
169
|
+
const turnId = this.info.activeTurnId;
|
|
170
|
+
if (extras.delivery !== 'steer' ||
|
|
171
|
+
this.info.queuePaused ||
|
|
172
|
+
text.startsWith('/') ||
|
|
173
|
+
!this.busy ||
|
|
174
|
+
!turnId ||
|
|
175
|
+
!backend?.steerTurn ||
|
|
176
|
+
this.pendingStart ||
|
|
177
|
+
this.replacementWaits()) {
|
|
178
|
+
return this.send(text, extras);
|
|
179
|
+
}
|
|
180
|
+
const queued = this.send(text, extras);
|
|
181
|
+
const message = this.queue.find((entry) => entry.turnId === queued.turnId);
|
|
182
|
+
if (!message) {
|
|
183
|
+
return queued;
|
|
184
|
+
}
|
|
185
|
+
this.steering.add(message.id);
|
|
186
|
+
try {
|
|
187
|
+
const references = this.options.references?.(extras.chats) ?? { ids: extras.chats ?? [], note: null };
|
|
188
|
+
const accepted = await backend.steerTurn({
|
|
189
|
+
promptId: message.id,
|
|
190
|
+
text,
|
|
191
|
+
preamble: references.note,
|
|
192
|
+
attachments: extras.attachments ?? [],
|
|
193
|
+
mentions: extras.mentions ?? [],
|
|
194
|
+
skills: extras.skills ?? []
|
|
195
|
+
});
|
|
196
|
+
if (!accepted) {
|
|
197
|
+
return queued;
|
|
198
|
+
}
|
|
199
|
+
this.emit([
|
|
200
|
+
this.thread.upsert({
|
|
201
|
+
id: `user-${message.id}`,
|
|
202
|
+
kind: 'user',
|
|
203
|
+
createdAt: message.createdAt,
|
|
204
|
+
turnId,
|
|
205
|
+
text,
|
|
206
|
+
mentions: extras.mentions,
|
|
207
|
+
skills: extras.skills,
|
|
208
|
+
chats: references.ids,
|
|
209
|
+
attachments: extras.attachments
|
|
210
|
+
}),
|
|
211
|
+
this.thread.patchInfo({ queue: this.queue.filter((entry) => entry.id !== message.id) })
|
|
212
|
+
]);
|
|
213
|
+
this.options.persist();
|
|
214
|
+
return { queued: false, turnId };
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
// A lost acknowledgement does not prove rejection. Keep the message for an explicit retry.
|
|
218
|
+
this.pauseQueue(true);
|
|
219
|
+
this.options.persist();
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
this.steering.delete(message.id);
|
|
224
|
+
this.drainQueue();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
165
227
|
/* Drops a queued message and hands it back; null when nothing waits under that id, as once it went out. */
|
|
166
228
|
unqueue(messageId) {
|
|
167
229
|
const queue = this.queue;
|
|
168
230
|
const message = queue.find((entry) => entry.id === messageId);
|
|
169
|
-
if (!message) {
|
|
231
|
+
if (!message || this.steering.has(message.id)) {
|
|
170
232
|
return null;
|
|
171
233
|
}
|
|
172
234
|
this.setQueue(queue.filter((entry) => entry.id !== messageId));
|
|
@@ -176,7 +238,7 @@ export class ChatSession {
|
|
|
176
238
|
sendNow(messageId) {
|
|
177
239
|
const queue = this.queue;
|
|
178
240
|
const message = queue.find((entry) => entry.id === messageId);
|
|
179
|
-
if (!message) {
|
|
241
|
+
if (!message || this.steering.has(message.id)) {
|
|
180
242
|
return false;
|
|
181
243
|
}
|
|
182
244
|
this.setQueue([message, ...queue.filter((entry) => entry.id !== messageId)]);
|
|
@@ -201,6 +263,7 @@ export class ChatSession {
|
|
|
201
263
|
const limited = limitedTurn(this.thread.list());
|
|
202
264
|
const explicit = asked || this.sendAfterReplacement;
|
|
203
265
|
if (!next ||
|
|
266
|
+
this.steering.size > 0 ||
|
|
204
267
|
this.busy ||
|
|
205
268
|
(!explicit && (this.info.queuePaused || (limited !== null && (limited.limit.kind === 'usage' || this.info.resumeAt !== undefined))))) {
|
|
206
269
|
return;
|
|
@@ -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;
|
|
@@ -22,7 +22,9 @@ export declare class CodexBackend implements ChatBackend {
|
|
|
22
22
|
get running(): boolean;
|
|
23
23
|
get pid(): number | null;
|
|
24
24
|
start(): Promise<void>;
|
|
25
|
+
private turnInput;
|
|
25
26
|
sendTurn(input: TurnInput): void;
|
|
27
|
+
steerTurn(input: TurnInput): Promise<boolean>;
|
|
26
28
|
private acceptPrompt;
|
|
27
29
|
private fail;
|
|
28
30
|
private respond;
|
|
@@ -114,7 +114,7 @@ export class CodexBackend {
|
|
|
114
114
|
this.emit(event);
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
-
|
|
117
|
+
turnInput(input) {
|
|
118
118
|
const images = input.attachments.filter((attachment) => attachmentImageMime(attachment) !== null);
|
|
119
119
|
if (images.length > 0 && this.imageInputSupported === false) {
|
|
120
120
|
throw new Error(`${this.launch.modelName} does not support image input. Choose a model that accepts images.`);
|
|
@@ -140,18 +140,40 @@ export class CodexBackend {
|
|
|
140
140
|
if (note !== '') {
|
|
141
141
|
parts.push(`\n\n${note}`);
|
|
142
142
|
}
|
|
143
|
+
return [...textInput(parts.join('')), ...images.map((attachment) => ({ type: 'localImage', path: attachment.path }))];
|
|
144
|
+
}
|
|
145
|
+
sendTurn(input) {
|
|
146
|
+
const nativeInput = this.turnInput(input);
|
|
143
147
|
const effort = this.launch.selection.options.effort;
|
|
144
148
|
const tier = codexServiceTier(this.launch.selection);
|
|
145
149
|
this.turnUnnamed = true;
|
|
146
150
|
this.promptId = input.promptId ?? null;
|
|
147
151
|
this.request('turn/start', {
|
|
148
152
|
threadId: this.threadId,
|
|
149
|
-
input:
|
|
153
|
+
input: nativeInput,
|
|
150
154
|
model: this.launch.selection.model,
|
|
151
155
|
...(typeof effort === 'string' ? { effort } : {}),
|
|
152
156
|
...(tier === null ? {} : { serviceTier: tier })
|
|
153
157
|
}, () => this.acceptPrompt(input.promptId));
|
|
154
158
|
}
|
|
159
|
+
async steerTurn(input) {
|
|
160
|
+
const transport = this.transport;
|
|
161
|
+
const turnId = this.protocol.turnId;
|
|
162
|
+
if (!transport || !turnId || this.turnUnnamed) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
await transport.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input: this.turnInput(input) });
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
const rejected = error instanceof Error && error.name === 'CodexRpcError';
|
|
171
|
+
if (rejected && /no active turn|not.*active|turn.*mismatch|expected.*turn|method not found/i.test(error.message)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
155
177
|
acceptPrompt(promptId) {
|
|
156
178
|
if (promptId !== undefined && this.promptId === promptId) {
|
|
157
179
|
this.emit({ type: 'turn.accepted', promptId });
|
|
@@ -282,9 +304,9 @@ export class CodexBackend {
|
|
|
282
304
|
this.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input: textInput(answer.text) });
|
|
283
305
|
return true;
|
|
284
306
|
}
|
|
285
|
-
/* Codex asks its async question once and waits; forgetting it locally is all a dismissal is. */
|
|
286
307
|
dismissRequest(requestId) {
|
|
287
|
-
|
|
308
|
+
const answer = this.protocol.dismissQuestionAnswer(requestId);
|
|
309
|
+
return answer?.kind === 'respond' ? this.respond(requestId, answer.rpcId, answer.result) : this.protocol.dismissQuestion(requestId);
|
|
288
310
|
}
|
|
289
311
|
declineRequest(requestId, _message) {
|
|
290
312
|
const answer = this.protocol.declineRequest(requestId);
|
|
@@ -29,6 +29,7 @@ export declare class CodexProtocol {
|
|
|
29
29
|
result: unknown;
|
|
30
30
|
} | null;
|
|
31
31
|
questionAnswer(requestId: string, answers: Record<string, string>): CodexAnswer | null;
|
|
32
|
+
dismissQuestionAnswer(requestId: string): CodexAnswer | null;
|
|
32
33
|
dismissQuestion(requestId: string): boolean;
|
|
33
34
|
forgetPending(): void;
|
|
34
35
|
declineRequest(requestId: string): {
|
|
@@ -315,6 +315,13 @@ export class CodexProtocol {
|
|
|
315
315
|
/* The reply that settles an approval, or null when nothing waits under that id. */
|
|
316
316
|
approvalDecision(requestId, decision) {
|
|
317
317
|
const pending = this.pending.get(requestId);
|
|
318
|
+
if (pending?.type === 'permissions') {
|
|
319
|
+
this.pending.delete(requestId);
|
|
320
|
+
return {
|
|
321
|
+
rpcId: pending.rpcId,
|
|
322
|
+
result: { permissions: decision === 'deny' ? {} : pending.permissions, scope: decision === 'allow-always' ? 'session' : 'turn' }
|
|
323
|
+
};
|
|
324
|
+
}
|
|
318
325
|
if (pending?.type === 'elicitation') {
|
|
319
326
|
this.pending.delete(requestId);
|
|
320
327
|
return { rpcId: pending.rpcId, result: elicitationAnswer(decision, pending.persist) };
|
|
@@ -346,6 +353,10 @@ export class CodexProtocol {
|
|
|
346
353
|
}
|
|
347
354
|
return { kind: 'respond', rpcId: pending.rpcId, result: { answers: byId } };
|
|
348
355
|
}
|
|
356
|
+
dismissQuestionAnswer(requestId) {
|
|
357
|
+
const pending = this.pending.get(requestId);
|
|
358
|
+
return pending?.type === 'question' && pending.async === true && pending.rpcId !== null ? this.questionAnswer(requestId, {}) : null;
|
|
359
|
+
}
|
|
349
360
|
/* Drops an asynchronous question the person walked away from; a blocking one has to be answered. */
|
|
350
361
|
dismissQuestion(requestId) {
|
|
351
362
|
const pending = this.pending.get(requestId);
|
|
@@ -427,8 +438,39 @@ export class CodexProtocol {
|
|
|
427
438
|
if (questions.length === 0) {
|
|
428
439
|
return;
|
|
429
440
|
}
|
|
430
|
-
this.pending.set(requestId, {
|
|
431
|
-
|
|
441
|
+
this.pending.set(requestId, {
|
|
442
|
+
...this.owner(params),
|
|
443
|
+
type: 'question',
|
|
444
|
+
rpcId,
|
|
445
|
+
questionIds: questions.map((question) => question.id),
|
|
446
|
+
async: params.isBlocking === false
|
|
447
|
+
});
|
|
448
|
+
events.push({
|
|
449
|
+
type: 'question.requested',
|
|
450
|
+
requestId,
|
|
451
|
+
questions,
|
|
452
|
+
...(params.isBlocking === false ? { async: true } : {}),
|
|
453
|
+
...(this.isChildRequest(params) ? { background: true } : {})
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (method === 'item/permissions/requestApproval') {
|
|
458
|
+
if (!isRecord(params.permissions)) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const permissions = params.permissions;
|
|
462
|
+
this.pending.set(requestId, { ...this.owner(params), type: 'permissions', rpcId, permissions });
|
|
463
|
+
events.push({
|
|
464
|
+
type: 'approval.requested',
|
|
465
|
+
requestId,
|
|
466
|
+
ref: str(params.itemId),
|
|
467
|
+
toolName: 'RequestPermissions',
|
|
468
|
+
input: { cwd: params.cwd, permissions },
|
|
469
|
+
description: this.requestDescription(params, str(params.reason)),
|
|
470
|
+
canAllowAlways: true,
|
|
471
|
+
allowAlways: { label: 'Allow for this session', description: 'Grant these permissions for the rest of this session.' },
|
|
472
|
+
...(this.isChildRequest(params) ? { background: true } : {})
|
|
473
|
+
});
|
|
432
474
|
return;
|
|
433
475
|
}
|
|
434
476
|
if (method === 'mcpServer/elicitation/request') {
|
package/dist/host/handlers.js
CHANGED
|
@@ -31,7 +31,7 @@ export function chatHandlers(chats, providers) {
|
|
|
31
31
|
chats.detach(payload.chatId, clientId);
|
|
32
32
|
return {};
|
|
33
33
|
},
|
|
34
|
-
'chat.send': (payload) => chats.send(payload.chatId, payload.text, { mentions: payload.mentions, skills: payload.skills, chats: payload.chats }, payload.attachments),
|
|
34
|
+
'chat.send': (payload) => chats.send(payload.chatId, payload.text, { delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats }, payload.attachments),
|
|
35
35
|
'chat.unqueue': (payload) => ({ message: chats.unqueue(payload.chatId, payload.messageId) }),
|
|
36
36
|
'chat.sendNow': (payload) => {
|
|
37
37
|
chats.sendNow(payload.chatId, payload.messageId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "bun --conditions=source test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@adecore/agent-contracts": "0.20.
|
|
23
|
+
"@adecore/agent-contracts": "0.20.1",
|
|
24
24
|
"zod": "^4.6.5"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
package/src/chat/backend.ts
CHANGED
|
@@ -175,6 +175,8 @@ export interface ChatBackend {
|
|
|
175
175
|
// Starts the process if it has not started; resolves once the CLI can take a turn.
|
|
176
176
|
start(): Promise<void>;
|
|
177
177
|
sendTurn(input: TurnInput): void;
|
|
178
|
+
// Resolves after native acceptance; false means the turn ended before the input could join it.
|
|
179
|
+
steerTurn?(input: TurnInput): Promise<boolean>;
|
|
178
180
|
compact(): void;
|
|
179
181
|
interrupt(): void;
|
|
180
182
|
// False when nothing waits under that id, so the caller can answer the client with an error.
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -700,7 +700,7 @@ export class ChatCore {
|
|
|
700
700
|
throw new ChatError('invalid-attachments', checked.error.issues[0]!.message);
|
|
701
701
|
}
|
|
702
702
|
const attachments = await Promise.all(uploads.map((upload) => this.attachments.save(chatId, upload)));
|
|
703
|
-
return session.
|
|
703
|
+
return session.sendInput(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
|
|
704
704
|
}
|
|
705
705
|
|
|
706
706
|
/* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
|
package/src/chat/chat-session.ts
CHANGED
|
@@ -98,6 +98,7 @@ export interface LimitResumeHooks {
|
|
|
98
98
|
const TITLE_RECHECK_MS = 10_000;
|
|
99
99
|
|
|
100
100
|
export interface ChatSendExtras {
|
|
101
|
+
delivery?: 'steer' | 'queue';
|
|
101
102
|
mentions?: string[];
|
|
102
103
|
skills?: string[];
|
|
103
104
|
chats?: string[];
|
|
@@ -175,6 +176,7 @@ export class ChatSession {
|
|
|
175
176
|
private readonly orphans = new Set<string>();
|
|
176
177
|
private pendingPreambles: readonly string[];
|
|
177
178
|
private submission: { turnId: string; input: TurnInput } | null = null;
|
|
179
|
+
private readonly steering = new Set<string>();
|
|
178
180
|
|
|
179
181
|
constructor(options: ChatSessionOptions) {
|
|
180
182
|
this.options = options;
|
|
@@ -249,9 +251,7 @@ export class ChatSession {
|
|
|
249
251
|
}
|
|
250
252
|
|
|
251
253
|
/*
|
|
252
|
-
*
|
|
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.
|
|
254
|
+
* Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
|
|
255
255
|
*/
|
|
256
256
|
send(text: string, extras: ChatSendExtras = {}): { queued: boolean; turnId: string } {
|
|
257
257
|
const turnId = newId('turn');
|
|
@@ -279,11 +279,76 @@ export class ChatSession {
|
|
|
279
279
|
return { queued: false, turnId };
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
sendInput(text: string, extras: ChatSendExtras = {}): Promise<{ queued: boolean; turnId: string }> {
|
|
283
|
+
return this.submitInput(text, extras);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async submitInput(text: string, extras: ChatSendExtras): Promise<{ queued: boolean; turnId: string }> {
|
|
287
|
+
const backend = this.backend;
|
|
288
|
+
const turnId = this.info.activeTurnId;
|
|
289
|
+
if (
|
|
290
|
+
extras.delivery !== 'steer' ||
|
|
291
|
+
this.info.queuePaused ||
|
|
292
|
+
text.startsWith('/') ||
|
|
293
|
+
!this.busy ||
|
|
294
|
+
!turnId ||
|
|
295
|
+
!backend?.steerTurn ||
|
|
296
|
+
this.pendingStart ||
|
|
297
|
+
this.replacementWaits()
|
|
298
|
+
) {
|
|
299
|
+
return this.send(text, extras);
|
|
300
|
+
}
|
|
301
|
+
const queued = this.send(text, extras);
|
|
302
|
+
const message = this.queue.find((entry) => entry.turnId === queued.turnId);
|
|
303
|
+
if (!message) {
|
|
304
|
+
return queued;
|
|
305
|
+
}
|
|
306
|
+
this.steering.add(message.id);
|
|
307
|
+
try {
|
|
308
|
+
const references = this.options.references?.(extras.chats) ?? { ids: extras.chats ?? [], note: null };
|
|
309
|
+
const accepted = await backend.steerTurn({
|
|
310
|
+
promptId: message.id,
|
|
311
|
+
text,
|
|
312
|
+
preamble: references.note,
|
|
313
|
+
attachments: extras.attachments ?? [],
|
|
314
|
+
mentions: extras.mentions ?? [],
|
|
315
|
+
skills: extras.skills ?? []
|
|
316
|
+
});
|
|
317
|
+
if (!accepted) {
|
|
318
|
+
return queued;
|
|
319
|
+
}
|
|
320
|
+
this.emit([
|
|
321
|
+
this.thread.upsert({
|
|
322
|
+
id: `user-${message.id}`,
|
|
323
|
+
kind: 'user',
|
|
324
|
+
createdAt: message.createdAt,
|
|
325
|
+
turnId,
|
|
326
|
+
text,
|
|
327
|
+
mentions: extras.mentions,
|
|
328
|
+
skills: extras.skills,
|
|
329
|
+
chats: references.ids,
|
|
330
|
+
attachments: extras.attachments
|
|
331
|
+
}),
|
|
332
|
+
this.thread.patchInfo({ queue: this.queue.filter((entry) => entry.id !== message.id) })
|
|
333
|
+
]);
|
|
334
|
+
this.options.persist();
|
|
335
|
+
return { queued: false, turnId };
|
|
336
|
+
} catch (error) {
|
|
337
|
+
// A lost acknowledgement does not prove rejection. Keep the message for an explicit retry.
|
|
338
|
+
this.pauseQueue(true);
|
|
339
|
+
this.options.persist();
|
|
340
|
+
throw error;
|
|
341
|
+
} finally {
|
|
342
|
+
this.steering.delete(message.id);
|
|
343
|
+
this.drainQueue();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
282
347
|
/* Drops a queued message and hands it back; null when nothing waits under that id, as once it went out. */
|
|
283
348
|
unqueue(messageId: string): ChatQueuedMessage | null {
|
|
284
349
|
const queue = this.queue;
|
|
285
350
|
const message = queue.find((entry) => entry.id === messageId);
|
|
286
|
-
if (!message) {
|
|
351
|
+
if (!message || this.steering.has(message.id)) {
|
|
287
352
|
return null;
|
|
288
353
|
}
|
|
289
354
|
this.setQueue(queue.filter((entry) => entry.id !== messageId));
|
|
@@ -294,7 +359,7 @@ export class ChatSession {
|
|
|
294
359
|
sendNow(messageId: string): boolean {
|
|
295
360
|
const queue = this.queue;
|
|
296
361
|
const message = queue.find((entry) => entry.id === messageId);
|
|
297
|
-
if (!message) {
|
|
362
|
+
if (!message || this.steering.has(message.id)) {
|
|
298
363
|
return false;
|
|
299
364
|
}
|
|
300
365
|
this.setQueue([message, ...queue.filter((entry) => entry.id !== messageId)]);
|
|
@@ -323,6 +388,7 @@ export class ChatSession {
|
|
|
323
388
|
const explicit = asked || this.sendAfterReplacement;
|
|
324
389
|
if (
|
|
325
390
|
!next ||
|
|
391
|
+
this.steering.size > 0 ||
|
|
326
392
|
this.busy ||
|
|
327
393
|
(!explicit && (this.info.queuePaused || (limited !== null && (limited.limit.kind === 'usage' || this.info.resumeAt !== undefined))))
|
|
328
394
|
) {
|
|
@@ -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 (
|
|
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;
|
|
@@ -126,7 +126,7 @@ export class CodexBackend implements ChatBackend {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
private turnInput(input: TurnInput): unknown[] {
|
|
130
130
|
const images = input.attachments.filter((attachment) => attachmentImageMime(attachment) !== null);
|
|
131
131
|
if (images.length > 0 && this.imageInputSupported === false) {
|
|
132
132
|
throw new Error(`${this.launch.modelName} does not support image input. Choose a model that accepts images.`);
|
|
@@ -151,6 +151,11 @@ export class CodexBackend implements ChatBackend {
|
|
|
151
151
|
if (note !== '') {
|
|
152
152
|
parts.push(`\n\n${note}`);
|
|
153
153
|
}
|
|
154
|
+
return [...textInput(parts.join('')), ...images.map((attachment) => ({ type: 'localImage', path: attachment.path }))];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
sendTurn(input: TurnInput): void {
|
|
158
|
+
const nativeInput = this.turnInput(input);
|
|
154
159
|
const effort = this.launch.selection.options.effort;
|
|
155
160
|
const tier = codexServiceTier(this.launch.selection);
|
|
156
161
|
this.turnUnnamed = true;
|
|
@@ -159,7 +164,7 @@ export class CodexBackend implements ChatBackend {
|
|
|
159
164
|
'turn/start',
|
|
160
165
|
{
|
|
161
166
|
threadId: this.threadId,
|
|
162
|
-
input:
|
|
167
|
+
input: nativeInput,
|
|
163
168
|
model: this.launch.selection.model,
|
|
164
169
|
...(typeof effort === 'string' ? { effort } : {}),
|
|
165
170
|
...(tier === null ? {} : { serviceTier: tier })
|
|
@@ -168,6 +173,24 @@ export class CodexBackend implements ChatBackend {
|
|
|
168
173
|
);
|
|
169
174
|
}
|
|
170
175
|
|
|
176
|
+
async steerTurn(input: TurnInput): Promise<boolean> {
|
|
177
|
+
const transport = this.transport;
|
|
178
|
+
const turnId = this.protocol.turnId;
|
|
179
|
+
if (!transport || !turnId || this.turnUnnamed) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
await transport.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input: this.turnInput(input) });
|
|
184
|
+
return true;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const rejected = error instanceof Error && error.name === 'CodexRpcError';
|
|
187
|
+
if (rejected && /no active turn|not.*active|turn.*mismatch|expected.*turn|method not found/i.test(error.message)) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
171
194
|
private acceptPrompt(promptId: string | undefined): void {
|
|
172
195
|
if (promptId !== undefined && this.promptId === promptId) {
|
|
173
196
|
this.emit({ type: 'turn.accepted', promptId });
|
|
@@ -308,9 +331,9 @@ export class CodexBackend implements ChatBackend {
|
|
|
308
331
|
return true;
|
|
309
332
|
}
|
|
310
333
|
|
|
311
|
-
/* Codex asks its async question once and waits; forgetting it locally is all a dismissal is. */
|
|
312
334
|
dismissRequest(requestId: string): boolean {
|
|
313
|
-
|
|
335
|
+
const answer = this.protocol.dismissQuestionAnswer(requestId);
|
|
336
|
+
return answer?.kind === 'respond' ? this.respond(requestId, answer.rpcId, answer.result) : this.protocol.dismissQuestion(requestId);
|
|
314
337
|
}
|
|
315
338
|
|
|
316
339
|
declineRequest(requestId: string, _message: string): boolean {
|
|
@@ -138,7 +138,8 @@ type RequestOwner = { threadId: string | null; turnId: string | null };
|
|
|
138
138
|
type Pending = RequestOwner &
|
|
139
139
|
(
|
|
140
140
|
| { type: 'approval'; rpcId: CodexRpcId; kind: 'command' | 'fileChange'; amendment: string[] | null; decisions: string[] }
|
|
141
|
-
| { type: 'question'; rpcId: CodexRpcId | null; questionIds: string[] }
|
|
141
|
+
| { type: 'question'; rpcId: CodexRpcId | null; questionIds: string[]; async?: boolean }
|
|
142
|
+
| { type: 'permissions'; rpcId: CodexRpcId; permissions: Frame }
|
|
142
143
|
| { type: 'elicitation'; rpcId: CodexRpcId; persist: ElicitationPersist | null }
|
|
143
144
|
);
|
|
144
145
|
|
|
@@ -361,6 +362,13 @@ export class CodexProtocol {
|
|
|
361
362
|
/* The reply that settles an approval, or null when nothing waits under that id. */
|
|
362
363
|
approvalDecision(requestId: string, decision: ApprovalDecision): { rpcId: CodexRpcId; result: unknown } | null {
|
|
363
364
|
const pending = this.pending.get(requestId);
|
|
365
|
+
if (pending?.type === 'permissions') {
|
|
366
|
+
this.pending.delete(requestId);
|
|
367
|
+
return {
|
|
368
|
+
rpcId: pending.rpcId,
|
|
369
|
+
result: { permissions: decision === 'deny' ? {} : pending.permissions, scope: decision === 'allow-always' ? 'session' : 'turn' }
|
|
370
|
+
};
|
|
371
|
+
}
|
|
364
372
|
if (pending?.type === 'elicitation') {
|
|
365
373
|
this.pending.delete(requestId);
|
|
366
374
|
return { rpcId: pending.rpcId, result: elicitationAnswer(decision, pending.persist) };
|
|
@@ -394,6 +402,11 @@ export class CodexProtocol {
|
|
|
394
402
|
return { kind: 'respond', rpcId: pending.rpcId, result: { answers: byId } };
|
|
395
403
|
}
|
|
396
404
|
|
|
405
|
+
dismissQuestionAnswer(requestId: string): CodexAnswer | null {
|
|
406
|
+
const pending = this.pending.get(requestId);
|
|
407
|
+
return pending?.type === 'question' && pending.async === true && pending.rpcId !== null ? this.questionAnswer(requestId, {}) : null;
|
|
408
|
+
}
|
|
409
|
+
|
|
397
410
|
/* Drops an asynchronous question the person walked away from; a blocking one has to be answered. */
|
|
398
411
|
dismissQuestion(requestId: string): boolean {
|
|
399
412
|
const pending = this.pending.get(requestId);
|
|
@@ -485,8 +498,39 @@ export class CodexProtocol {
|
|
|
485
498
|
if (questions.length === 0) {
|
|
486
499
|
return;
|
|
487
500
|
}
|
|
488
|
-
this.pending.set(requestId, {
|
|
489
|
-
|
|
501
|
+
this.pending.set(requestId, {
|
|
502
|
+
...this.owner(params),
|
|
503
|
+
type: 'question',
|
|
504
|
+
rpcId,
|
|
505
|
+
questionIds: questions.map((question) => question.id),
|
|
506
|
+
async: params.isBlocking === false
|
|
507
|
+
});
|
|
508
|
+
events.push({
|
|
509
|
+
type: 'question.requested',
|
|
510
|
+
requestId,
|
|
511
|
+
questions,
|
|
512
|
+
...(params.isBlocking === false ? { async: true } : {}),
|
|
513
|
+
...(this.isChildRequest(params) ? { background: true } : {})
|
|
514
|
+
});
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (method === 'item/permissions/requestApproval') {
|
|
518
|
+
if (!isRecord(params.permissions)) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const permissions = params.permissions;
|
|
522
|
+
this.pending.set(requestId, { ...this.owner(params), type: 'permissions', rpcId, permissions });
|
|
523
|
+
events.push({
|
|
524
|
+
type: 'approval.requested',
|
|
525
|
+
requestId,
|
|
526
|
+
ref: str(params.itemId),
|
|
527
|
+
toolName: 'RequestPermissions',
|
|
528
|
+
input: { cwd: params.cwd, permissions },
|
|
529
|
+
description: this.requestDescription(params, str(params.reason)),
|
|
530
|
+
canAllowAlways: true,
|
|
531
|
+
allowAlways: { label: 'Allow for this session', description: 'Grant these permissions for the rest of this session.' },
|
|
532
|
+
...(this.isChildRequest(params) ? { background: true } : {})
|
|
533
|
+
});
|
|
490
534
|
return;
|
|
491
535
|
}
|
|
492
536
|
if (method === 'mcpServer/elicitation/request') {
|
package/src/host/handlers.ts
CHANGED
|
@@ -60,7 +60,12 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
|
|
|
60
60
|
return {};
|
|
61
61
|
},
|
|
62
62
|
'chat.send': (payload) =>
|
|
63
|
-
chats.send(
|
|
63
|
+
chats.send(
|
|
64
|
+
payload.chatId,
|
|
65
|
+
payload.text,
|
|
66
|
+
{ delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats },
|
|
67
|
+
payload.attachments
|
|
68
|
+
),
|
|
64
69
|
'chat.unqueue': (payload) => ({ message: chats.unqueue(payload.chatId, payload.messageId) }),
|
|
65
70
|
'chat.sendNow': (payload) => {
|
|
66
71
|
chats.sendNow(payload.chatId, payload.messageId);
|