@adecore/agents 0.27.0 → 0.27.2

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.
@@ -154,6 +154,7 @@ export declare class ChatSession {
154
154
  turnId: string;
155
155
  }>;
156
156
  private submitInput;
157
+ private steer;
157
158
  unqueue(messageId: string): ChatQueuedMessage | null;
158
159
  sendNow(messageId: string): boolean;
159
160
  private get queue();
@@ -12,6 +12,8 @@ import { errorText } from '../error-text.js';
12
12
  import { systemClock } from '../outbox/outbox-worker.js';
13
13
  // Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
14
14
  const TITLE_RECHECK_MS = 10_000;
15
+ // Well inside the 60 seconds a client gives a request; the steer itself goes on after it.
16
+ const STEER_ANSWER_MS = 15_000;
15
17
  /* One per limited turn, so a second fork of the same turn writes no second note. */
16
18
  function continuedNoteId(turnId) {
17
19
  return `continued-${turnId}`;
@@ -383,9 +385,30 @@ export class ChatSession {
383
385
  return queued;
384
386
  }
385
387
  this.steering.add(message.id);
388
+ const steer = this.steer(message, text, extras, backend.steerTurn.bind(backend), turnId, queued);
389
+ // Claude takes a steer only between tool calls, so a long command would outlast the client's request.
390
+ const clock = this.options.clock ?? systemClock;
391
+ let timer = null;
392
+ const waited = new Promise((resolve) => {
393
+ timer = clock.setTimeout(() => resolve(null), STEER_ANSWER_MS);
394
+ });
395
+ try {
396
+ const answer = await Promise.race([steer, waited]);
397
+ if (answer !== null) {
398
+ return answer;
399
+ }
400
+ // Still in the queue until it is taken; a later failure pauses the queue as it does now.
401
+ steer.catch(() => undefined);
402
+ return queued;
403
+ }
404
+ finally {
405
+ clock.clearTimeout(timer);
406
+ }
407
+ }
408
+ async steer(message, text, extras, steerTurn, turnId, queued) {
386
409
  try {
387
410
  const references = this.options.references?.(extras.chats) ?? { ids: extras.chats ?? [], note: null };
388
- const accepted = await backend.steerTurn({
411
+ const accepted = await steerTurn({
389
412
  promptId: message.id,
390
413
  text,
391
414
  preamble: references.note,
@@ -1475,7 +1498,14 @@ export class ChatSession {
1475
1498
  return;
1476
1499
  }
1477
1500
  const pending = this.watchStart(turnId);
1478
- const backend = this.ensureBackend();
1501
+ let backend;
1502
+ try {
1503
+ backend = this.ensureBackend();
1504
+ }
1505
+ catch (error) {
1506
+ // What the host prepares for a launch (its folders, its instructions) can throw too; the turn already opened, so it fails.
1507
+ backend = Promise.reject(error instanceof Error ? error : new Error(String(error)));
1508
+ }
1479
1509
  const generation = this.generation;
1480
1510
  void Promise.all([backend, this.turnReady, this.options.save?.()])
1481
1511
  .then(([started]) => {
@@ -32,6 +32,7 @@ export declare class ClaudeBackend implements ChatBackend {
32
32
  private readLines;
33
33
  private handleLine;
34
34
  private readLine;
35
+ private cancelledSteer;
35
36
  private awaitSteers;
36
37
  private clearSteerTimer;
37
38
  private rejectSteers;
@@ -7,6 +7,8 @@ import { buildUserMessage } from './input.js';
7
7
  // Claude may echo a steer only once a slow tool returns, so a running turn sets no deadline; after the turn ended, a message
8
8
  // it still holds opens the next turn at once or never arrives.
9
9
  const STEER_GRACE_MS = 30_000;
10
+ // The request id of a `cancel_async_message` that withdraws a steer, followed by its uuid.
11
+ const CANCEL_STEER = 'cancel-steer-';
10
12
  /*
11
13
  * One `claude -p` process on the stream-json protocol: flags from the selection and the modes,
12
14
  * frames in over stdout, frames out over stdin. The process stays alive between turns; the session
@@ -101,6 +103,14 @@ export class ClaudeBackend {
101
103
  this.interrupted = true;
102
104
  this.protocol.noteInterrupt();
103
105
  this.write({ type: 'control_request', request_id: `interrupt-${Date.now()}`, request: { subtype: 'interrupt' } });
106
+ // A steer the CLI has not taken in survives a plain interrupt and would run after the Stop; it goes back to the queue instead.
107
+ for (const promptId of this.steers.keys()) {
108
+ this.write({
109
+ type: 'control_request',
110
+ request_id: `${CANCEL_STEER}${promptId}`,
111
+ request: { subtype: 'cancel_async_message', message_uuid: promptId }
112
+ });
113
+ }
104
114
  }
105
115
  stopTask(taskId) {
106
116
  this.write(this.protocol.stopTaskRequest(taskId));
@@ -167,6 +177,9 @@ export class ClaudeBackend {
167
177
  return;
168
178
  }
169
179
  const echoed = typeof frame === 'object' && frame !== null ? frame : null;
180
+ if (echoed?.type === 'control_response' && this.cancelledSteer(echoed.response)) {
181
+ return;
182
+ }
170
183
  if (echoed?.type === 'user' && typeof echoed.uuid === 'string' && echoed.parent_tool_use_id == null) {
171
184
  this.steers.get(echoed.uuid)?.resolve(true);
172
185
  this.steers.delete(echoed.uuid);
@@ -195,6 +208,25 @@ export class ClaudeBackend {
195
208
  this.failed(error);
196
209
  }
197
210
  }
211
+ /* Whether this answers a withdrawn steer; one the CLI no longer held (already taken in) still waits for its echo. */
212
+ cancelledSteer(response) {
213
+ if (typeof response !== 'object' || response === null) {
214
+ return false;
215
+ }
216
+ const { request_id: requestId, response: body } = response;
217
+ if (typeof requestId !== 'string' || !requestId.startsWith(CANCEL_STEER)) {
218
+ return false;
219
+ }
220
+ const promptId = requestId.slice(CANCEL_STEER.length);
221
+ if (typeof body === 'object' && body !== null && body.cancelled === true) {
222
+ this.steers.get(promptId)?.resolve(false);
223
+ this.steers.delete(promptId);
224
+ if (this.steers.size === 0) {
225
+ this.clearSteerTimer();
226
+ }
227
+ }
228
+ return true;
229
+ }
198
230
  awaitSteers() {
199
231
  if (this.steers.size === 0 || this.steerTimer !== null) {
200
232
  return;
@@ -318,7 +318,8 @@ export class CodexBackend {
318
318
  }
319
319
  catch (error) {
320
320
  const rejected = error instanceof Error && error.name === 'CodexRpcError';
321
- if (rejected && /no active turn|not.*active|turn.*mismatch|expected.*turn|method not found/i.test(error.message)) {
321
+ // A compact or review turn takes no steer (codex 0.162: "cannot steer a compact turn"); the message waits for the next turn.
322
+ if (rejected && /no active turn|not.*active|turn.*mismatch|expected.*turn|method not found|cannot steer|not steerable/i.test(error.message)) {
322
323
  return false;
323
324
  }
324
325
  throw error;
@@ -21,6 +21,7 @@ export declare class ThreadProjector {
21
21
  private readonly pendingMetadata;
22
22
  private readonly backgroundCalls;
23
23
  private readonly unplacedCalls;
24
+ private readonly wokenRows;
24
25
  private thinking;
25
26
  constructor(thread: ChatThread, options: ProjectorOptions);
26
27
  reset(): void;
@@ -100,6 +100,8 @@ export class ThreadProjector {
100
100
  backgroundCalls = new Set();
101
101
  // Agents a subagent opened whose call never reached the stream (a grandchild's own); only drill-in shows them.
102
102
  unplacedCalls = new Set();
103
+ // The row of an earlier process an agent woke under, by the ref of its own call in this one: its frames still name that call.
104
+ wokenRows = new Map();
103
105
  // The stretch of thinking still open: its item, and which refs already streamed into it.
104
106
  thinking = null;
105
107
  constructor(thread, options) {
@@ -549,7 +551,8 @@ export class ThreadProjector {
549
551
  events.push(this.thread.upsert({ ...tool, progress }));
550
552
  }
551
553
  subagent(generation, ref) {
552
- const item = this.thread.get(this.itemId(generation, ref));
554
+ const id = this.itemId(generation, ref);
555
+ const item = this.thread.get(id) ?? this.thread.get(this.wokenRows.get(id) ?? '');
553
556
  return item?.kind === 'subagent' ? item : null;
554
557
  }
555
558
  /*
@@ -652,6 +655,10 @@ export class ThreadProjector {
652
655
  }
653
656
  // A settled agent that starts again under another call was woken by a message, and works on in the background.
654
657
  const resumed = event.resumed === true || (item.status !== 'running' && item.toolUseId !== event.ref);
658
+ const ownCall = this.itemId(generation, item.toolUseId);
659
+ if (resumed && item.id !== ownCall) {
660
+ this.wokenRows.set(ownCall, item.id);
661
+ }
655
662
  events.push(this.thread.upsert({
656
663
  ...item,
657
664
  description: item.description || event.description || '',
@@ -104,9 +104,16 @@ export function wireTasks(deps) {
104
104
  coordinator,
105
105
  recover: async () => {
106
106
  for (const task of deps.tasks.all()) {
107
- if (task.status === 'open') {
107
+ if (task.status !== 'open') {
108
+ continue;
109
+ }
110
+ // One child whose record this version cannot read must not keep every later task from recovering.
111
+ try {
108
112
  await recoverOpen(task);
109
113
  }
114
+ catch (e) {
115
+ console.error(`Recovering task ${task.id} failed:`, errorText(e));
116
+ }
110
117
  }
111
118
  await coordinator.settled();
112
119
  for (const task of deps.tasks.all()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.27.0",
3
+ "version": "0.27.2",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,8 +81,8 @@
81
81
  "test": "bun --conditions=source test"
82
82
  },
83
83
  "dependencies": {
84
- "@adecore/agent-contracts": "0.27.0",
85
- "@adecore/intelligent-ui": "0.27.0",
84
+ "@adecore/agent-contracts": "0.27.2",
85
+ "@adecore/intelligent-ui": "0.27.2",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -116,6 +116,8 @@ export interface LimitResumeHooks {
116
116
 
117
117
  // Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
118
118
  const TITLE_RECHECK_MS = 10_000;
119
+ // Well inside the 60 seconds a client gives a request; the steer itself goes on after it.
120
+ const STEER_ANSWER_MS = 15_000;
119
121
 
120
122
  export interface ChatSendExtras {
121
123
  uiChoice?: ChatUiChoiceOrigin;
@@ -544,9 +546,37 @@ export class ChatSession {
544
546
  return queued;
545
547
  }
546
548
  this.steering.add(message.id);
549
+ const steer = this.steer(message, text, extras, backend.steerTurn.bind(backend), turnId, queued);
550
+ // Claude takes a steer only between tool calls, so a long command would outlast the client's request.
551
+ const clock = this.options.clock ?? systemClock;
552
+ let timer: unknown = null;
553
+ const waited = new Promise<null>((resolve) => {
554
+ timer = clock.setTimeout(() => resolve(null), STEER_ANSWER_MS);
555
+ });
556
+ try {
557
+ const answer = await Promise.race([steer, waited]);
558
+ if (answer !== null) {
559
+ return answer;
560
+ }
561
+ // Still in the queue until it is taken; a later failure pauses the queue as it does now.
562
+ steer.catch(() => undefined);
563
+ return queued;
564
+ } finally {
565
+ clock.clearTimeout(timer);
566
+ }
567
+ }
568
+
569
+ private async steer(
570
+ message: ChatQueuedMessage,
571
+ text: string,
572
+ extras: ChatSendExtras,
573
+ steerTurn: NonNullable<ChatBackend['steerTurn']>,
574
+ turnId: string,
575
+ queued: { queued: boolean; turnId: string }
576
+ ): Promise<{ queued: boolean; turnId: string }> {
547
577
  try {
548
578
  const references = this.options.references?.(extras.chats) ?? { ids: extras.chats ?? [], note: null };
549
- const accepted = await backend.steerTurn({
579
+ const accepted = await steerTurn({
550
580
  promptId: message.id,
551
581
  text,
552
582
  preamble: references.note,
@@ -1705,7 +1735,13 @@ export class ChatSession {
1705
1735
  return;
1706
1736
  }
1707
1737
  const pending = this.watchStart(turnId);
1708
- const backend = this.ensureBackend();
1738
+ let backend: Promise<ChatBackend>;
1739
+ try {
1740
+ backend = this.ensureBackend();
1741
+ } catch (error) {
1742
+ // What the host prepares for a launch (its folders, its instructions) can throw too; the turn already opened, so it fails.
1743
+ backend = Promise.reject(error instanceof Error ? error : new Error(String(error)));
1744
+ }
1709
1745
  const generation = this.generation;
1710
1746
  void Promise.all([backend, this.turnReady, this.options.save?.()])
1711
1747
  .then(([started]) => {
@@ -9,6 +9,8 @@ import { buildUserMessage } from './input.ts';
9
9
  // Claude may echo a steer only once a slow tool returns, so a running turn sets no deadline; after the turn ended, a message
10
10
  // it still holds opens the next turn at once or never arrives.
11
11
  const STEER_GRACE_MS = 30_000;
12
+ // The request id of a `cancel_async_message` that withdraws a steer, followed by its uuid.
13
+ const CANCEL_STEER = 'cancel-steer-';
12
14
 
13
15
  export interface ClaudeBackendOptions {
14
16
  // Tools the CLI runs without asking, in every mode, as `--allowedTools` patterns (`Bash(git status)`).
@@ -116,6 +118,14 @@ export class ClaudeBackend implements ChatBackend {
116
118
  this.interrupted = true;
117
119
  this.protocol.noteInterrupt();
118
120
  this.write({ type: 'control_request', request_id: `interrupt-${Date.now()}`, request: { subtype: 'interrupt' } });
121
+ // A steer the CLI has not taken in survives a plain interrupt and would run after the Stop; it goes back to the queue instead.
122
+ for (const promptId of this.steers.keys()) {
123
+ this.write({
124
+ type: 'control_request',
125
+ request_id: `${CANCEL_STEER}${promptId}`,
126
+ request: { subtype: 'cancel_async_message', message_uuid: promptId }
127
+ });
128
+ }
119
129
  }
120
130
 
121
131
  stopTask(taskId: string): void {
@@ -193,6 +203,9 @@ export class ClaudeBackend implements ChatBackend {
193
203
  return;
194
204
  }
195
205
  const echoed = typeof frame === 'object' && frame !== null ? (frame as Record<string, unknown>) : null;
206
+ if (echoed?.type === 'control_response' && this.cancelledSteer(echoed.response)) {
207
+ return;
208
+ }
196
209
  if (echoed?.type === 'user' && typeof echoed.uuid === 'string' && echoed.parent_tool_use_id == null) {
197
210
  this.steers.get(echoed.uuid)?.resolve(true);
198
211
  this.steers.delete(echoed.uuid);
@@ -222,6 +235,26 @@ export class ClaudeBackend implements ChatBackend {
222
235
  }
223
236
  }
224
237
 
238
+ /* Whether this answers a withdrawn steer; one the CLI no longer held (already taken in) still waits for its echo. */
239
+ private cancelledSteer(response: unknown): boolean {
240
+ if (typeof response !== 'object' || response === null) {
241
+ return false;
242
+ }
243
+ const { request_id: requestId, response: body } = response as { request_id?: unknown; response?: unknown };
244
+ if (typeof requestId !== 'string' || !requestId.startsWith(CANCEL_STEER)) {
245
+ return false;
246
+ }
247
+ const promptId = requestId.slice(CANCEL_STEER.length);
248
+ if (typeof body === 'object' && body !== null && (body as { cancelled?: unknown }).cancelled === true) {
249
+ this.steers.get(promptId)?.resolve(false);
250
+ this.steers.delete(promptId);
251
+ if (this.steers.size === 0) {
252
+ this.clearSteerTimer();
253
+ }
254
+ }
255
+ return true;
256
+ }
257
+
225
258
  private awaitSteers(): void {
226
259
  if (this.steers.size === 0 || this.steerTimer !== null) {
227
260
  return;
@@ -338,7 +338,8 @@ export class CodexBackend implements ChatBackend {
338
338
  return true;
339
339
  } catch (error) {
340
340
  const rejected = error instanceof Error && error.name === 'CodexRpcError';
341
- if (rejected && /no active turn|not.*active|turn.*mismatch|expected.*turn|method not found/i.test(error.message)) {
341
+ // A compact or review turn takes no steer (codex 0.162: "cannot steer a compact turn"); the message waits for the next turn.
342
+ if (rejected && /no active turn|not.*active|turn.*mismatch|expected.*turn|method not found|cannot steer|not steerable/i.test(error.message)) {
342
343
  return false;
343
344
  }
344
345
  throw error;
@@ -131,6 +131,8 @@ export class ThreadProjector {
131
131
  private readonly backgroundCalls = new Set<string>();
132
132
  // Agents a subagent opened whose call never reached the stream (a grandchild's own); only drill-in shows them.
133
133
  private readonly unplacedCalls = new Set<string>();
134
+ // The row of an earlier process an agent woke under, by the ref of its own call in this one: its frames still name that call.
135
+ private readonly wokenRows = new Map<string, string>();
134
136
  // The stretch of thinking still open: its item, and which refs already streamed into it.
135
137
  private thinking: { id: string; refs: Set<string>; last: string | null } | null = null;
136
138
 
@@ -610,7 +612,8 @@ export class ThreadProjector {
610
612
  }
611
613
 
612
614
  private subagent(generation: number, ref: string): ChatSubagentItem | null {
613
- const item = this.thread.get(this.itemId(generation, ref));
615
+ const id = this.itemId(generation, ref);
616
+ const item = this.thread.get(id) ?? this.thread.get(this.wokenRows.get(id) ?? '');
614
617
  return item?.kind === 'subagent' ? item : null;
615
618
  }
616
619
 
@@ -719,6 +722,10 @@ export class ThreadProjector {
719
722
  }
720
723
  // A settled agent that starts again under another call was woken by a message, and works on in the background.
721
724
  const resumed = event.resumed === true || (item.status !== 'running' && item.toolUseId !== event.ref);
725
+ const ownCall = this.itemId(generation, item.toolUseId);
726
+ if (resumed && item.id !== ownCall) {
727
+ this.wokenRows.set(ownCall, item.id);
728
+ }
722
729
  events.push(
723
730
  this.thread.upsert({
724
731
  ...item,
@@ -181,8 +181,14 @@ export function wireTasks(deps: TaskWiringDeps): TaskWiring {
181
181
  coordinator,
182
182
  recover: async () => {
183
183
  for (const task of deps.tasks.all()) {
184
- if (task.status === 'open') {
184
+ if (task.status !== 'open') {
185
+ continue;
186
+ }
187
+ // One child whose record this version cannot read must not keep every later task from recovering.
188
+ try {
185
189
  await recoverOpen(task);
190
+ } catch (e) {
191
+ console.error(`Recovering task ${task.id} failed:`, errorText(e));
186
192
  }
187
193
  }
188
194
  await coordinator.settled();