@adecore/agents 0.21.2 → 0.22.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.
@@ -137,6 +137,39 @@ export function fakeClaude(io) {
137
137
  // What a background agent asked; its answer comes in after the turn that launched it, whenever a person gives one.
138
138
  let backgroundAsk = null;
139
139
  let slow = false;
140
+ // The two foreground subagents of `two subagents` that have not settled; the turn stays open until both have.
141
+ const openTwo = new Set();
142
+ const settleTwo = (name, stopped) => {
143
+ const toolUseId = name === 'a-one' ? 'toolu_two_a' : 'toolu_two_b';
144
+ if (!openTwo.delete(name)) {
145
+ return;
146
+ }
147
+ if (stopped) {
148
+ out({ type: 'system', subtype: 'task_updated', task_id: name, patch: { status: 'killed', end_time: 0 }, session_id: sessionId });
149
+ out({
150
+ type: 'system',
151
+ subtype: 'task_notification',
152
+ task_id: name,
153
+ tool_use_id: toolUseId,
154
+ status: 'stopped',
155
+ output_file: '',
156
+ summary: 'Stopped',
157
+ session_id: sessionId
158
+ });
159
+ }
160
+ out({
161
+ type: 'user',
162
+ message: {
163
+ role: 'user',
164
+ content: [{ type: 'tool_result', tool_use_id: toolUseId, content: stopped ? 'Stopped' : `${name} done`, is_error: stopped }]
165
+ },
166
+ session_id: sessionId
167
+ });
168
+ if (openTwo.size === 0) {
169
+ assistantText('summarized B');
170
+ result();
171
+ }
172
+ };
140
173
  // The rest of a background agent once its request was answered: its call's result, its report, and the turn the CLI opens about it.
141
174
  const finishBackgroundAgent = (command, allowed, message) => {
142
175
  const toolUseId = 'toolu_bgask';
@@ -182,6 +215,43 @@ export function fakeClaude(io) {
182
215
  io.exit(1);
183
216
  return;
184
217
  }
218
+ // Two foreground subagents beside each other; the turn ends once both settled: a-two when the test runs what is put off, a-one by a `stop_task`.
219
+ if (text === 'two subagents') {
220
+ openTwo.add('a-one').add('a-two');
221
+ for (const [name, toolUseId] of [
222
+ ['a-one', 'toolu_two_a'],
223
+ ['a-two', 'toolu_two_b']
224
+ ]) {
225
+ out({
226
+ type: 'assistant',
227
+ message: {
228
+ id: `msg_${nonce}_${++messageCounter}`,
229
+ model,
230
+ role: 'assistant',
231
+ content: [
232
+ { type: 'tool_use', id: toolUseId, name: 'Agent', input: { description: name, subagent_type: 'general-purpose', prompt: name } }
233
+ ],
234
+ usage
235
+ },
236
+ session_id: sessionId
237
+ });
238
+ out({
239
+ type: 'system',
240
+ subtype: 'task_started',
241
+ task_id: name,
242
+ tool_use_id: toolUseId,
243
+ description: name,
244
+ subagent_type: 'general-purpose',
245
+ is_backgrounded: false,
246
+ spawn_depth: 1,
247
+ task_type: 'local_agent',
248
+ prompt: name,
249
+ session_id: sessionId
250
+ });
251
+ }
252
+ io.later(() => settleTwo('a-two', false));
253
+ return;
254
+ }
185
255
  if (text === 'crash loudly') {
186
256
  io.err(`${'warming up\n'.repeat(2000)}Error: the fake lost its key\n at handleUser (fake-claude.ts)\n`);
187
257
  io.exit(1);
@@ -932,6 +1002,9 @@ export function fakeClaude(io) {
932
1002
  else if (frame.type === 'control_request') {
933
1003
  const request = frame.request;
934
1004
  out({ type: 'control_response', response: { subtype: 'success', request_id: frame.request_id, response: {} } });
1005
+ if (request.subtype === 'stop_task' && (request.task_id === 'a-one' || request.task_id === 'a-two')) {
1006
+ settleTwo(request.task_id, true);
1007
+ }
935
1008
  if (request.subtype === 'interrupt' && slow) {
936
1009
  slow = false;
937
1010
  assistantText('[interrupted]');
@@ -0,0 +1,14 @@
1
+ import type { ChatItem } from '@adecore/agent-contracts';
2
+ export interface ThreadHit {
3
+ itemId: string;
4
+ author: 'person' | 'agent';
5
+ snippet: string;
6
+ matchStart: number;
7
+ matchLength: number;
8
+ createdAt: number;
9
+ turnId: string | null;
10
+ }
11
+ export interface ThreadSearchOptions {
12
+ limit: number;
13
+ }
14
+ export declare function searchThreadItems(items: readonly ChatItem[], query: string, { limit }: ThreadSearchOptions): ThreadHit[];
@@ -0,0 +1,64 @@
1
+ // Characters kept on each side of the match.
2
+ const CONTEXT = 60;
3
+ // Emphasis and code markers carry no meaning in a one-line snippet, where they only read as noise.
4
+ function plain(text) {
5
+ return text.replace(/\*{1,3}|_{2,}|`+/g, '').replace(/\s+/g, ' ');
6
+ }
7
+ // Moves a cut forward to the start of the next word, never past `limit`, so a snippet does not open on half a word.
8
+ function wordStart(text, from, limit) {
9
+ if (from === 0 || /\s/.test(text[from - 1]) || /\s/.test(text[from])) {
10
+ return from;
11
+ }
12
+ const space = text.slice(from, limit).search(/\s/);
13
+ return space === -1 ? from : from + space + 1;
14
+ }
15
+ // Moves a cut back to the end of the previous word, never before `limit`.
16
+ function wordEnd(text, to, limit) {
17
+ if (to === text.length || /\s/.test(text[to - 1]) || /\s/.test(text[to])) {
18
+ return to;
19
+ }
20
+ const space = text.slice(limit, to).search(/\s\S*$/);
21
+ return space === -1 ? to : limit + space;
22
+ }
23
+ function snippetOf(text, index, length) {
24
+ const from = wordStart(text, Math.max(0, index - CONTEXT), index);
25
+ const to = wordEnd(text, Math.min(text.length, index + length + CONTEXT), index + length);
26
+ const before = `${from > 0 ? '…' : ''}${plain(text.slice(from, index))}`;
27
+ const match = plain(text.slice(index, index + length));
28
+ const after = `${plain(text.slice(index + length, to))}${to < text.length ? '…' : ''}`;
29
+ return { snippet: `${before}${match}${after}`, matchStart: before.length, matchLength: match.length };
30
+ }
31
+ /*
32
+ * The messages of a thread that hold `query`, oldest first, one hit per message. Only what a person
33
+ * and the agent said counts: what a subagent said (`parentToolUseId`) is not part of the conversation.
34
+ */
35
+ export function searchThreadItems(items, query, { limit }) {
36
+ if (query === '' || limit <= 0) {
37
+ return [];
38
+ }
39
+ const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
40
+ const hits = [];
41
+ for (const item of items) {
42
+ if (item.kind !== 'user' && item.kind !== 'assistant') {
43
+ continue;
44
+ }
45
+ if (item.kind === 'assistant' && item.parentToolUseId) {
46
+ continue;
47
+ }
48
+ const found = pattern.exec(item.text);
49
+ if (found === null) {
50
+ continue;
51
+ }
52
+ hits.push({
53
+ itemId: item.id,
54
+ author: item.kind === 'user' ? 'person' : 'agent',
55
+ ...snippetOf(item.text, found.index, found[0].length),
56
+ createdAt: item.createdAt,
57
+ turnId: item.turnId
58
+ });
59
+ if (hits.length >= limit) {
60
+ break;
61
+ }
62
+ }
63
+ return hits;
64
+ }
@@ -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, { delivery: payload.delivery, 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, quoteSource: payload.quoteSource }, payload.attachments),
35
35
  'chat.uiChoice': (payload, clientId) => chats.choose(payload, clientId),
36
36
  'ui.link': (payload, clientId) => chats.linkUi(payload, clientId),
37
37
  'ui.query': (payload, clientId) => chats.queryUi(payload, clientId),
@@ -33,7 +33,8 @@ export class ModelCatalog {
33
33
  badge: model.badge,
34
34
  legacy: model.legacy === true,
35
35
  isDefault: model.slug === this.data.defaultModel,
36
- options: this.profileOf(model.slug)?.options ?? []
36
+ options: this.profileOf(model.slug)?.options ?? [],
37
+ ...(model.minCliVersion === undefined ? {} : { minCliVersion: model.minCliVersion })
37
38
  }));
38
39
  }
39
40
  /* What a person reads for a model, so an error in a thread names it the way the picker does. */
@@ -44,6 +44,7 @@ export interface DeliverWaitingDeps {
44
44
  deliver(chatId: string, delivery: {
45
45
  noteId: string;
46
46
  note: string;
47
+ from?: string;
47
48
  preamble: string;
48
49
  }): Promise<boolean>;
49
50
  words: WaitingWords;
@@ -1,4 +1,4 @@
1
- import { clipText } from '@adecore/agent-contracts';
1
+ import { clipText, WAITING_NOTE_PREFIX } from '@adecore/agent-contracts';
2
2
  import { errorText } from '../error-text.js';
3
3
  export function isPendingRequest(item) {
4
4
  return (item?.kind === 'question' && item.state === 'pending') || (item?.kind === 'approval' && item.decision === 'pending');
@@ -26,7 +26,7 @@ export function chatRequests(chats) {
26
26
  }
27
27
  /* The id of the note one request of one child leaves in its parent, so delivering it twice leaves one. */
28
28
  export function waitingNoteId(childId, requestId) {
29
- return `waiting-${childId}-${requestId}`;
29
+ return `${WAITING_NOTE_PREFIX}${childId}-${requestId}`;
30
30
  }
31
31
  /*
32
32
  * How long a request may wait before its parent hears of it. A person watching the child settles one
@@ -140,6 +140,6 @@ export function deliverWaitingHandler(deps) {
140
140
  return;
141
141
  }
142
142
  const texts = waitingTexts({ id: childId, title: deps.titleFor(childId) ?? childId }, item, deps.words);
143
- await deps.deliver(entry.target, { noteId: waitingNoteId(childId, requestId), ...texts });
143
+ await deps.deliver(entry.target, { noteId: waitingNoteId(childId, requestId), from: childId, ...texts });
144
144
  };
145
145
  }
@@ -19,5 +19,6 @@ export interface CodexParserState {
19
19
  }
20
20
  export declare function createCodexState(): CodexParserState;
21
21
  export declare function cloneCodexState(state: CodexParserState): CodexParserState;
22
+ export declare function isForkOrSubagent(payload: Record<string, unknown>): boolean;
22
23
  export declare function parseCodexLine(line: string, state: CodexParserState): UsageRecord | null;
23
24
  export {};
@@ -45,7 +45,7 @@ function deltaOf(total, previous) {
45
45
  return delta.input < 0 || delta.cached < 0 || delta.cacheWrite < 0 || delta.output < 0 || delta.reasoning < 0 ? null : delta;
46
46
  }
47
47
  /* A thread that was forked or spawned repeats its parent's events before its own work starts. */
48
- function isForkOrSubagent(payload) {
48
+ export function isForkOrSubagent(payload) {
49
49
  if (typeof payload.forked_from_id === 'string' || typeof payload.parent_thread_id === 'string') {
50
50
  return true;
51
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.21.2",
3
+ "version": "0.22.1",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,8 +20,8 @@
20
20
  "test": "bun --conditions=source test"
21
21
  },
22
22
  "dependencies": {
23
- "@adecore/agent-contracts": "0.21.2",
24
- "@adecore/intelligent-ui": "0.21.2",
23
+ "@adecore/agent-contracts": "0.22.1",
24
+ "@adecore/intelligent-ui": "0.22.1",
25
25
  "zod": "^4.6.5"
26
26
  },
27
27
  "devDependencies": {
@@ -160,6 +160,8 @@ export type BackendEvent =
160
160
  | { type: 'compaction'; preTokens: number | null }
161
161
  | { type: 'model'; model: string }
162
162
  | { type: 'note'; level: 'info' | 'warning' | 'error'; text: string }
163
+ // The CLI answered a `stopTask` with an error, so the task still runs.
164
+ | { type: 'stop.refused'; taskId: string; message: string }
163
165
  // `native` is the CLI's own name for where the turn ended, which a fork of the chat is cut at.
164
166
  | {
165
167
  type: 'turn.done';
@@ -640,8 +640,9 @@ export class ChatCore {
640
640
  }
641
641
 
642
642
  /*
643
- * Stops one running sub-agent of the CLI's own. It cannot be stopped apart from its CLI, so its row
644
- * is only marked, and only while no turn runs: stopping that turn is the composer's Stop.
643
+ * Stops one running sub-agent of the CLI's own. The process that started it ends it with `stop_task`
644
+ * and the parent keeps working. Without such a process the row is only marked, and only while no
645
+ * turn runs: stopping that turn is the composer's Stop.
645
646
  */
646
647
  async stopSubagent(chatId: string, toolUseId: string): Promise<void> {
647
648
  await this.loaded(chatId);
@@ -656,7 +657,7 @@ export class ChatCore {
656
657
  if (row.origin === 'ruimte') {
657
658
  throw new ChatError('chat-unsupported', 'This row stands for work the host runs; stop it there');
658
659
  }
659
- session.markSubagentStopped(toolUseId);
660
+ session.stopSubagent(toolUseId);
660
661
  }
661
662
 
662
663
  /* Ends a command or a monitor a chat's CLI runs in the background, for a person who pressed Stop on it. */
@@ -7,6 +7,7 @@ import type {
7
7
  ChatInfo,
8
8
  ChatItem,
9
9
  ChatQueuedMessage,
10
+ ChatQuoteSource,
10
11
  ChatSkill,
11
12
  ChatSubagentItem,
12
13
  ChatTurnItem,
@@ -42,6 +43,8 @@ export interface TurnCheckpoints {
42
43
  diff(cwd: string, tree: string): Promise<ChatCheckpointDiff | null>;
43
44
  /* The diff of a turn that ended, with the tree of the folder it was taken against: where that turn left the files. */
44
45
  settle(cwd: string, tree: string): Promise<{ diff: ChatCheckpointDiff; after: string } | null>;
46
+ /* The commit HEAD points at, or null when unborn or outside a repository. A host without it records no heads. */
47
+ head?(cwd: string): Promise<string | null>;
45
48
  }
46
49
 
47
50
  /* What a host says in front of the next real prompt, beside the preambles a chat keeps itself. */
@@ -123,6 +126,7 @@ export interface ChatSendExtras {
123
126
  mentions?: string[];
124
127
  skills?: string[];
125
128
  chats?: string[];
129
+ quoteSource?: ChatQuoteSource;
126
130
  attachments?: ChatAttachment[];
127
131
  }
128
132
 
@@ -193,6 +197,10 @@ export class ChatSession {
193
197
  private naming = false;
194
198
  // Set while the host goes down: a CLI dying with it must not end the turn a restart takes up again.
195
199
  private frozen = false;
200
+ // Which CLI process (generation) announced each subagent of its own, by call id; only that one can stop it.
201
+ private readonly subagentOwners = new Map<string, number>();
202
+ // Subagents a stop_task went out for, as task id to call id, until their notification settles them.
203
+ private readonly stopping = new Map<string, string>();
196
204
  // Background subagents a load found running whose transcript did not show an end yet; no CLI here will report them.
197
205
  private readonly orphans = new Set<string>();
198
206
  private pendingPreambles: readonly string[];
@@ -302,7 +310,8 @@ export class ChatSession {
302
310
  /*
303
311
  * Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
304
312
  */
305
- send(text: string, extras: ChatSendExtras = {}): { queued: boolean; turnId: string } {
313
+ send(text: string, sent: ChatSendExtras = {}): { queued: boolean; turnId: string } {
314
+ const extras = this.vetted(sent);
306
315
  const turnId = newId('turn');
307
316
  // Behind whatever still waits (a CLI that crashed leaves its queue), so everything goes out in the order it was sent.
308
317
  if (this.busy || this.queue.length > 0 || this.replacementWaits()) {
@@ -318,6 +327,7 @@ export class ChatSession {
318
327
  ...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
319
328
  ...(extras.skills?.length ? { skills: extras.skills } : {}),
320
329
  ...(extras.chats?.length ? { chats: extras.chats } : {}),
330
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {}),
321
331
  ...(extras.attachments?.length ? { attachments: extras.attachments } : {})
322
332
  };
323
333
  this.setQueue([...this.queue, message]);
@@ -424,11 +434,22 @@ export class ChatSession {
424
434
  this.options.persist();
425
435
  }
426
436
 
437
+ /* A quote whose source is no reply of this thread is sent as the plain text it is; the message itself is never refused for it. */
438
+ private vetted(extras: ChatSendExtras): ChatSendExtras {
439
+ if (extras.quoteSource === undefined || this.thread.get(extras.quoteSource.itemId)?.kind === 'assistant') {
440
+ return extras;
441
+ }
442
+ const plain = { ...extras };
443
+ delete plain.quoteSource;
444
+ return plain;
445
+ }
446
+
427
447
  sendInput(text: string, extras: ChatSendExtras = {}): Promise<{ queued: boolean; turnId: string }> {
428
448
  return this.submitInput(text, extras);
429
449
  }
430
450
 
431
- private async submitInput(text: string, extras: ChatSendExtras): Promise<{ queued: boolean; turnId: string }> {
451
+ private async submitInput(text: string, sent: ChatSendExtras): Promise<{ queued: boolean; turnId: string }> {
452
+ const extras = this.vetted(sent);
432
453
  const backend = this.backend;
433
454
  const turnId = this.info.activeTurnId;
434
455
  if (
@@ -472,7 +493,8 @@ export class ChatSession {
472
493
  mentions: extras.mentions,
473
494
  skills: extras.skills,
474
495
  chats: references.ids,
475
- attachments: extras.attachments
496
+ attachments: extras.attachments,
497
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {})
476
498
  }),
477
499
  this.thread.patchInfo({ queue: this.queue.filter((entry) => entry.id !== message.id) })
478
500
  ]);
@@ -562,7 +584,14 @@ export class ChatSession {
562
584
  this.setQueue(rest);
563
585
  this.dispatch(
564
586
  next.text,
565
- { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice },
587
+ {
588
+ mentions: next.mentions,
589
+ skills: next.skills,
590
+ chats: next.chats,
591
+ attachments: next.attachments,
592
+ uiChoice: next.uiChoice,
593
+ quoteSource: next.quoteSource
594
+ },
566
595
  next.turnId
567
596
  );
568
597
  }
@@ -1212,6 +1241,98 @@ export class ChatSession {
1212
1241
  this.deliverNote({ noteId: `lost-background-${item.id}`, note: text, preamble: text });
1213
1242
  }
1214
1243
 
1244
+ /*
1245
+ * Stops one subagent of the CLI's own. The process that announced it ends it with `stop_task` and the
1246
+ * parent goes on; a process that did not start it has nothing to address, so while a turn runs that
1247
+ * is refused, and otherwise the row is only marked.
1248
+ */
1249
+ stopSubagent(toolUseId: string): 'stopping' | 'marked' {
1250
+ const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1251
+ const agentId = row?.native?.agentId;
1252
+ const owned = this.subagentOwners.get(toolUseId) === this.generation;
1253
+ if (agentId !== undefined && owned && this.backend?.stopTask) {
1254
+ this.stopping.set(agentId, toolUseId);
1255
+ this.backend.stopTask(agentId);
1256
+ return 'stopping';
1257
+ }
1258
+ if (agentId !== undefined && !owned && this.thread.info.activeTurnId !== null) {
1259
+ throw new ChatError('subagent-stale', 'This sub-agent belongs to a process that has ended');
1260
+ }
1261
+ this.markSubagentStopped(toolUseId);
1262
+ return 'marked';
1263
+ }
1264
+
1265
+ private trackSubagentOwner(generation: number, event: BackendEvent): void {
1266
+ if (event.type === 'task.started' && event.taskId) {
1267
+ this.subagentOwners.set(event.ref, generation);
1268
+ } else if (event.type === 'task.done') {
1269
+ this.noteStopped(event);
1270
+ if (event.ref !== null) {
1271
+ this.subagentOwners.delete(event.ref);
1272
+ }
1273
+ } else if (event.type === 'exit') {
1274
+ this.stopping.clear();
1275
+ }
1276
+ }
1277
+
1278
+ /* The CLI would not stop it: with no turn running the row is only marked, and while one runs a note says it still goes on. */
1279
+ private refuseStop(agentId: string, message: string): void {
1280
+ const toolUseId = this.stopping.get(agentId);
1281
+ if (toolUseId === undefined) {
1282
+ return;
1283
+ }
1284
+ this.stopping.delete(agentId);
1285
+ const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1286
+ if (row?.status !== 'running') {
1287
+ return;
1288
+ }
1289
+ if (this.thread.info.activeTurnId === null) {
1290
+ this.markSubagentStopped(toolUseId);
1291
+ return;
1292
+ }
1293
+ this.emit([
1294
+ this.thread.upsert({
1295
+ id: `stop-refused-${toolUseId}-${Date.now()}`,
1296
+ kind: 'note',
1297
+ createdAt: Date.now(),
1298
+ turnId: null,
1299
+ level: 'warning',
1300
+ text: `"${row.description || row.subagentType || 'Sub-agent'}" could not be stopped: ${message}`
1301
+ })
1302
+ ]);
1303
+ this.options.persist();
1304
+ }
1305
+
1306
+ /* The line under a subagent that `stop_task` ended, once the CLI reported it settled. */
1307
+ private noteStopped(event: Extract<BackendEvent, { type: 'task.done' }>): void {
1308
+ const stopped = [...this.stopping].find(([agentId, toolUseId]) => agentId === event.taskId || toolUseId === event.ref);
1309
+ if (stopped === undefined) {
1310
+ return;
1311
+ }
1312
+ this.stopping.delete(stopped[0]);
1313
+ // It finished on its own just before the stop arrived; the row already says so.
1314
+ if (event.ok) {
1315
+ return;
1316
+ }
1317
+ const row = this.thread.find('subagent', (item) => item.toolUseId === stopped[1]);
1318
+ if (!row) {
1319
+ return;
1320
+ }
1321
+ const now = Date.now();
1322
+ this.emit([
1323
+ ...(row.status === 'running' ? [this.thread.upsert({ ...row, status: 'failed' as const, finishedAt: now })] : []),
1324
+ this.thread.upsert({
1325
+ id: `stopped-${row.toolUseId}`,
1326
+ kind: 'note',
1327
+ createdAt: now,
1328
+ turnId: null,
1329
+ level: 'info',
1330
+ text: `"${row.description || row.subagentType || 'Sub-agent'}" was stopped.`
1331
+ })
1332
+ ]);
1333
+ this.options.persist();
1334
+ }
1335
+
1215
1336
  /*
1216
1337
  * Marks a subagent of the CLI's own as stopped: `failed` with a note that says what it means, since
1217
1338
  * no CLI stops one subagent on its own. Refused while a turn runs, which may still be waiting on it.
@@ -1325,7 +1446,8 @@ export class ChatSession {
1325
1446
  skills,
1326
1447
  chats,
1327
1448
  attachments,
1328
- ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
1449
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
1450
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {})
1329
1451
  })
1330
1452
  );
1331
1453
  }
@@ -1362,14 +1484,14 @@ export class ChatSession {
1362
1484
  if (!checkpoints) {
1363
1485
  return Promise.resolve();
1364
1486
  }
1365
- return checkpoints
1366
- .take(this.thread.info.cwd)
1367
- .then((tree) => {
1487
+ const cwd = this.thread.info.cwd;
1488
+ return Promise.all([checkpoints.take(cwd), checkpoints.head?.(cwd) ?? null])
1489
+ .then(([tree, head]) => {
1368
1490
  const turn = this.thread.get(turnId);
1369
1491
  if (tree === null || turn?.kind !== 'turn' || turn.state !== 'running' || this.info.activeTurnId !== turnId) {
1370
1492
  return;
1371
1493
  }
1372
- this.emit([this.thread.upsert({ ...turn, checkpoint: tree })]);
1494
+ this.emit([this.thread.upsert({ ...turn, checkpoint: tree, ...(head === null ? {} : { checkpointHead: head }) })]);
1373
1495
  })
1374
1496
  .catch(() => undefined);
1375
1497
  }
@@ -1380,14 +1502,22 @@ export class ChatSession {
1380
1502
  if (turn?.kind !== 'turn' || turn.checkpoint === undefined || turn.checkpointDiff || !this.options.checkpoints) {
1381
1503
  return;
1382
1504
  }
1383
- void this.options.checkpoints
1384
- .settle(this.thread.info.cwd, turn.checkpoint)
1385
- .then((answer) => {
1505
+ const { checkpoints } = this.options;
1506
+ const cwd = this.thread.info.cwd;
1507
+ void Promise.all([checkpoints.settle(cwd, turn.checkpoint), checkpoints.head?.(cwd) ?? null])
1508
+ .then(([answer, head]) => {
1386
1509
  const settled = this.thread.get(turnId);
1387
1510
  if (answer === null || settled?.kind !== 'turn') {
1388
1511
  return;
1389
1512
  }
1390
- this.emit([this.thread.upsert({ ...settled, checkpointDiff: answer.diff, checkpointAfter: answer.after })]);
1513
+ this.emit([
1514
+ this.thread.upsert({
1515
+ ...settled,
1516
+ checkpointDiff: answer.diff,
1517
+ checkpointAfter: answer.after,
1518
+ ...(head === null ? {} : { checkpointHeadAfter: head })
1519
+ })
1520
+ ]);
1391
1521
  this.options.persist();
1392
1522
  })
1393
1523
  .catch(() => undefined);
@@ -1631,6 +1761,10 @@ export class ChatSession {
1631
1761
  this.applyTitle(event.title);
1632
1762
  return;
1633
1763
  }
1764
+ if (event.type === 'stop.refused') {
1765
+ this.refuseStop(event.taskId, event.message);
1766
+ return;
1767
+ }
1634
1768
  /*
1635
1769
  * A CLI reports the window it was started on. A pick made since waits for the restart the next
1636
1770
  * send does, so its report may not put the old size back under a meter that already reads the new one.
@@ -1687,6 +1821,7 @@ export class ChatSession {
1687
1821
  this.awaitingBackgroundReport = true;
1688
1822
  }
1689
1823
  this.emit(projected);
1824
+ this.trackSubagentOwner(generation, event);
1690
1825
  this.watchWorkflows(event);
1691
1826
  if (isMainAgentOutput(event)) {
1692
1827
  this.spokenTurnId = this.thread.info.activeTurnId;
@@ -147,6 +147,9 @@ export class ClaudeProtocol {
147
147
  private apiError: string | null = null;
148
148
  private readonly refusedWindows = new Map<string, number | null>();
149
149
 
150
+ // The task each `stop_task` request named, so an error answer finds it again.
151
+ private readonly stopRequests = new Map<string, string>();
152
+
150
153
  private readonly steerIds = new Set<string>();
151
154
 
152
155
  beginSteer(promptId: string): void {
@@ -185,6 +188,9 @@ export class ClaudeProtocol {
185
188
  case 'control_request':
186
189
  this.handleControlRequest(frame, events);
187
190
  break;
191
+ case 'control_response':
192
+ this.handleControlResponse(frame, events);
193
+ break;
188
194
  case 'control_cancel_request': {
189
195
  const requestId = str(frame.request_id);
190
196
  if (requestId) {
@@ -445,7 +451,23 @@ export class ClaudeProtocol {
445
451
 
446
452
  /* The control request that ends one task the CLI runs beside its turns. */
447
453
  stopTaskRequest(taskId: string): unknown {
448
- return { type: 'control_request', request_id: `stop-task-${taskId}-${Date.now()}`, request: { subtype: 'stop_task', task_id: taskId } };
454
+ const requestId = `stop-task-${taskId}-${Date.now()}`;
455
+ this.stopRequests.set(requestId, taskId);
456
+ return { type: 'control_request', request_id: requestId, request: { subtype: 'stop_task', task_id: taskId } };
457
+ }
458
+
459
+ /* Only a refused `stop_task` matters; every other answer is the CLI saying it took the request. */
460
+ private handleControlResponse(frame: Frame, events: BackendEvent[]): void {
461
+ const response = isRecord(frame.response) ? frame.response : null;
462
+ const requestId = str(response?.request_id);
463
+ const taskId = requestId === null ? undefined : this.stopRequests.get(requestId);
464
+ if (response === null || requestId === null || taskId === undefined) {
465
+ return;
466
+ }
467
+ this.stopRequests.delete(requestId);
468
+ if (response.subtype === 'error') {
469
+ events.push({ type: 'stop.refused', taskId, message: str(response.error) ?? 'The CLI refused to stop it' });
470
+ }
449
471
  }
450
472
 
451
473
  private handleStreamEvent(frame: Frame, events: BackendEvent[]): void {
@@ -19,7 +19,7 @@ function str(value: unknown): string | null {
19
19
  }
20
20
 
21
21
  /* The words of a user line that somebody said, or null for a line that carries tool results. */
22
- function spokenText(content: unknown): string | null {
22
+ export function spokenText(content: unknown): string | null {
23
23
  if (typeof content === 'string') {
24
24
  return content;
25
25
  }
@@ -12,6 +12,8 @@ export type ChatErrorCode =
12
12
  | 'chat-unsupported'
13
13
  | 'invalid-attachments'
14
14
  | 'subagent-not-found'
15
+ // The sub-agent was started by a CLI process that has ended, so no process here can stop it.
16
+ | 'subagent-stale'
15
17
  | 'task-not-found'
16
18
  // What reading or forking a CLI's own conversation is refused with.
17
19
  | 'transcript-missing'