@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.
- 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/backend.d.ts
CHANGED
package/dist/chat/chat-core.d.ts
CHANGED
|
@@ -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?: {
|
package/dist/chat/chat-core.js
CHANGED
|
@@ -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.
|
|
490
|
-
*
|
|
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.
|
|
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) {
|
|
@@ -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,
|
|
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,
|
|
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,
|
|
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, {
|
|
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
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
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([
|
|
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;
|
|
@@ -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
|
-
|
|
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;
|
|
@@ -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
|
}
|
package/dist/chat/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { CodedError } from '../coded-error.ts';
|
|
2
|
-
export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
|
|
2
|
+
export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'subagent-stale' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
|
|
3
3
|
export declare class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {
|
|
4
4
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface SessionFs {
|
|
2
|
+
readdir(path: string): Promise<string[]>;
|
|
3
|
+
stat(path: string): Promise<{
|
|
4
|
+
mtimeMs: number;
|
|
5
|
+
} | null>;
|
|
6
|
+
readHead(path: string, maxBytes: number): Promise<string>;
|
|
7
|
+
}
|
|
8
|
+
export interface ExternalSession {
|
|
9
|
+
provider: 'claude' | 'codex';
|
|
10
|
+
sessionId: string;
|
|
11
|
+
title: string;
|
|
12
|
+
updatedAt: number;
|
|
13
|
+
cwd: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function listClaudeSessions({ projectsDir, folder, fs }: {
|
|
16
|
+
projectsDir: string;
|
|
17
|
+
folder: string;
|
|
18
|
+
fs: SessionFs;
|
|
19
|
+
}): Promise<ExternalSession[]>;
|
|
20
|
+
export declare function listCodexSessions({ sessionsDir, folder, fs, maxFiles }: {
|
|
21
|
+
sessionsDir: string;
|
|
22
|
+
folder: string;
|
|
23
|
+
fs: SessionFs;
|
|
24
|
+
maxFiles?: number;
|
|
25
|
+
}): Promise<ExternalSession[]>;
|
|
26
|
+
export declare function nodeSessionFs(): Promise<SessionFs>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { clipText } from '@adecore/agent-contracts';
|
|
3
|
+
import { isForkOrSubagent } from '../usage/readers/codex.js';
|
|
4
|
+
import { claudeProjectSlug, spokenText } from './claude-transcript.js';
|
|
5
|
+
const TITLE_LENGTH = 120;
|
|
6
|
+
// Claude writes hook output and attachments ahead of the first prompt, and Codex its instructions.
|
|
7
|
+
const HEAD_BYTES = 256 * 1024;
|
|
8
|
+
const CLAUDE_MAX_FILES = 200;
|
|
9
|
+
function titleOf(text) {
|
|
10
|
+
return clipText(text.replace(/\s+/g, ' ').trim(), TITLE_LENGTH);
|
|
11
|
+
}
|
|
12
|
+
function records(head) {
|
|
13
|
+
const lines = head.split('\n');
|
|
14
|
+
// The head ends wherever the byte limit fell, so its last line may not parse.
|
|
15
|
+
return lines.flatMap((line) => {
|
|
16
|
+
if (line === '') {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(line);
|
|
21
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? [parsed] : [];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const CLAUDE_BOOKKEEPING = new Set(['summary', 'file-history-snapshot', 'queue-operation']);
|
|
29
|
+
/* A reminder or the wrapper of a slash command is written as a user line too, but nobody said it. */
|
|
30
|
+
function claudePrompt(entry) {
|
|
31
|
+
if (CLAUDE_BOOKKEEPING.has(String(entry.type)) || entry.type !== 'user' || entry.isSidechain === true || entry.isMeta === true) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const message = typeof entry.message === 'object' && entry.message !== null ? entry.message : {};
|
|
35
|
+
const said = spokenText(message.content);
|
|
36
|
+
if (said === null || said.trim() === '' || said.startsWith('<command-') || said.startsWith('<local-command-')) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return said;
|
|
40
|
+
}
|
|
41
|
+
/*
|
|
42
|
+
* The conversations Claude Code kept for a folder, newest first. Only the folder's own directory is
|
|
43
|
+
* read, and only the `.jsonl` files in it, so the transcripts of subagents in `<session>/subagents` stay out.
|
|
44
|
+
* A file without a prompt somebody said is no conversation.
|
|
45
|
+
*/
|
|
46
|
+
export async function listClaudeSessions({ projectsDir, folder, fs }) {
|
|
47
|
+
const dir = join(projectsDir, claudeProjectSlug(folder));
|
|
48
|
+
const names = (await fs.readdir(dir)).filter((name) => name.endsWith('.jsonl'));
|
|
49
|
+
const stats = await Promise.all(names.map(async (name) => ({ name, stat: await fs.stat(join(dir, name)) })));
|
|
50
|
+
const newest = stats
|
|
51
|
+
.flatMap(({ name, stat }) => (stat === null ? [] : [{ name, mtimeMs: stat.mtimeMs }]))
|
|
52
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
|
53
|
+
.slice(0, CLAUDE_MAX_FILES);
|
|
54
|
+
const sessions = [];
|
|
55
|
+
for (const { name, mtimeMs } of newest) {
|
|
56
|
+
const prompt = records(await fs.readHead(join(dir, name), HEAD_BYTES))
|
|
57
|
+
.map(claudePrompt)
|
|
58
|
+
.find((said) => said !== null);
|
|
59
|
+
if (prompt !== undefined && prompt !== null) {
|
|
60
|
+
sessions.push({ provider: 'claude', sessionId: name.slice(0, -'.jsonl'.length), title: titleOf(prompt), updatedAt: mtimeMs, cwd: folder });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return sessions;
|
|
64
|
+
}
|
|
65
|
+
function codexPrompt(entry) {
|
|
66
|
+
const payload = typeof entry.payload === 'object' && entry.payload !== null ? entry.payload : null;
|
|
67
|
+
if (payload === null) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (entry.type === 'event_msg' && payload.type === 'user_message' && typeof payload.message === 'string') {
|
|
71
|
+
return payload.message.trim() === '' ? null : payload.message;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/* The newest names first; the folders are named by year, month and day, and the files by when they began. */
|
|
76
|
+
function newestFirst(names) {
|
|
77
|
+
return [...names].sort().reverse();
|
|
78
|
+
}
|
|
79
|
+
async function rolloutFiles(sessionsDir, fs, max) {
|
|
80
|
+
const files = [];
|
|
81
|
+
for (const year of newestFirst(await fs.readdir(sessionsDir))) {
|
|
82
|
+
for (const month of newestFirst(await fs.readdir(join(sessionsDir, year)))) {
|
|
83
|
+
for (const day of newestFirst(await fs.readdir(join(sessionsDir, year, month)))) {
|
|
84
|
+
const names = newestFirst((await fs.readdir(join(sessionsDir, year, month, day))).filter((name) => name.startsWith('rollout-') && name.endsWith('.jsonl')));
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
files.push(join(sessionsDir, year, month, day, name));
|
|
87
|
+
if (files.length >= max) {
|
|
88
|
+
return files;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return files;
|
|
95
|
+
}
|
|
96
|
+
/*
|
|
97
|
+
* The conversations Codex kept for a folder, newest first, out of the newest `maxFiles` rollouts. A
|
|
98
|
+
* rollout counts when its first line, the session's metadata, names this folder and is no fork or subagent.
|
|
99
|
+
*/
|
|
100
|
+
export async function listCodexSessions({ sessionsDir, folder, fs, maxFiles = 400 }) {
|
|
101
|
+
const sessions = [];
|
|
102
|
+
for (const path of await rolloutFiles(sessionsDir, fs, maxFiles)) {
|
|
103
|
+
const entries = records(await fs.readHead(path, HEAD_BYTES));
|
|
104
|
+
const meta = entries[0];
|
|
105
|
+
const payload = meta?.type === 'session_meta' && typeof meta.payload === 'object' && meta.payload !== null ? meta.payload : null;
|
|
106
|
+
if (payload === null || payload.cwd !== folder || typeof payload.id !== 'string' || isForkOrSubagent(payload)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const prompt = entries.map(codexPrompt).find((said) => said !== null);
|
|
110
|
+
if (prompt === undefined || prompt === null) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const stat = await fs.stat(path);
|
|
114
|
+
sessions.push({ provider: 'codex', sessionId: payload.id, title: titleOf(prompt), updatedAt: stat?.mtimeMs ?? 0, cwd: folder });
|
|
115
|
+
}
|
|
116
|
+
return sessions;
|
|
117
|
+
}
|
|
118
|
+
/* A `node:fs` behind the seam, for a host that lists the real thing. */
|
|
119
|
+
export async function nodeSessionFs() {
|
|
120
|
+
const { readdir, stat, open } = await import('node:fs/promises');
|
|
121
|
+
return {
|
|
122
|
+
readdir: async (path) => readdir(path).catch(() => []),
|
|
123
|
+
stat: async (path) => stat(path).catch(() => null),
|
|
124
|
+
readHead: async (path, maxBytes) => {
|
|
125
|
+
const handle = await open(path, 'r');
|
|
126
|
+
try {
|
|
127
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
128
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
|
129
|
+
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await handle.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|