@adecore/agents 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/async.js +3 -8
  2. package/dist/chat/backend.d.ts +4 -0
  3. package/dist/chat/chat-core.d.ts +5 -2
  4. package/dist/chat/chat-core.js +8 -7
  5. package/dist/chat/chat-log.js +2 -2
  6. package/dist/chat/chat-session.d.ts +11 -2
  7. package/dist/chat/chat-session.js +141 -13
  8. package/dist/chat/chat-store.js +4 -7
  9. package/dist/chat/chat-title.js +4 -8
  10. package/dist/chat/claude-backend.d.ts +1 -0
  11. package/dist/chat/claude-backend.js +8 -6
  12. package/dist/chat/claude-protocol.d.ts +2 -0
  13. package/dist/chat/claude-protocol.js +21 -1
  14. package/dist/chat/claude-title.js +1 -1
  15. package/dist/chat/claude-transcript.d.ts +1 -0
  16. package/dist/chat/claude-transcript.js +1 -1
  17. package/dist/chat/codex-backend.d.ts +2 -2
  18. package/dist/chat/codex-backend.js +5 -8
  19. package/dist/chat/codex-protocol.js +1 -1
  20. package/dist/chat/codex-thread.js +4 -5
  21. package/dist/chat/composer-preferences.js +2 -3
  22. package/dist/chat/errors.d.ts +1 -1
  23. package/dist/chat/external-sessions.d.ts +26 -0
  24. package/dist/chat/external-sessions.js +136 -0
  25. package/dist/chat/fake-claude.js +73 -0
  26. package/dist/chat/limit-resume.js +2 -7
  27. package/dist/chat/thread-search.d.ts +14 -0
  28. package/dist/chat/thread-search.js +64 -0
  29. package/dist/chat/visual-store.js +8 -6
  30. package/dist/context/refusal.js +3 -7
  31. package/dist/context/verb.js +1 -5
  32. package/dist/host/agent-host.js +2 -2
  33. package/dist/host/handlers.js +1 -2
  34. package/dist/host/wiring.js +1 -1
  35. package/dist/lineage.js +2 -3
  36. package/dist/messages/deliver-message.js +6 -11
  37. package/dist/messages/deliver-notice.js +8 -12
  38. package/dist/messages/notice-store.js +3 -5
  39. package/dist/outbox/outbox-worker.js +5 -2
  40. package/dist/providers/accounts/service.d.ts +2 -1
  41. package/dist/providers/accounts/service.js +11 -13
  42. package/dist/providers/catalog.js +2 -1
  43. package/dist/providers/claude.js +0 -1
  44. package/dist/providers/detect.js +2 -10
  45. package/dist/record-directory.js +0 -1
  46. package/dist/tasks/task-coordinator.d.ts +1 -0
  47. package/dist/tasks/task-coordinator.js +34 -34
  48. package/dist/tasks/task-store.js +1 -1
  49. package/dist/tasks/task-work.js +2 -2
  50. package/dist/tasks/waiting-child.d.ts +1 -0
  51. package/dist/tasks/waiting-child.js +8 -6
  52. package/dist/tasks/wake-parent.js +15 -10
  53. package/dist/tasks/wiring.js +32 -37
  54. package/dist/usage/aggregate.js +62 -46
  55. package/dist/usage/exchange.js +1 -1
  56. package/dist/usage/limits/monitor.d.ts +2 -2
  57. package/dist/usage/limits/monitor.js +2 -2
  58. package/dist/usage/limits/probe.js +1 -1
  59. package/dist/usage/pricing.js +1 -1
  60. package/dist/usage/readers/codex.d.ts +1 -0
  61. package/dist/usage/readers/codex.js +1 -1
  62. package/dist/usage/roots.js +1 -2
  63. package/dist/usage/scanner.js +7 -9
  64. package/dist/usage/usage-service.d.ts +1 -2
  65. package/dist/usage/usage-service.js +3 -6
  66. package/package.json +3 -3
  67. package/src/async.ts +3 -9
  68. package/src/chat/backend.ts +2 -0
  69. package/src/chat/chat-core.ts +8 -7
  70. package/src/chat/chat-log.ts +2 -2
  71. package/src/chat/chat-session.ts +148 -13
  72. package/src/chat/chat-store.ts +4 -8
  73. package/src/chat/chat-title.ts +4 -8
  74. package/src/chat/claude-backend.ts +9 -6
  75. package/src/chat/claude-protocol.ts +23 -1
  76. package/src/chat/claude-title.ts +1 -1
  77. package/src/chat/claude-transcript.ts +2 -2
  78. package/src/chat/codex-backend.ts +5 -8
  79. package/src/chat/codex-protocol.ts +1 -1
  80. package/src/chat/codex-thread.ts +6 -5
  81. package/src/chat/composer-preferences.ts +2 -3
  82. package/src/chat/errors.ts +2 -0
  83. package/src/chat/external-sessions.ts +177 -0
  84. package/src/chat/fake-claude.ts +75 -1
  85. package/src/chat/limit-resume.ts +2 -7
  86. package/src/chat/thread-search.ts +87 -0
  87. package/src/chat/thread.ts +1 -1
  88. package/src/chat/visual-store.ts +9 -6
  89. package/src/chat/wake-chat.ts +4 -1
  90. package/src/context/refusal.ts +3 -7
  91. package/src/context/verb.ts +1 -5
  92. package/src/host/agent-host.ts +2 -2
  93. package/src/host/handlers.ts +1 -2
  94. package/src/host/wiring.ts +1 -1
  95. package/src/lineage.ts +2 -3
  96. package/src/messages/deliver-message.ts +6 -11
  97. package/src/messages/deliver-notice.ts +8 -12
  98. package/src/messages/notice-store.ts +3 -5
  99. package/src/outbox/outbox-worker.ts +5 -2
  100. package/src/providers/accounts/service.ts +12 -13
  101. package/src/providers/accounts/variables.ts +1 -0
  102. package/src/providers/catalog.ts +2 -1
  103. package/src/providers/claude.ts +0 -1
  104. package/src/providers/detect.ts +2 -11
  105. package/src/record-directory.ts +0 -1
  106. package/src/skills.ts +0 -1
  107. package/src/tasks/task-coordinator.ts +35 -33
  108. package/src/tasks/task-store.ts +1 -1
  109. package/src/tasks/task-work.ts +2 -2
  110. package/src/tasks/waiting-child.ts +10 -7
  111. package/src/tasks/wake-parent.ts +21 -16
  112. package/src/tasks/wiring.ts +33 -41
  113. package/src/usage/aggregate.ts +80 -50
  114. package/src/usage/exchange.ts +1 -1
  115. package/src/usage/limits/monitor.ts +4 -4
  116. package/src/usage/limits/probe.ts +2 -2
  117. package/src/usage/pricing.ts +1 -1
  118. package/src/usage/readers/codex.ts +1 -1
  119. package/src/usage/roots.ts +1 -2
  120. package/src/usage/scanner.ts +8 -10
  121. package/src/usage/usage-service.ts +5 -9
