@adecore/agents 0.21.2 → 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.
- package/dist/chat/backend.d.ts +4 -0
- package/dist/chat/chat-core.d.ts +3 -0
- package/dist/chat/chat-core.js +4 -3
- package/dist/chat/chat-session.d.ts +11 -2
- package/dist/chat/chat-session.js +141 -13
- package/dist/chat/claude-protocol.d.ts +2 -0
- package/dist/chat/claude-protocol.js +21 -1
- package/dist/chat/claude-transcript.d.ts +1 -0
- package/dist/chat/claude-transcript.js +1 -1
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/external-sessions.d.ts +26 -0
- package/dist/chat/external-sessions.js +136 -0
- package/dist/chat/fake-claude.js +73 -0
- package/dist/chat/thread-search.d.ts +14 -0
- package/dist/chat/thread-search.js +64 -0
- package/dist/host/handlers.js +1 -1
- package/dist/providers/catalog.js +2 -1
- package/dist/tasks/waiting-child.d.ts +1 -0
- package/dist/tasks/waiting-child.js +3 -3
- package/dist/usage/readers/codex.d.ts +1 -0
- package/dist/usage/readers/codex.js +1 -1
- package/package.json +3 -3
- package/src/chat/backend.ts +2 -0
- package/src/chat/chat-core.ts +4 -3
- package/src/chat/chat-session.ts +148 -13
- package/src/chat/claude-protocol.ts +23 -1
- package/src/chat/claude-transcript.ts +1 -1
- package/src/chat/errors.ts +2 -0
- package/src/chat/external-sessions.ts +177 -0
- package/src/chat/fake-claude.ts +75 -1
- package/src/chat/thread-search.ts +87 -0
- package/src/host/handlers.ts +1 -1
- package/src/providers/catalog.ts +2 -1
- package/src/tasks/waiting-child.ts +4 -4
- package/src/usage/readers/codex.ts +1 -1
package/dist/chat/fake-claude.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/host/handlers.js
CHANGED
|
@@ -31,7 +31,7 @@ export function chatHandlers(chats, providers) {
|
|
|
31
31
|
chats.detach(payload.chatId, clientId);
|
|
32
32
|
return {};
|
|
33
33
|
},
|
|
34
|
-
'chat.send': (payload) => chats.send(payload.chatId, payload.text, { 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. */
|
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.22.0",
|
|
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.
|
|
24
|
-
"@adecore/intelligent-ui": "0.
|
|
23
|
+
"@adecore/agent-contracts": "0.22.0",
|
|
24
|
+
"@adecore/intelligent-ui": "0.22.0",
|
|
25
25
|
"zod": "^4.6.5"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
package/src/chat/backend.ts
CHANGED
|
@@ -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';
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -640,8 +640,9 @@ export class ChatCore {
|
|
|
640
640
|
}
|
|
641
641
|
|
|
642
642
|
/*
|
|
643
|
-
* Stops one running sub-agent of the CLI's own.
|
|
644
|
-
*
|
|
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.
|
|
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. */
|
package/src/chat/chat-session.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
{
|
|
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
|
-
|
|
1366
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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([
|
|
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
|
-
|
|
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
|
}
|
package/src/chat/errors.ts
CHANGED
|
@@ -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'
|