@adecore/agents 0.23.0 → 0.24.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/README.md +3 -3
- package/dist/chat/attachment-store.js +4 -5
- package/dist/chat/backend.d.ts +6 -1
- package/dist/chat/background-work.d.ts +1 -1
- package/dist/chat/background-work.js +2 -1
- package/dist/chat/chat-core.d.ts +53 -161
- package/dist/chat/chat-core.js +146 -112
- package/dist/chat/chat-log.js +3 -3
- package/dist/chat/chat-process.d.ts +21 -0
- package/dist/chat/chat-process.js +109 -9
- package/dist/chat/chat-session.d.ts +10 -17
- package/dist/chat/chat-session.js +141 -182
- package/dist/chat/chat-store.d.ts +1 -0
- package/dist/chat/chat-store.js +105 -1
- package/dist/chat/claude-backend.d.ts +4 -0
- package/dist/chat/claude-backend.js +51 -27
- package/dist/chat/claude-protocol.d.ts +12 -0
- package/dist/chat/claude-protocol.js +137 -7
- package/dist/chat/claude-title.d.ts +1 -3
- package/dist/chat/claude-title.js +11 -39
- package/dist/chat/claude-transcript.d.ts +1 -0
- package/dist/chat/claude-transcript.js +36 -17
- package/dist/chat/codex-backend.d.ts +21 -1
- package/dist/chat/codex-backend.js +168 -10
- package/dist/chat/codex-protocol.d.ts +2 -0
- package/dist/chat/codex-protocol.js +37 -3
- package/dist/chat/codex-thread.js +1 -3
- package/dist/chat/codex-transport.d.ts +3 -0
- package/dist/chat/codex-transport.js +38 -35
- package/dist/chat/context-breakdown.js +2 -2
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/external-sessions.js +2 -2
- package/dist/chat/fixtures/claude-2.1.296-goal.json +136 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
- package/dist/chat/index.d.ts +29 -0
- package/dist/chat/index.js +23 -0
- package/dist/chat/projector.d.ts +2 -1
- package/dist/chat/projector.js +12 -3
- package/dist/chat/request-summary.js +1 -4
- package/dist/chat/subagent-settlement.js +1 -3
- package/dist/chat/thread.d.ts +3 -0
- package/dist/chat/thread.js +20 -3
- package/dist/chat/ui-queries.d.ts +2 -0
- package/dist/chat/ui-queries.js +40 -5
- package/dist/chat/ui-replies.d.ts +33 -0
- package/dist/chat/ui-replies.js +152 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +3 -0
- package/dist/coordination/index.d.ts +15 -0
- package/dist/coordination/index.js +16 -0
- package/dist/host/handlers.js +8 -0
- package/dist/host/index.d.ts +7 -0
- package/dist/host/index.js +7 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +10 -0
- package/dist/providers/accounts/status.js +1 -3
- package/dist/providers/claude.js +3 -1
- package/dist/providers/codex.js +3 -1
- package/dist/providers/index.d.ts +14 -0
- package/dist/providers/index.js +12 -0
- package/dist/run-process.js +54 -14
- package/dist/skills.d.ts +1 -0
- package/dist/skills.js +4 -0
- package/dist/tasks/task-coordinator.d.ts +4 -0
- package/dist/tasks/task-coordinator.js +16 -2
- package/dist/tasks/wiring.js +7 -1
- package/dist/{providers/accounts/test-accounts.d.ts → testing/accounts.d.ts} +2 -2
- package/dist/{providers/accounts/test-accounts.js → testing/accounts.js} +3 -3
- package/dist/testing/bin/fake-claude.d.ts +1 -0
- package/dist/testing/bin/fake-claude.js +6 -0
- package/dist/testing/bin/fake-codex.d.ts +1 -0
- package/dist/testing/bin/fake-codex.js +6 -0
- package/dist/{chat → testing}/fake-claude.d.ts +1 -1
- package/dist/{chat → testing}/fake-claude.js +0 -4
- package/dist/{chat → testing}/fake-cli.d.ts +1 -1
- package/dist/{chat → testing}/fake-codex.d.ts +1 -1
- package/dist/{chat → testing}/fake-codex.js +70 -8
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +7 -0
- package/dist/{outbox → testing}/manual-clock.d.ts +1 -1
- package/dist/{watch-test-helpers.d.ts → testing/watch.d.ts} +1 -1
- package/dist/usage/exchange.d.ts +5 -3
- package/dist/usage/exchange.js +25 -16
- package/dist/usage/index-file.d.ts +1 -0
- package/dist/usage/index-file.js +20 -2
- package/dist/usage/index.d.ts +9 -0
- package/dist/usage/index.js +6 -0
- package/dist/usage/limits/normalize.js +1 -3
- package/dist/usage/pricing.d.ts +5 -3
- package/dist/usage/pricing.js +28 -19
- package/dist/usage/scanner.js +39 -11
- package/dist/usage/usage-service.js +8 -3
- package/package.json +72 -11
- package/src/chat/attachment-store.ts +4 -6
- package/src/chat/backend.ts +8 -0
- package/src/chat/background-work.ts +2 -2
- package/src/chat/chat-core.ts +221 -143
- package/src/chat/chat-log.ts +3 -3
- package/src/chat/chat-process.ts +128 -8
- package/src/chat/chat-session.ts +153 -204
- package/src/chat/chat-store.ts +104 -1
- package/src/chat/claude-backend.ts +58 -26
- package/src/chat/claude-protocol.ts +147 -8
- package/src/chat/claude-title.ts +11 -39
- package/src/chat/claude-transcript.ts +38 -18
- package/src/chat/codex-backend.ts +182 -20
- package/src/chat/codex-protocol.ts +40 -5
- package/src/chat/codex-thread.ts +1 -4
- package/src/chat/codex-transport.ts +45 -35
- package/src/chat/context-breakdown.ts +2 -2
- package/src/chat/errors.ts +1 -0
- package/src/chat/external-sessions.ts +2 -2
- package/src/chat/fixtures/claude-2.1.296-goal.json +136 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
- package/src/chat/index.ts +49 -0
- package/src/chat/projector.ts +13 -4
- package/src/chat/request-summary.ts +8 -5
- package/src/chat/subagent-settlement.ts +1 -4
- package/src/chat/thread.ts +22 -3
- package/src/chat/ui-queries.ts +46 -5
- package/src/chat/ui-replies.ts +187 -0
- package/src/context/index.ts +26 -0
- package/src/coordination/index.ts +67 -0
- package/src/host/handlers.ts +8 -0
- package/src/host/index.ts +8 -0
- package/src/index.ts +20 -0
- package/src/providers/accounts/status.ts +1 -4
- package/src/providers/claude.ts +3 -1
- package/src/providers/codex.ts +3 -1
- package/src/providers/index.ts +15 -0
- package/src/run-process.ts +56 -16
- package/src/skills.ts +5 -0
- package/src/tasks/task-coordinator.ts +22 -2
- package/src/tasks/wiring.ts +7 -1
- package/src/{providers/accounts/test-accounts.ts → testing/accounts.ts} +4 -4
- package/src/testing/bin/fake-claude.ts +7 -0
- package/src/testing/bin/fake-codex.ts +7 -0
- package/src/{chat → testing}/fake-claude.ts +1 -5
- package/src/{chat → testing}/fake-cli.ts +1 -1
- package/src/{chat → testing}/fake-codex.ts +75 -6
- package/src/testing/index.ts +7 -0
- package/src/{outbox → testing}/manual-clock.ts +1 -1
- package/src/{watch-test-helpers.ts → testing/watch.ts} +1 -1
- package/src/usage/exchange.ts +31 -20
- package/src/usage/index-file.ts +23 -2
- package/src/usage/index.ts +10 -0
- package/src/usage/limits/normalize.ts +1 -4
- package/src/usage/pricing.ts +34 -23
- package/src/usage/scanner.ts +48 -11
- package/src/usage/usage-service.ts +8 -3
- /package/dist/{chat → testing}/fake-cli.js +0 -0
- /package/dist/{outbox → testing}/manual-clock.js +0 -0
- /package/dist/{watch-test-helpers.js → testing/watch.js} +0 -0
|
@@ -2,10 +2,14 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { errorText } from '../error-text.ts';
|
|
3
3
|
import { claudeArgs, claudeEnv, promptPrefix } from '../providers/claude.ts';
|
|
4
4
|
import type { ApprovalDecision, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
|
|
5
|
-
import { ChatChild } from './chat-process.ts';
|
|
5
|
+
import { ChatChild, MAX_LINE_LENGTH, readStreamLines } from './chat-process.ts';
|
|
6
6
|
import { ClaudeProtocol } from './claude-protocol.ts';
|
|
7
7
|
import { buildUserMessage } from './input.ts';
|
|
8
8
|
|
|
9
|
+
// Claude may echo a steer only once a slow tool returns, so a running turn sets no deadline; after the turn ended, a message
|
|
10
|
+
// it still holds opens the next turn at once or never arrives.
|
|
11
|
+
const STEER_GRACE_MS = 30_000;
|
|
12
|
+
|
|
9
13
|
export interface ClaudeBackendOptions {
|
|
10
14
|
// Tools the CLI runs without asking, in every mode, as `--allowedTools` patterns (`Bash(git status)`).
|
|
11
15
|
allowedTools?: readonly string[];
|
|
@@ -22,18 +26,20 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
22
26
|
private readonly launch: BackendLaunch;
|
|
23
27
|
private readonly host: BackendHost;
|
|
24
28
|
private readonly allowedTools: readonly string[];
|
|
25
|
-
private readonly protocol
|
|
29
|
+
private readonly protocol: ClaudeProtocol;
|
|
26
30
|
private child: ChatChild | null = null;
|
|
27
31
|
private stdinClosed = false;
|
|
28
32
|
// The person asked to stop; the result frame that follows closes the turn as aborted.
|
|
29
33
|
private interrupted = false;
|
|
30
34
|
private readonly steers = new Map<string, { resolve(accepted: boolean): void; reject(reason: Error): void }>();
|
|
35
|
+
private steerTimer: ReturnType<typeof setTimeout> | null = null;
|
|
31
36
|
private active = false;
|
|
32
37
|
|
|
33
38
|
constructor(launch: BackendLaunch, host: BackendHost, options: ClaudeBackendOptions = {}) {
|
|
34
39
|
this.launch = launch;
|
|
35
40
|
this.host = host;
|
|
36
41
|
this.allowedTools = options.allowedTools ?? [];
|
|
42
|
+
this.protocol = new ClaudeProtocol(launch.goal ?? null);
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
get running(): boolean {
|
|
@@ -62,7 +68,7 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
62
68
|
cwd: this.launch.cwd,
|
|
63
69
|
env: { ...this.launch.env, ...claudeEnv(selection) },
|
|
64
70
|
...(this.launch.spawn ? { spawn: this.launch.spawn } : {}),
|
|
65
|
-
onError: (error) => this.
|
|
71
|
+
onError: (error) => this.broken(error),
|
|
66
72
|
onExit: (exitCode, stderr) => this.handleExit(child, exitCode, stderr)
|
|
67
73
|
});
|
|
68
74
|
this.child = child;
|
|
@@ -101,6 +107,7 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
101
107
|
|
|
102
108
|
interrupt(): void {
|
|
103
109
|
this.interrupted = true;
|
|
110
|
+
this.protocol.noteInterrupt();
|
|
104
111
|
this.write({ type: 'control_request', request_id: `interrupt-${Date.now()}`, request: { subtype: 'interrupt' } });
|
|
105
112
|
}
|
|
106
113
|
|
|
@@ -151,32 +158,20 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
private async readLines(child: ChatChild): Promise<void> {
|
|
154
|
-
const reader = child.process.stdout.getReader();
|
|
155
|
-
const decoder = new TextDecoder();
|
|
156
|
-
let buffered = '';
|
|
157
161
|
try {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
while (newline >= 0) {
|
|
166
|
-
this.readLine(child, buffered.slice(0, newline));
|
|
167
|
-
buffered = buffered.slice(newline + 1);
|
|
168
|
-
newline = buffered.indexOf('\n');
|
|
162
|
+
await readStreamLines(
|
|
163
|
+
child.process.stdout,
|
|
164
|
+
(line) => this.readLine(child, line),
|
|
165
|
+
() => {
|
|
166
|
+
if (this.child === child) {
|
|
167
|
+
this.failed(new Error(`Claude wrote a frame of more than ${MAX_LINE_LENGTH / 1_000_000} million characters, which was dropped`));
|
|
168
|
+
}
|
|
169
169
|
}
|
|
170
|
-
|
|
171
|
-
if (buffered.trim() !== '') {
|
|
172
|
-
this.readLine(child, buffered);
|
|
173
|
-
}
|
|
170
|
+
);
|
|
174
171
|
} catch (error) {
|
|
175
172
|
if (this.child === child) {
|
|
176
|
-
this.
|
|
173
|
+
this.broken(error);
|
|
177
174
|
}
|
|
178
|
-
} finally {
|
|
179
|
-
reader.releaseLock();
|
|
180
175
|
}
|
|
181
176
|
}
|
|
182
177
|
|
|
@@ -194,10 +189,14 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
194
189
|
if (echoed?.type === 'user' && typeof echoed.uuid === 'string' && echoed.parent_tool_use_id == null) {
|
|
195
190
|
this.steers.get(echoed.uuid)?.resolve(true);
|
|
196
191
|
this.steers.delete(echoed.uuid);
|
|
192
|
+
if (this.steers.size === 0) {
|
|
193
|
+
this.clearSteerTimer();
|
|
194
|
+
}
|
|
197
195
|
}
|
|
198
196
|
for (const event of this.protocol.handle(frame)) {
|
|
199
197
|
if (event.type === 'turn.done') {
|
|
200
198
|
this.active = false;
|
|
199
|
+
this.awaitSteers();
|
|
201
200
|
}
|
|
202
201
|
if (event.type === 'turn.done' && this.interrupted) {
|
|
203
202
|
this.interrupted = false;
|
|
@@ -216,14 +215,42 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
216
215
|
}
|
|
217
216
|
}
|
|
218
217
|
|
|
218
|
+
private awaitSteers(): void {
|
|
219
|
+
if (this.steers.size === 0 || this.steerTimer !== null) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.steerTimer = setTimeout(() => {
|
|
223
|
+
this.steerTimer = null;
|
|
224
|
+
for (const pending of this.steers.values()) {
|
|
225
|
+
pending.reject(new Error('Claude ended its turn without taking your message'));
|
|
226
|
+
}
|
|
227
|
+
this.steers.clear();
|
|
228
|
+
}, STEER_GRACE_MS);
|
|
229
|
+
this.steerTimer.unref?.();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private clearSteerTimer(): void {
|
|
233
|
+
if (this.steerTimer !== null) {
|
|
234
|
+
clearTimeout(this.steerTimer);
|
|
235
|
+
this.steerTimer = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
219
239
|
private rejectSteers(reason: string): void {
|
|
220
240
|
this.active = false;
|
|
241
|
+
this.clearSteerTimer();
|
|
221
242
|
for (const pending of this.steers.values()) {
|
|
222
243
|
pending.reject(new Error(reason));
|
|
223
244
|
}
|
|
224
245
|
this.steers.clear();
|
|
225
246
|
}
|
|
226
247
|
|
|
248
|
+
/* A pipe that failed takes no more writes; a frame a consumer choked on leaves it as it was. */
|
|
249
|
+
private broken(error: unknown): void {
|
|
250
|
+
this.stdinClosed = true;
|
|
251
|
+
this.failed(error);
|
|
252
|
+
}
|
|
253
|
+
|
|
227
254
|
private failed(error: unknown): void {
|
|
228
255
|
this.rejectSteers(`Claude transport failed: ${errorText(error)}`);
|
|
229
256
|
this.protocol.forgetPending();
|
|
@@ -250,8 +277,13 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
250
277
|
if (!child || this.stdinClosed) {
|
|
251
278
|
throw new Error('Claude stdin is closed');
|
|
252
279
|
}
|
|
253
|
-
|
|
254
|
-
|
|
280
|
+
try {
|
|
281
|
+
child.process.stdin.write(`${JSON.stringify(frame)}\n`);
|
|
282
|
+
child.process.stdin.flush();
|
|
283
|
+
} catch (error) {
|
|
284
|
+
this.stdinClosed = true;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
255
287
|
}
|
|
256
288
|
|
|
257
289
|
private closeStdin(): void {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
ChatGoal,
|
|
2
3
|
ChatQuestion,
|
|
3
4
|
ChatSubagentStatus,
|
|
4
5
|
ChatSubagentUsage,
|
|
@@ -7,16 +8,13 @@ import type {
|
|
|
7
8
|
ChatWorkflowAgent,
|
|
8
9
|
ChatWorkflowPhase
|
|
9
10
|
} from '@adecore/agent-contracts';
|
|
11
|
+
import { isRecord } from '@adecore/agent-contracts';
|
|
10
12
|
import { readClaudeEvent } from '../usage/limits/normalize.ts';
|
|
11
13
|
import { claudeRuntimeMode } from '../providers/claude.ts';
|
|
12
14
|
import type { ApprovalDecision, BackendEvent } from './backend.ts';
|
|
13
15
|
|
|
14
16
|
type Frame = Record<string, unknown>;
|
|
15
17
|
|
|
16
|
-
function isRecord(value: unknown): value is Frame {
|
|
17
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
18
|
function str(value: unknown): string | null {
|
|
21
19
|
return typeof value === 'string' ? value : null;
|
|
22
20
|
}
|
|
@@ -118,6 +116,70 @@ type Pending =
|
|
|
118
116
|
| { type: 'approval'; toolUseId: string | null; input: unknown; suggestions: unknown[] }
|
|
119
117
|
| { type: 'question'; toolUseId: string | null; input: unknown };
|
|
120
118
|
|
|
119
|
+
/*
|
|
120
|
+
* What Claude Code 2.1.296 answers a `/goal` with, as a `<synthetic>` assistant message of its own: the goal
|
|
121
|
+
* it set, the one it cleared, or the one that is active with how many turns its check sent back. Undefined
|
|
122
|
+
* for any other text, null where no goal is left.
|
|
123
|
+
*/
|
|
124
|
+
const GOAL_SET = 'Goal set: ';
|
|
125
|
+
const GOAL_ACTIVE = /^Goal active: ([\s\S]+?) \(([^)]*)\)(?:\nLast check: ([\s\S]*))?$/;
|
|
126
|
+
const GOAL_GONE = ['Goal cleared: ', 'Goal cleared after ', 'No goal set'];
|
|
127
|
+
|
|
128
|
+
export function claudeGoalFromCommand(text: string, current: ChatGoal | null): ChatGoal | null | undefined {
|
|
129
|
+
const said = text.trim();
|
|
130
|
+
if (said.startsWith(GOAL_SET)) {
|
|
131
|
+
const objective = said.slice(GOAL_SET.length).trim();
|
|
132
|
+
return objective === '' ? undefined : { objective, status: 'active', checks: 0 };
|
|
133
|
+
}
|
|
134
|
+
if (GOAL_GONE.some((prefix) => said.startsWith(prefix))) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const active = GOAL_ACTIVE.exec(said);
|
|
138
|
+
if (!active) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
const objective = active[1]!;
|
|
142
|
+
const checks = /\d+/.exec(active[2]!)?.[0];
|
|
143
|
+
const lastCheck = active[3]?.trim();
|
|
144
|
+
return {
|
|
145
|
+
...(current?.objective === objective ? current : {}),
|
|
146
|
+
objective,
|
|
147
|
+
status: 'active',
|
|
148
|
+
checks: checks === undefined ? 0 : Number(checks),
|
|
149
|
+
...(lastCheck ? { lastCheck } : {})
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The messages whose blocks are still counted. The frames of one message come close together, so only the newest can still grow.
|
|
154
|
+
const COUNTED_MESSAGES = 256;
|
|
155
|
+
|
|
156
|
+
/* The ordinal of the next block of `messageId`; a count moves to the back on every block and the oldest goes. */
|
|
157
|
+
function nextOrdinal(counts: Map<string, number>, messageId: string): number {
|
|
158
|
+
const ordinal = counts.get(messageId) ?? 0;
|
|
159
|
+
counts.delete(messageId);
|
|
160
|
+
counts.set(messageId, ordinal + 1);
|
|
161
|
+
if (counts.size > COUNTED_MESSAGES) {
|
|
162
|
+
counts.delete(counts.keys().next().value!);
|
|
163
|
+
}
|
|
164
|
+
return ordinal;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/*
|
|
168
|
+
* One check of the goal's Stop hook that found it not met yet: a synthetic user message the CLI feeds the
|
|
169
|
+
* agent before it goes on, which names the objective in brackets. Undefined for any other message.
|
|
170
|
+
*/
|
|
171
|
+
export function claudeGoalCheck(text: string, current: ChatGoal | null): ChatGoal | undefined {
|
|
172
|
+
if (current === null) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const prefix = `Stop hook feedback:\n[${current.objective}]: `;
|
|
176
|
+
if (!text.startsWith(prefix)) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const lastCheck = text.slice(prefix.length).trim();
|
|
180
|
+
return { ...current, status: 'active', checks: (current.checks ?? 0) + 1, ...(lastCheck === '' ? {} : { lastCheck }) };
|
|
181
|
+
}
|
|
182
|
+
|
|
121
183
|
/*
|
|
122
184
|
* Claude Code's stream-json frames, in and out. Text is keyed by message id plus the ordinal of the
|
|
123
185
|
* text block inside that message, because the streaming events and the final `assistant` frames
|
|
@@ -151,6 +213,28 @@ export class ClaudeProtocol {
|
|
|
151
213
|
private readonly stopRequests = new Map<string, string>();
|
|
152
214
|
|
|
153
215
|
private readonly steerIds = new Set<string>();
|
|
216
|
+
// The goal this process works toward, which `--resume` takes up from the transcript; a model answer since it was
|
|
217
|
+
// set or last checked is what lets a turn that ends normally count as its check passing.
|
|
218
|
+
private goal: ChatGoal | null;
|
|
219
|
+
private goalAnswered = false;
|
|
220
|
+
private interrupting = false;
|
|
221
|
+
// Background subagents still at work; Claude Code holds a goal's check until they report.
|
|
222
|
+
private readonly backgroundAgents = new Set<string>();
|
|
223
|
+
|
|
224
|
+
constructor(goal: ChatGoal | null = null) {
|
|
225
|
+
this.goal = goal;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* A stop is on its way; the turn it ends leaves the goal set but nobody working on it. */
|
|
229
|
+
noteInterrupt(): void {
|
|
230
|
+
this.interrupting = true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private setGoal(goal: ChatGoal | null, events: BackendEvent[]): void {
|
|
234
|
+
this.goal = goal;
|
|
235
|
+
this.goalAnswered = false;
|
|
236
|
+
events.push({ type: 'goal', goal });
|
|
237
|
+
}
|
|
154
238
|
|
|
155
239
|
beginSteer(promptId: string): void {
|
|
156
240
|
this.steerIds.add(promptId);
|
|
@@ -347,6 +431,7 @@ export class ClaudeProtocol {
|
|
|
347
431
|
if (taskId) {
|
|
348
432
|
this.endBackground(taskId, events);
|
|
349
433
|
this.workflows.delete(taskId);
|
|
434
|
+
this.backgroundAgents.delete(taskId);
|
|
350
435
|
}
|
|
351
436
|
// The CLI wakes the main agent itself when a background task settles; this frame is the only
|
|
352
437
|
// thing that says what it was about, and it arrives before the turn nobody asked for.
|
|
@@ -369,6 +454,10 @@ export class ClaudeProtocol {
|
|
|
369
454
|
private handleTaskStarted(frame: Frame, events: BackendEvent[]): void {
|
|
370
455
|
const ref = str(frame.tool_use_id);
|
|
371
456
|
if (frame.task_type === 'local_agent') {
|
|
457
|
+
const agentTask = str(frame.task_id);
|
|
458
|
+
if (agentTask && frame.is_backgrounded === true) {
|
|
459
|
+
this.backgroundAgents.add(agentTask);
|
|
460
|
+
}
|
|
372
461
|
if (ref) {
|
|
373
462
|
events.push({
|
|
374
463
|
type: 'task.started',
|
|
@@ -417,6 +506,7 @@ export class ClaudeProtocol {
|
|
|
417
506
|
const status = str(patch.status);
|
|
418
507
|
if (status !== null && ENDED_TASK_STATUSES.has(status)) {
|
|
419
508
|
this.foregroundShells.delete(taskId);
|
|
509
|
+
this.backgroundAgents.delete(taskId);
|
|
420
510
|
this.endBackground(taskId, events);
|
|
421
511
|
this.endWorkflow(taskId, null, status === 'completed', events);
|
|
422
512
|
}
|
|
@@ -525,6 +615,9 @@ export class ClaudeProtocol {
|
|
|
525
615
|
this.apiError = frame.error;
|
|
526
616
|
}
|
|
527
617
|
const model = str(message.model);
|
|
618
|
+
if (!parentRef) {
|
|
619
|
+
this.trackGoal(model, content, events);
|
|
620
|
+
}
|
|
528
621
|
// The CLI answers some frames itself and names them `<synthetic>`; no model wrote those.
|
|
529
622
|
if (parentRef && model && model !== '<synthetic>' && this.subagentModels.get(parentRef) !== model) {
|
|
530
623
|
this.subagentModels.set(parentRef, model);
|
|
@@ -535,13 +628,11 @@ export class ClaudeProtocol {
|
|
|
535
628
|
continue;
|
|
536
629
|
}
|
|
537
630
|
if (block.type === 'text') {
|
|
538
|
-
const ordinal = this.frameTextCount
|
|
539
|
-
this.frameTextCount.set(messageId, ordinal + 1);
|
|
631
|
+
const ordinal = nextOrdinal(this.frameTextCount, messageId);
|
|
540
632
|
// A subagent's text is its own report, not the thread's answer; the parent says whose row it is.
|
|
541
633
|
events.push({ type: 'text.done', ref: this.textRef(messageId, ordinal), text: str(block.text) ?? '', ...(parentRef ? { parentRef } : {}) });
|
|
542
634
|
} else if (block.type === 'thinking' && !parentRef) {
|
|
543
|
-
const ordinal = this.frameThinkingCount
|
|
544
|
-
this.frameThinkingCount.set(messageId, ordinal + 1);
|
|
635
|
+
const ordinal = nextOrdinal(this.frameThinkingCount, messageId);
|
|
545
636
|
events.push({ type: 'thinking.done', ref: this.thinkingRef(messageId, ordinal), text: str(block.thinking) ?? '' });
|
|
546
637
|
} else if (block.type === 'tool_use') {
|
|
547
638
|
events.push({
|
|
@@ -563,6 +654,45 @@ export class ClaudeProtocol {
|
|
|
563
654
|
}
|
|
564
655
|
}
|
|
565
656
|
|
|
657
|
+
private trackGoal(model: string | null, content: unknown[], events: BackendEvent[]): void {
|
|
658
|
+
if (model === '<synthetic>') {
|
|
659
|
+
const next = claudeGoalFromCommand(resultText(content), this.goal);
|
|
660
|
+
if (next !== undefined) {
|
|
661
|
+
this.setGoal(next, events);
|
|
662
|
+
}
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (this.goal === null) {
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
this.goalAnswered = true;
|
|
669
|
+
// A message after a stop takes the goal up again; its hook never went away.
|
|
670
|
+
if (this.goal.status === 'paused') {
|
|
671
|
+
this.goal = { ...this.goal, status: 'active' };
|
|
672
|
+
events.push({ type: 'goal', goal: this.goal });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/*
|
|
677
|
+
* Where a turn leaves the goal. Claude Code ends a turn on its own only once the goal's check passed, so
|
|
678
|
+
* a turn that ends normally after the model answered met it; Claude Code 2.1.296 says nothing more
|
|
679
|
+
* about it in stream-json. A check it defers while background work runs leaves the goal active, and a
|
|
680
|
+
* stopped or failed turn leaves it set with nobody working on it.
|
|
681
|
+
*/
|
|
682
|
+
private settleGoal(failed: boolean, events: BackendEvent[]): void {
|
|
683
|
+
const goal = this.goal;
|
|
684
|
+
const interrupted = this.interrupting;
|
|
685
|
+
this.interrupting = false;
|
|
686
|
+
if (goal?.status !== 'active') {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (failed || interrupted) {
|
|
690
|
+
this.setGoal({ ...goal, status: 'paused' }, events);
|
|
691
|
+
} else if (this.goalAnswered && this.backgroundTasks.size === 0 && this.workflows.size === 0 && this.backgroundAgents.size === 0) {
|
|
692
|
+
this.setGoal({ ...goal, status: 'complete' }, events);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
566
696
|
/*
|
|
567
697
|
* `tool_progress` carries how long the call has run (once per 30 s for a Bash under the CLI's
|
|
568
698
|
* remote gate, or a heartbeat for a slow MCP tool); the start is kept so the client can count on.
|
|
@@ -583,6 +713,13 @@ export class ClaudeProtocol {
|
|
|
583
713
|
}
|
|
584
714
|
const message = isRecord(frame.message) ? frame.message : {};
|
|
585
715
|
const content = Array.isArray(message.content) ? message.content : [];
|
|
716
|
+
if (frame.isSynthetic === true && !str(frame.parent_tool_use_id)) {
|
|
717
|
+
const checked = claudeGoalCheck(resultText(message.content), this.goal);
|
|
718
|
+
if (checked !== undefined) {
|
|
719
|
+
this.setGoal(checked, events);
|
|
720
|
+
events.push({ type: 'note', level: 'info', text: `Goal not met yet: ${checked.lastCheck ?? checked.objective}` });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
586
723
|
for (const block of content) {
|
|
587
724
|
if (!isRecord(block) || block.type !== 'tool_result') {
|
|
588
725
|
continue;
|
|
@@ -630,6 +767,8 @@ export class ClaudeProtocol {
|
|
|
630
767
|
const limit = failed ? this.limit() : null;
|
|
631
768
|
this.apiError = null;
|
|
632
769
|
this.refusedWindows.clear();
|
|
770
|
+
// Before the turn ends, so whoever settles work on that end already reads where the goal stands.
|
|
771
|
+
this.settleGoal(failed, events);
|
|
633
772
|
events.push({
|
|
634
773
|
type: 'turn.done',
|
|
635
774
|
...(promptId === null ? {} : { promptId }),
|
package/src/chat/claude-title.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { join } from 'node:path';
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { isRecord } from '@adecore/agent-contracts';
|
|
4
3
|
import { cleanTitle, readLines } from '../title-file.ts';
|
|
5
4
|
import { usageRoots } from '../usage/roots.ts';
|
|
5
|
+
import { findClaudeSession } from './claude-transcript.ts';
|
|
6
6
|
|
|
7
7
|
interface Progress {
|
|
8
8
|
offset: number;
|
|
@@ -22,7 +22,6 @@ const MARKERS = ['"ai-title"', '"custom-title"'];
|
|
|
22
22
|
*/
|
|
23
23
|
export class ClaudeTitleReader {
|
|
24
24
|
private readonly progress = new Map<string, Progress>();
|
|
25
|
-
private readonly found = new Map<string, string>();
|
|
26
25
|
private readonly projectsDir: string;
|
|
27
26
|
private readonly chunkBytes: number;
|
|
28
27
|
|
|
@@ -31,9 +30,9 @@ export class ClaudeTitleReader {
|
|
|
31
30
|
this.chunkBytes = chunkBytes;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
/* The title of a session found by its id, for a chat, which knows no transcript path; in the projects of its account. */
|
|
35
|
-
async forSession(agentSessionId: string, projectsDir: string = this.projectsDir): Promise<string | null> {
|
|
36
|
-
const path = await
|
|
33
|
+
/* The title of a session found by its id, for a chat, which knows no transcript path; in the projects of its account, its own folder first. */
|
|
34
|
+
async forSession(agentSessionId: string, projectsDir: string = this.projectsDir, cwd?: string): Promise<string | null> {
|
|
35
|
+
const path = await findClaudeSession(projectsDir, agentSessionId, cwd);
|
|
37
36
|
return path === null ? null : await this.forTranscript(path);
|
|
38
37
|
}
|
|
39
38
|
|
|
@@ -67,40 +66,13 @@ export class ClaudeTitleReader {
|
|
|
67
66
|
} catch {
|
|
68
67
|
return;
|
|
69
68
|
}
|
|
70
|
-
if (
|
|
69
|
+
if (!isRecord(record)) {
|
|
71
70
|
return;
|
|
72
71
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
progress.customTitle = cleanTitle(entry.customTitle) ?? progress.customTitle;
|
|
72
|
+
if (record.type === 'ai-title') {
|
|
73
|
+
progress.aiTitle = cleanTitle(record.aiTitle) ?? progress.aiTitle;
|
|
74
|
+
} else if (record.type === 'custom-title') {
|
|
75
|
+
progress.customTitle = cleanTitle(record.customTitle) ?? progress.customTitle;
|
|
78
76
|
}
|
|
79
77
|
}
|
|
80
|
-
|
|
81
|
-
/* The folder a transcript sits in is named after the working directory, so the file name is the only key a session id gives. */
|
|
82
|
-
private async locate(agentSessionId: string, projectsDir: string): Promise<string | null> {
|
|
83
|
-
const known = this.found.get(agentSessionId);
|
|
84
|
-
if (known !== undefined && existsSync(known)) {
|
|
85
|
-
return known;
|
|
86
|
-
}
|
|
87
|
-
if (projectsDir === '' || agentSessionId.includes('/')) {
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
let dirs: string[];
|
|
91
|
-
try {
|
|
92
|
-
dirs = await readdir(projectsDir);
|
|
93
|
-
} catch {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
const file = `${agentSessionId}.jsonl`;
|
|
97
|
-
for (const dir of dirs) {
|
|
98
|
-
const path = join(projectsDir, dir, file);
|
|
99
|
-
if (existsSync(path)) {
|
|
100
|
-
this.found.set(agentSessionId, path);
|
|
101
|
-
return path;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
78
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
import type { ChatHistoryResult, ChatItem } from '@adecore/agent-contracts';
|
|
5
|
+
import { isRecord } from '@adecore/agent-contracts';
|
|
6
|
+
import { fileExists } from '../fs.ts';
|
|
5
7
|
import { readLines } from '../title-file.ts';
|
|
6
8
|
import { ClaudeProtocol } from './claude-protocol.ts';
|
|
7
9
|
import { readingThread, settledReading } from './subagent-projection.ts';
|
|
@@ -10,10 +12,6 @@ import type { ChatThread } from './thread.ts';
|
|
|
10
12
|
|
|
11
13
|
const CHUNK_BYTES = 4 * 1024 * 1024;
|
|
12
14
|
|
|
13
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
14
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
15
|
function str(value: unknown): string | null {
|
|
18
16
|
return typeof value === 'string' && value !== '' ? value : null;
|
|
19
17
|
}
|
|
@@ -45,32 +43,54 @@ export function claudeProjectSlug(cwd: string): string {
|
|
|
45
43
|
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
46
44
|
}
|
|
47
45
|
|
|
46
|
+
// Where each session was found last, by projects folder and session id; a path whose file went is searched for again.
|
|
47
|
+
const sessionPaths = new Map<string, string>();
|
|
48
|
+
|
|
48
49
|
/*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* session
|
|
50
|
+
* The transcript Claude Code keeps of a session, found by its id: in the folder of `cwd` first, then
|
|
51
|
+
* where it was found before, then in every project folder, since the id is unique across all of them
|
|
52
|
+
* and a slug the CLI wrote differently (or a session resumed from elsewhere) sits in another folder.
|
|
53
|
+
* Null when no folder holds it, and for an id that would reach outside the projects folder.
|
|
52
54
|
*/
|
|
53
|
-
export async function
|
|
54
|
-
if (projectsDir === '' || sessionId
|
|
55
|
+
export async function findClaudeSession(projectsDir: string, sessionId: string, cwd?: string): Promise<string | null> {
|
|
56
|
+
if (projectsDir === '' || sessionId === '' || /[/\\]|\.\./.test(sessionId)) {
|
|
55
57
|
return null;
|
|
56
58
|
}
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
+
const file = `${sessionId}.jsonl`;
|
|
60
|
+
const key = `${projectsDir}\n${sessionId}`;
|
|
61
|
+
const own = cwd === undefined ? null : join(projectsDir, claudeProjectSlug(cwd), file);
|
|
62
|
+
if (own !== null && (await fileExists(own))) {
|
|
63
|
+
sessionPaths.set(key, own);
|
|
59
64
|
return own;
|
|
60
65
|
}
|
|
66
|
+
const known = sessionPaths.get(key);
|
|
67
|
+
if (known !== undefined && known !== own && (await fileExists(known))) {
|
|
68
|
+
return known;
|
|
69
|
+
}
|
|
70
|
+
sessionPaths.delete(key);
|
|
61
71
|
let dirs: string[];
|
|
62
72
|
try {
|
|
63
73
|
dirs = await readdir(projectsDir);
|
|
64
74
|
} catch {
|
|
65
75
|
return null;
|
|
66
76
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
const candidates = dirs.map((dir) => join(projectsDir, dir, file)).filter((candidate) => candidate !== own);
|
|
78
|
+
const present = await Promise.all(candidates.map(fileExists));
|
|
79
|
+
const found = candidates.find((_candidate, i) => present[i]) ?? null;
|
|
80
|
+
if (found !== null) {
|
|
81
|
+
sessionPaths.set(key, found);
|
|
72
82
|
}
|
|
73
|
-
return
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* Where the subagents of a session keep their transcripts: the folder beside its own transcript, once the CLI made it. */
|
|
87
|
+
export async function findSubagentsDir(projectsDir: string, cwd: string, sessionId: string): Promise<string | null> {
|
|
88
|
+
const transcript = await findClaudeSession(projectsDir, sessionId, cwd);
|
|
89
|
+
if (transcript === null) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const dir = join(dirname(transcript), sessionId, 'subagents');
|
|
93
|
+
return (await fileExists(dir)) ? dir : null;
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
/* Every meta file in a subagents folder that says which agent it is about; one that does not parse is left out. */
|