@adecore/agents 0.19.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.
@@ -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;
@@ -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.send(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
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
- * 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.
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 && (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;
@@ -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
- sendTurn(input) {
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: [...textInput(parts.join('')), ...images.map((attachment) => ({ type: 'localImage', path: attachment.path }))],
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
- return this.protocol.dismissQuestion(requestId);
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, { ...this.owner(params), type: 'question', rpcId, questionIds: questions.map((question) => question.id) });
431
- events.push({ type: 'question.requested', requestId, questions, ...(this.isChildRequest(params) ? { background: true } : {}) });
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') {
@@ -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.19.0",
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.19.0",
23
+ "@adecore/agent-contracts": "0.20.1",
24
24
  "zod": "^4.6.5"
25
25
  },
26
26
  "devDependencies": {
@@ -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.
@@ -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.send(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
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. */
@@ -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
- * A message while a turn runs joins the queue instead of being refused; the host sends it when
253
- * that turn settles. One queue for both providers: Claude's steer and Codex's own queue have
254
- * different semantics, and one rule is easier to reason about than a rule per CLI.
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 (this.promptId !== null && (echoed.length > 0 ? !echoed.includes(this.promptId) : origin !== null && origin !== 'human' && origin !== 'user')) {
585
+ if (
586
+ this.promptId !== null &&
587
+ (echoed.length > 0
588
+ ? !echoed.includes(this.promptId) && !echoed.some((id) => this.steerIds.has(id))
589
+ : origin !== null && origin !== 'human' && origin !== 'user')
590
+ ) {
579
591
  if (typeof frame.total_cost_usd === 'number') {
580
592
  events.push({ type: 'usage', costUsd: num(frame.total_cost_usd) });
581
593
  }
@@ -586,6 +598,9 @@ export class ClaudeProtocol {
586
598
  }
587
599
  const promptId = this.promptId !== null && echoed.includes(this.promptId) ? this.promptId : null;
588
600
  this.promptId = null;
601
+ for (const id of echoed) {
602
+ this.steerIds.delete(id);
603
+ }
589
604
  const failed = frame.is_error === true || (typeof frame.subtype === 'string' && frame.subtype.startsWith('error'));
590
605
  const errors = Array.isArray(frame.errors) ? frame.errors.filter((error): error is string => typeof error === 'string') : [];
591
606
  const lastUuid = this.lastUuid;
@@ -126,7 +126,7 @@ export class CodexBackend implements ChatBackend {
126
126
  }
127
127
  }
128
128
 
129
- sendTurn(input: TurnInput): void {
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: [...textInput(parts.join('')), ...images.map((attachment) => ({ type: 'localImage', path: attachment.path }))],
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
- return this.protocol.dismissQuestion(requestId);
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, { ...this.owner(params), type: 'question', rpcId, questionIds: questions.map((question) => question.id) });
489
- events.push({ type: 'question.requested', requestId, questions, ...(this.isChildRequest(params) ? { background: true } : {}) });
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') {
@@ -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(payload.chatId, payload.text, { mentions: payload.mentions, skills: payload.skills, chats: payload.chats }, payload.attachments),
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);