package/dist/async.js CHANGED
@@ -1,8 +1,6 @@
1
- /* The two waits the host does: a pause it can be woken from, and a deadline on somebody else's promise. */
2
1
  /*
3
- * Resolves after `ms`, or at once when the signal is raised. An abort ends the wait rather than
4
- * failing it: every caller here is a loop that checks the signal itself on the next turn, and a
5
- * rejection would only be caught and thrown away.
2
+ * Resolves after `ms`, or at once when the signal is raised. An abort resolves instead of rejecting:
3
+ * every caller is a loop that checks the signal itself on its next turn.
6
4
  */
7
5
  export function wait(ms, signal) {
8
6
  return new Promise((resolve) => {
@@ -13,10 +11,7 @@ export function wait(ms, signal) {
13
11
  }, { once: true });
14
12
  });
15
13
  }
16
- /*
17
- * The promise, or `message` when it takes longer than `ms`. The timer is cleared either way, so a
18
- * call that answers at once does not hold the process for the rest of its deadline.
19
- */
14
+ /* The timer is cleared either way, so a call that answers at once does not hold the process until its deadline. */
20
15
  export function withTimeout(promise, ms, message) {
21
16
  return new Promise((resolve, reject) => {
22
17
  const timer = setTimeout(() => reject(new Error(message)), ms);
@@ -176,6 +176,10 @@ export type BackendEvent = {
176
176
  type: 'note';
177
177
  level: 'info' | 'warning' | 'error';
178
178
  text: string;
179
+ } | {
180
+ type: 'stop.refused';
181
+ taskId: string;
182
+ message: string;
179
183
  } | {
180
184
  type: 'turn.done';
181
185
  promptId?: string;
@@ -1,5 +1,4 @@
1
1
  import { type AgentKind, type ChatAttachment, type ChatAttachResult, type ChatAttachmentUpload, type ChatBookmark, type ChatCheckpointDiff, type ChatConfigurePayload, type ChatCreatePayload, type ChatEvent, type ChatHistoryResult, type ChatInfo, type ChatItem, type ChatQueuedMessage, type ChatUiChoicePayload, type ChatUiQueryPayload, type ChatUiLinkPayload, type ChatSkill, type ChatSubagentPayload, type ChatSubagentResult, type ChatTurnItem, type ChatVisual, type ModelSelection, type RuntimeMode } from '@adecore/agent-contracts';
2
- import { type ChatUiHost } from './ui-queries.ts';
3
2
  import { ClientSinks } from '../client-sinks.ts';
4
3
  import type { AgentEvent, AgentSink } from '../events.ts';
5
4
  import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
@@ -8,6 +7,7 @@ import type { ProviderRegistry } from '../providers/registry.ts';
8
7
  import { SkillIndex } from '../skills.ts';
9
8
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
10
9
  import type { AttachmentStore } from './attachment-store.ts';
10
+ import { type BackgroundWork } from './background-work.ts';
11
11
  import type { BookmarkStore } from './bookmark-store.ts';
12
12
  import type { SpawnChatProcess } from './chat-process.ts';
13
13
  import { ChatSession, type ChatReferences, type ChatSendExtras, type PromptNotes, type ResumeWords, type TurnCheckpoints } from './chat-session.ts';
@@ -17,8 +17,8 @@ import type { CodexProcessSpec } from './codex-thread.ts';
17
17
  import type { CodexClientInfo } from './codex-transport.ts';
18
18
  import { ComposerPreferences } from './composer-preferences.ts';
19
19
  import { DeltaCoalescer } from './delta-coalescer.ts';
20
- import { type BackgroundWork } from './background-work.ts';
21
20
  import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
21
+ import { type ChatUiHost } from './ui-queries.ts';
22
22
  import type { VisualInput, VisualStore } from './visual-store.ts';
23
23
  export interface InterruptedRun {
24
24
  chatId: string;
@@ -113,6 +113,9 @@ export declare class ChatCore {
113
113
  values?: Record<string, unknown> | undefined;
114
114
  at: number;
115
115
  } | undefined;
116
+ quoteSource?: {
117
+ itemId: string;
118
+ } | undefined;
116
119
  }[] | undefined;
117
120
  queuePaused?: boolean | undefined;
118
121
  background?: {
@@ -1,25 +1,25 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
3
  import { ChatAttachmentUploadsSchema, generatedImageAttachment } from '@adecore/agent-contracts';
4
- import { ChatUiQueries } from './ui-queries.js';
5
4
  import { ClientSinks } from '../client-sinks.js';
6
5
  import { errorText } from '../error-text.js';
7
6
  import { AccountError, definedEnv, isDefaultAccountOf, launchEnv, storedAccount } from '../providers/accounts/launch.js';
8
7
  import { SkillIndex } from '../skills.js';
9
8
  import { usageRoots } from '../usage/roots.js';
9
+ import { commandLabel, isBackgroundWork, runsInBackground } from './background-work.js';
10
10
  import { ChatLog, COMPACT_ABOVE_BYTES } from './chat-log.js';
11
11
  import { ChatSession } from './chat-session.js';
12
12
  import { ComposerPreferences } from './composer-preferences.js';
13
13
  import { DeltaCoalescer } from './delta-coalescer.js';
14
- import { readableAssistantText } from './readable-text.js';
15
14
  import { ChatError } from './errors.js';
16
- import { commandLabel, isBackgroundWork, runsInBackground } from './background-work.js';
15
+ import { readableAssistantText } from './readable-text.js';
17
16
  import { SubagentReader } from './subagent-reader.js';
18
- /* A message a person or the agent wrote in the chat's own thread; a subagent's words belong to its row. */
17
+ import { ChatUiQueries } from './ui-queries.js';
19
18
  function withoutHidden(info) {
20
19
  const { hidden: _hidden, ...rest } = info;
21
20
  return rest;
22
21
  }
22
+ /* A message a person or the agent wrote in the chat's own thread; a subagent's words belong to its row. */
23
23
  function isBookmarkable(item) {
24
24
  return item.kind === 'user' || (item.kind === 'assistant' && (item.parentToolUseId ?? null) === null);
25
25
  }
@@ -486,8 +486,9 @@ export class ChatCore {
486
486
  return result;
487
487
  }
488
488
  /*
489
- * Stops one running sub-agent of the CLI's own. It cannot be stopped apart from its CLI, so its row
490
- * is only marked, and only while no turn runs: stopping that turn is the composer's Stop.
489
+ * Stops one running sub-agent of the CLI's own. The process that started it ends it with `stop_task`
490
+ * and the parent keeps working. Without such a process the row is only marked, and only while no
491
+ * turn runs: stopping that turn is the composer's Stop.
491
492
  */
492
493
  async stopSubagent(chatId, toolUseId) {
493
494
  await this.loaded(chatId);
@@ -502,7 +503,7 @@ export class ChatCore {
502
503
  if (row.origin === 'ruimte') {
503
504
  throw new ChatError('chat-unsupported', 'This row stands for work the host runs; stop it there');
504
505
  }
505
- session.markSubagentStopped(toolUseId);
506
+ session.stopSubagent(toolUseId);
506
507
  }
507
508
  /* Ends a command or a monitor a chat's CLI runs in the background, for a person who pressed Stop on it. */
508
509
  async stopTask(chatId, taskId) {
@@ -31,8 +31,8 @@ export function parseLog(raw) {
31
31
  return lines;
32
32
  }
33
33
  /*
34
- * Number and synchronously append each broadcast event so disk, memory and snapshot boundaries keep
35
- * one order. Recent lines stay in memory for immediate `since` attachment.
34
+ * Numbers every broadcast event and appends it synchronously, so disk, memory and snapshot boundaries
35
+ * keep one order. Recent lines stay in memory to answer an attach with `since` at once.
36
36
  */
37
37
  export class ChatLog {
38
38
  path;
@@ -1,4 +1,4 @@
1
- import type { ChatAttachment, ChatAssistantItem, ChatCheckpointDiff, ChatEvent, ChatInfo, ChatItem, ChatQueuedMessage, ChatSkill, ChatUiChoiceOrigin, ChatUiChoicePayload, ChatUiQueryState, ModelSelection, RuntimeMode } from '@adecore/agent-contracts';
1
+ import type { ChatAttachment, ChatAssistantItem, ChatCheckpointDiff, ChatEvent, ChatInfo, ChatItem, ChatQueuedMessage, ChatQuoteSource, ChatSkill, ChatUiChoiceOrigin, ChatUiChoicePayload, ChatUiQueryState, ModelSelection, RuntimeMode } from '@adecore/agent-contracts';
2
2
  import type { UiCompileOptions } from '@adecore/intelligent-ui/compiler';
3
3
  import type { ChatProvider } from '../providers/provider.ts';
4
4
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
@@ -15,6 +15,7 @@ export interface TurnCheckpoints {
15
15
  diff: ChatCheckpointDiff;
16
16
  after: string;
17
17
  } | null>;
18
+ head?(cwd: string): Promise<string | null>;
18
19
  }
19
20
  export interface PromptNotes {
20
21
  next(): {
@@ -72,6 +73,7 @@ export interface ChatSendExtras {
72
73
  mentions?: string[];
73
74
  skills?: string[];
74
75
  chats?: string[];
76
+ quoteSource?: ChatQuoteSource;
75
77
  attachments?: ChatAttachment[];
76
78
  }
77
79
  export interface ResumeWords {
@@ -101,6 +103,8 @@ export declare class ChatSession {
101
103
  private titleTimer;
102
104
  private naming;
103
105
  private frozen;
106
+ private readonly subagentOwners;
107
+ private readonly stopping;
104
108
  private readonly orphans;
105
109
  private pendingPreambles;
106
110
  private submission;
@@ -127,7 +131,7 @@ export declare class ChatSession {
127
131
  resumeAtReset?: boolean;
128
132
  }): ChatInfo;
129
133
  setAccount(account: string | undefined): ChatInfo;
130
- send(text: string, extras?: ChatSendExtras): {
134
+ send(text: string, sent?: ChatSendExtras): {
131
135
  queued: boolean;
132
136
  turnId: string;
133
137
  };
@@ -140,6 +144,7 @@ export declare class ChatSession {
140
144
  turnId: string;
141
145
  };
142
146
  private recordUiAnswer;
147
+ private vetted;
143
148
  sendInput(text: string, extras?: ChatSendExtras): Promise<{
144
149
  queued: boolean;
145
150
  turnId: string;
@@ -199,6 +204,10 @@ export declare class ChatSession {
199
204
  }): Promise<unknown> | null;
200
205
  settleOrphanedSubagents(): Promise<void>;
201
206
  private recordLostBackground;
207
+ stopSubagent(toolUseId: string): 'stopping' | 'marked';
208
+ private trackSubagentOwner;
209
+ private refuseStop;
210
+ private noteStopped;
202
211
  markSubagentStopped(toolUseId: string): void;
203
212
  markSubagentsStopped(): void;
204
213
  private markStopped;
@@ -70,6 +70,10 @@ export class ChatSession {
70
70
  naming = false;
71
71
  // Set while the host goes down: a CLI dying with it must not end the turn a restart takes up again.
72
72
  frozen = false;
73
+ // Which CLI process (generation) announced each subagent of its own, by call id; only that one can stop it.
74
+ subagentOwners = new Map();
75
+ // Subagents a stop_task went out for, as task id to call id, until their notification settles them.
76
+ stopping = new Map();
73
77
  // Background subagents a load found running whose transcript did not show an end yet; no CLI here will report them.
74
78
  orphans = new Set();
75
79
  pendingPreambles;
@@ -167,7 +171,8 @@ export class ChatSession {
167
171
  /*
168
172
  * Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
169
173
  */
170
- send(text, extras = {}) {
174
+ send(text, sent = {}) {
175
+ const extras = this.vetted(sent);
171
176
  const turnId = newId('turn');
172
177
  // Behind whatever still waits (a CLI that crashed leaves its queue), so everything goes out in the order it was sent.
173
178
  if (this.busy || this.queue.length > 0 || this.replacementWaits()) {
@@ -183,6 +188,7 @@ export class ChatSession {
183
188
  ...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
184
189
  ...(extras.skills?.length ? { skills: extras.skills } : {}),
185
190
  ...(extras.chats?.length ? { chats: extras.chats } : {}),
191
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {}),
186
192
  ...(extras.attachments?.length ? { attachments: extras.attachments } : {})
187
193
  };
188
194
  this.setQueue([...this.queue, message]);
@@ -278,10 +284,20 @@ export class ChatSession {
278
284
  this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
279
285
  this.options.persist();
280
286
  }
287
+ /* 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. */
288
+ vetted(extras) {
289
+ if (extras.quoteSource === undefined || this.thread.get(extras.quoteSource.itemId)?.kind === 'assistant') {
290
+ return extras;
291
+ }
292
+ const plain = { ...extras };
293
+ delete plain.quoteSource;
294
+ return plain;
295
+ }
281
296
  sendInput(text, extras = {}) {
282
297
  return this.submitInput(text, extras);
283
298
  }
284
- async submitInput(text, extras) {
299
+ async submitInput(text, sent) {
300
+ const extras = this.vetted(sent);
285
301
  const backend = this.backend;
286
302
  const turnId = this.info.activeTurnId;
287
303
  if (extras.delivery !== 'steer' ||
@@ -323,7 +339,8 @@ export class ChatSession {
323
339
  mentions: extras.mentions,
324
340
  skills: extras.skills,
325
341
  chats: references.ids,
326
- attachments: extras.attachments
342
+ attachments: extras.attachments,
343
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {})
327
344
  }),
328
345
  this.thread.patchInfo({ queue: this.queue.filter((entry) => entry.id !== message.id) })
329
346
  ]);
@@ -406,7 +423,14 @@ export class ChatSession {
406
423
  this.sendAfterReplacement = false;
407
424
  this.pauseQueue(false);
408
425
  this.setQueue(rest);
409
- this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice }, next.turnId);
426
+ this.dispatch(next.text, {
427
+ mentions: next.mentions,
428
+ skills: next.skills,
429
+ chats: next.chats,
430
+ attachments: next.attachments,
431
+ uiChoice: next.uiChoice,
432
+ quoteSource: next.quoteSource
433
+ }, next.turnId);
410
434
  }
411
435
  dispatch(text, extras, requestedTurnId) {
412
436
  this.pauseQueue(false);
@@ -1016,6 +1040,96 @@ export class ChatSession {
1016
1040
  const text = `The machine restarted before background work "${label}" completed. It will not send a result. Do not wait for it.`;
1017
1041
  this.deliverNote({ noteId: `lost-background-${item.id}`, note: text, preamble: text });
1018
1042
  }
1043
+ /*
1044
+ * Stops one subagent of the CLI's own. The process that announced it ends it with `stop_task` and the
1045
+ * parent goes on; a process that did not start it has nothing to address, so while a turn runs that
1046
+ * is refused, and otherwise the row is only marked.
1047
+ */
1048
+ stopSubagent(toolUseId) {
1049
+ const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1050
+ const agentId = row?.native?.agentId;
1051
+ const owned = this.subagentOwners.get(toolUseId) === this.generation;
1052
+ if (agentId !== undefined && owned && this.backend?.stopTask) {
1053
+ this.stopping.set(agentId, toolUseId);
1054
+ this.backend.stopTask(agentId);
1055
+ return 'stopping';
1056
+ }
1057
+ if (agentId !== undefined && !owned && this.thread.info.activeTurnId !== null) {
1058
+ throw new ChatError('subagent-stale', 'This sub-agent belongs to a process that has ended');
1059
+ }
1060
+ this.markSubagentStopped(toolUseId);
1061
+ return 'marked';
1062
+ }
1063
+ trackSubagentOwner(generation, event) {
1064
+ if (event.type === 'task.started' && event.taskId) {
1065
+ this.subagentOwners.set(event.ref, generation);
1066
+ }
1067
+ else if (event.type === 'task.done') {
1068
+ this.noteStopped(event);
1069
+ if (event.ref !== null) {
1070
+ this.subagentOwners.delete(event.ref);
1071
+ }
1072
+ }
1073
+ else if (event.type === 'exit') {
1074
+ this.stopping.clear();
1075
+ }
1076
+ }
1077
+ /* 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. */
1078
+ refuseStop(agentId, message) {
1079
+ const toolUseId = this.stopping.get(agentId);
1080
+ if (toolUseId === undefined) {
1081
+ return;
1082
+ }
1083
+ this.stopping.delete(agentId);
1084
+ const row = this.thread.find('subagent', (item) => item.toolUseId === toolUseId);
1085
+ if (row?.status !== 'running') {
1086
+ return;
1087
+ }
1088
+ if (this.thread.info.activeTurnId === null) {
1089
+ this.markSubagentStopped(toolUseId);
1090
+ return;
1091
+ }
1092
+ this.emit([
1093
+ this.thread.upsert({
1094
+ id: `stop-refused-${toolUseId}-${Date.now()}`,
1095
+ kind: 'note',
1096
+ createdAt: Date.now(),
1097
+ turnId: null,
1098
+ level: 'warning',
1099
+ text: `"${row.description || row.subagentType || 'Sub-agent'}" could not be stopped: ${message}`
1100
+ })
1101
+ ]);
1102
+ this.options.persist();
1103
+ }
1104
+ /* The line under a subagent that `stop_task` ended, once the CLI reported it settled. */
1105
+ noteStopped(event) {
1106
+ const stopped = [...this.stopping].find(([agentId, toolUseId]) => agentId === event.taskId || toolUseId === event.ref);
1107
+ if (stopped === undefined) {
1108
+ return;
1109
+ }
1110
+ this.stopping.delete(stopped[0]);
1111
+ // It finished on its own just before the stop arrived; the row already says so.
1112
+ if (event.ok) {
1113
+ return;
1114
+ }
1115
+ const row = this.thread.find('subagent', (item) => item.toolUseId === stopped[1]);
1116
+ if (!row) {
1117
+ return;
1118
+ }
1119
+ const now = Date.now();
1120
+ this.emit([
1121
+ ...(row.status === 'running' ? [this.thread.upsert({ ...row, status: 'failed', finishedAt: now })] : []),
1122
+ this.thread.upsert({
1123
+ id: `stopped-${row.toolUseId}`,
1124
+ kind: 'note',
1125
+ createdAt: now,
1126
+ turnId: null,
1127
+ level: 'info',
1128
+ text: `"${row.description || row.subagentType || 'Sub-agent'}" was stopped.`
1129
+ })
1130
+ ]);
1131
+ this.options.persist();
1132
+ }
1019
1133
  /*
1020
1134
  * Marks a subagent of the CLI's own as stopped: `failed` with a note that says what it means, since
1021
1135
  * no CLI stops one subagent on its own. Refused while a turn runs, which may still be waiting on it.
@@ -1121,7 +1235,8 @@ export class ChatSession {
1121
1235
  skills,
1122
1236
  chats,
1123
1237
  attachments,
1124
- ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
1238
+ ...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
1239
+ ...(extras.quoteSource ? { quoteSource: extras.quoteSource } : {})
1125
1240
  }));
1126
1241
  }
1127
1242
  events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
@@ -1155,14 +1270,14 @@ export class ChatSession {
1155
1270
  if (!checkpoints) {
1156
1271
  return Promise.resolve();
1157
1272
  }
1158
- return checkpoints
1159
- .take(this.thread.info.cwd)
1160
- .then((tree) => {
1273
+ const cwd = this.thread.info.cwd;
1274
+ return Promise.all([checkpoints.take(cwd), checkpoints.head?.(cwd) ?? null])
1275
+ .then(([tree, head]) => {
1161
1276
  const turn = this.thread.get(turnId);
1162
1277
  if (tree === null || turn?.kind !== 'turn' || turn.state !== 'running' || this.info.activeTurnId !== turnId) {
1163
1278
  return;
1164
1279
  }
1165
- this.emit([this.thread.upsert({ ...turn, checkpoint: tree })]);
1280
+ this.emit([this.thread.upsert({ ...turn, checkpoint: tree, ...(head === null ? {} : { checkpointHead: head }) })]);
1166
1281
  })
1167
1282
  .catch(() => undefined);
1168
1283
  }
@@ -1172,14 +1287,22 @@ export class ChatSession {
1172
1287
  if (turn?.kind !== 'turn' || turn.checkpoint === undefined || turn.checkpointDiff || !this.options.checkpoints) {
1173
1288
  return;
1174
1289
  }
1175
- void this.options.checkpoints
1176
- .settle(this.thread.info.cwd, turn.checkpoint)
1177
- .then((answer) => {
1290
+ const { checkpoints } = this.options;
1291
+ const cwd = this.thread.info.cwd;
1292
+ void Promise.all([checkpoints.settle(cwd, turn.checkpoint), checkpoints.head?.(cwd) ?? null])
1293
+ .then(([answer, head]) => {
1178
1294
  const settled = this.thread.get(turnId);
1179
1295
  if (answer === null || settled?.kind !== 'turn') {
1180
1296
  return;
1181
1297
  }
1182
- this.emit([this.thread.upsert({ ...settled, checkpointDiff: answer.diff, checkpointAfter: answer.after })]);
1298
+ this.emit([
1299
+ this.thread.upsert({
1300
+ ...settled,
1301
+ checkpointDiff: answer.diff,
1302
+ checkpointAfter: answer.after,
1303
+ ...(head === null ? {} : { checkpointHeadAfter: head })
1304
+ })
1305
+ ]);
1183
1306
  this.options.persist();
1184
1307
  })
1185
1308
  .catch(() => undefined);
@@ -1409,6 +1532,10 @@ export class ChatSession {
1409
1532
  this.applyTitle(event.title);
1410
1533
  return;
1411
1534
  }
1535
+ if (event.type === 'stop.refused') {
1536
+ this.refuseStop(event.taskId, event.message);
1537
+ return;
1538
+ }
1412
1539
  /*
1413
1540
  * A CLI reports the window it was started on. A pick made since waits for the restart the next
1414
1541
  * send does, so its report may not put the old size back under a meter that already reads the new one.
@@ -1466,6 +1593,7 @@ export class ChatSession {
1466
1593
  this.awaitingBackgroundReport = true;
1467
1594
  }
1468
1595
  this.emit(projected);
1596
+ this.trackSubagentOwner(generation, event);
1469
1597
  this.watchWorkflows(event);
1470
1598
  if (isMainAgentOutput(event)) {
1471
1599
  this.spokenTurnId = this.thread.info.activeTurnId;
@@ -4,12 +4,12 @@ import { join } from 'node:path';
4
4
  import { ChatInfoSchema, ChatItemSchema } from '@adecore/agent-contracts';
5
5
  import { z } from 'zod';
6
6
  import { isNotFound, writeAtomic, writeAtomicSync } from '../fs.js';
7
- import { isBookmarkFileName } from './bookmark-store.js';
7
+ import { recordFileName } from '../record-directory.js';
8
8
  import { migrateInlineAttachments } from './attachment-store.js';
9
+ import { isBookmarkFileName } from './bookmark-store.js';
9
10
  import { parseLog } from './chat-log.js';
10
11
  import { ChatThread } from './thread.js';
11
12
  import { isVisualFileName } from './visual-store.js';
12
- import { recordFileName } from '../record-directory.js';
13
13
  // zod strips what it does not know inside the info and the items, so a file written by an older build
14
14
  // (with `interactionMode`, say) still parses and loses only the dropped field. A record without `seq`
15
15
  // predates the log. Keys beside these are the host's own, and come back to it as they were written.
@@ -24,7 +24,7 @@ const RecordSchema = z.looseObject({
24
24
  uiAccess: z.record(z.string(), z.unknown()).optional()
25
25
  });
26
26
  const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles', 'uiAccess']);
27
- function extrasOf(record) {
27
+ function withoutOwnKeys(record) {
28
28
  return Object.fromEntries(Object.entries(record).filter(([key]) => !OWN_KEYS.has(key)));
29
29
  }
30
30
  function logName(chatId) {
@@ -54,9 +54,6 @@ function liftUiAccess(record) {
54
54
  }
55
55
  return Object.keys(uiAccess).length === 0 ? record : { ...record, uiAccess };
56
56
  }
57
- function withoutOwnKeys(extras) {
58
- return Object.fromEntries(Object.entries(extras).filter(([key]) => !OWN_KEYS.has(key)));
59
- }
60
57
  /*
61
58
  * One JSON file per chat under `<home>/chats`, written while a turn runs and on shutdown, with
62
59
  * the log of what happened since beside it (`ChatLog` appends to it; reading plays it back).
@@ -146,7 +143,7 @@ export class ChatStore {
146
143
  resetSeq,
147
144
  preambles: snapshot.preambles ?? [],
148
145
  uiAccess: snapshot.uiAccess ?? {},
149
- extras: extrasOf(snapshot),
146
+ extras: withoutOwnKeys(snapshot),
150
147
  lines
151
148
  };
152
149
  }
@@ -1,5 +1,5 @@
1
- import { cleanTitle } from '../title-file.js';
2
1
  import { runProcess } from '../run-process.js';
2
+ import { cleanTitle } from '../title-file.js';
3
3
  // Enough of a conversation to name it; the rest of a long prompt or answer says nothing a title needs.
4
4
  const MAX_PROMPT_CHARS = 2000;
5
5
  const MAX_ANSWER_CHARS = 1500;
@@ -8,10 +8,7 @@ const TIMEOUT_MS = 45_000;
8
8
  function cap(text, limit) {
9
9
  return text.length <= limit ? text : `${text.slice(0, limit)}...`;
10
10
  }
11
- /*
12
- * What the CLI is asked. JSON again, as for a commit message: a model left to write freely opens
13
- * with a sentence, and that sentence would become the name of the node.
14
- */
11
+ /* JSON, since a model left to write freely opens with a sentence, and that sentence would become the name. */
15
12
  export function buildTitlePrompt(prompt, answer) {
16
13
  return [
17
14
  'Write a short title for the conversation below, as a person would name it in a list of chats.',
@@ -30,9 +27,8 @@ export function buildTitlePrompt(prompt, answer) {
30
27
  ].join('\n');
31
28
  }
32
29
  /*
33
- * The title out of what the CLI printed, or null. Strict on purpose, unlike a commit message: a run
34
- * that holds no `{"title": "..."}` object printed something other than a title, and a derived name
35
- * is better than a stray line of it.
30
+ * Strict on purpose: a run that holds no `{"title": "..."}` object printed something other than a
31
+ * title, and a derived name is better than a stray line of it.
36
32
  */
37
33
  export function parseTitle(output) {
38
34
  const start = [...output.matchAll(/\{\s*"title"/g)].at(-1)?.index;
@@ -27,6 +27,7 @@ export declare class ClaudeBackend implements ChatBackend {
27
27
  declineRequest(requestId: string, message: string): boolean;
28
28
  stop(): void;
29
29
  dispose(): Promise<void>;
30
+ private userMessage;
30
31
  private readLines;
31
32
  private handleLine;
32
33
  private readLine;
@@ -1,5 +1,5 @@
1
- import { errorText } from '../error-text.js';
2
1
  import { randomUUID } from 'node:crypto';
2
+ import { errorText } from '../error-text.js';
3
3
  import { claudeArgs, claudeEnv, promptPrefix } from '../providers/claude.js';
4
4
  import { ChatChild } from './chat-process.js';
5
5
  import { ClaudeProtocol } from './claude-protocol.js';
@@ -59,12 +59,10 @@ export class ClaudeBackend {
59
59
  return Promise.resolve();
60
60
  }
61
61
  sendTurn(input) {
62
- // The prefix is what the CLI must see first (ultrathink), then the note about the links, then what was typed.
63
- const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
64
62
  const promptId = input.promptId ?? randomUUID();
65
63
  this.active = true;
66
64
  this.protocol.beginPrompt(promptId);
67
- this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
65
+ this.write(this.userMessage(input, promptId));
68
66
  }
69
67
  steerTurn(input) {
70
68
  if (!this.child || !this.active || this.stdinClosed) {
@@ -75,8 +73,7 @@ export class ClaudeBackend {
75
73
  this.steers.set(promptId, { resolve, reject });
76
74
  this.protocol.beginSteer(promptId);
77
75
  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 }));
76
+ this.write(this.userMessage(input, promptId));
80
77
  }
81
78
  catch (error) {
82
79
  this.steers.delete(promptId);
@@ -126,6 +123,11 @@ export class ClaudeBackend {
126
123
  this.child = null;
127
124
  return child?.end() ?? Promise.resolve();
128
125
  }
126
+ userMessage(input, promptId) {
127
+ // What the CLI must see first (ultrathink), then the preamble, then what was typed.
128
+ const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
129
+ return buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId });
130
+ }
129
131
  async readLines(child) {
130
132
  const reader = child.process.stdout.getReader();
131
133
  const decoder = new TextDecoder();
@@ -15,6 +15,7 @@ export declare class ClaudeProtocol {
15
15
  private readonly subagentModels;
16
16
  private apiError;
17
17
  private readonly refusedWindows;
18
+ private readonly stopRequests;
18
19
  private readonly steerIds;
19
20
  beginSteer(promptId: string): void;
20
21
  beginPrompt(promptId: string): void;
@@ -30,6 +31,7 @@ export declare class ClaudeProtocol {
30
31
  private endBackground;
31
32
  private endWorkflow;
32
33
  stopTaskRequest(taskId: string): unknown;
34
+ private handleControlResponse;
33
35
  private handleStreamEvent;
34
36
  private handleAssistant;
35
37
  private handleToolProgress;
@@ -117,6 +117,8 @@ 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
+ // The task each `stop_task` request named, so an error answer finds it again.
121
+ stopRequests = new Map();
120
122
  steerIds = new Set();
121
123
  beginSteer(promptId) {
122
124
  this.steerIds.add(promptId);
@@ -152,6 +154,9 @@ export class ClaudeProtocol {
152
154
  case 'control_request':
153
155
  this.handleControlRequest(frame, events);
154
156
  break;
157
+ case 'control_response':
158
+ this.handleControlResponse(frame, events);
159
+ break;
155
160
  case 'control_cancel_request': {
156
161
  const requestId = str(frame.request_id);
157
162
  if (requestId) {
@@ -408,7 +413,22 @@ export class ClaudeProtocol {
408
413
  }
409
414
  /* The control request that ends one task the CLI runs beside its turns. */
410
415
  stopTaskRequest(taskId) {
411
- return { type: 'control_request', request_id: `stop-task-${taskId}-${Date.now()}`, request: { subtype: 'stop_task', task_id: taskId } };
416
+ const requestId = `stop-task-${taskId}-${Date.now()}`;
417
+ this.stopRequests.set(requestId, taskId);
418
+ return { type: 'control_request', request_id: requestId, request: { subtype: 'stop_task', task_id: taskId } };
419
+ }
420
+ /* Only a refused `stop_task` matters; every other answer is the CLI saying it took the request. */
421
+ handleControlResponse(frame, events) {
422
+ const response = isRecord(frame.response) ? frame.response : null;
423
+ const requestId = str(response?.request_id);
424
+ const taskId = requestId === null ? undefined : this.stopRequests.get(requestId);
425
+ if (response === null || requestId === null || taskId === undefined) {
426
+ return;
427
+ }
428
+ this.stopRequests.delete(requestId);
429
+ if (response.subtype === 'error') {
430
+ events.push({ type: 'stop.refused', taskId, message: str(response.error) ?? 'The CLI refused to stop it' });
431
+ }
412
432
  }
413
433
  handleStreamEvent(frame, events) {
414
434
  const event = frame.event;
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { usageRoots } from '../usage/roots.js';
5
4
  import { cleanTitle, readLines } from '../title-file.js';
5
+ import { usageRoots } from '../usage/roots.js';
6
6
  // The two records carry these words; a line without them is never parsed.
7
7
  const MARKERS = ['"ai-title"', '"custom-title"'];
8
8
  /*
@@ -1,4 +1,5 @@
1
1
  import type { ChatHistoryResult, ChatItem } from '@adecore/agent-contracts';
2
+ export declare function spokenText(content: unknown): string | null;
2
3
  export interface SubagentMeta {
3
4
  agentId: string;
4
5
  toolUseId: string | null;
@@ -12,7 +12,7 @@ function str(value) {
12
12
  return typeof value === 'string' && value !== '' ? value : null;
13
13
  }
14
14
  /* The words of a user line that somebody said, or null for a line that carries tool results. */
15
- function spokenText(content) {
15
+ export function spokenText(content) {
16
16
  if (typeof content === 'string') {
17
17
  return content;
18
18
  }