@agentclientprotocol/claude-agent-acp 0.65.0 → 0.67.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 +1 -0
- package/dist/acp-agent.d.ts +27 -0
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +606 -69
- package/dist/goal-extension.d.ts +36 -0
- package/dist/goal-extension.d.ts.map +1 -0
- package/dist/goal-extension.js +50 -0
- package/dist/tools.d.ts +4 -6
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +127 -15
- package/package.json +9 -8
package/dist/acp-agent.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { agent as acpAgent, methods, ndJsonStream, RequestError, } from "@agentclientprotocol/sdk";
|
|
2
2
|
import { deleteSession, getSessionInfo, getSessionMessages, listSessions, query, } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFromPrompt, parseGoalRequest, toGoalSnapshot, } from "./goal-extension.js";
|
|
3
4
|
import { execFile } from "node:child_process";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
5
7
|
import * as fs from "node:fs/promises";
|
|
6
8
|
import * as os from "node:os";
|
|
7
9
|
import * as path from "node:path";
|
|
@@ -9,7 +11,7 @@ import { promisify } from "node:util";
|
|
|
9
11
|
import packageJson from "../package.json" with { type: "json" };
|
|
10
12
|
import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
|
|
11
13
|
import { SettingsManager } from "./settings.js";
|
|
12
|
-
import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
14
|
+
import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
13
15
|
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
14
16
|
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
15
17
|
const execFileAsync = promisify(execFile);
|
|
@@ -99,11 +101,13 @@ function parseSteerRequest(params) {
|
|
|
99
101
|
* result is the turn's real terminal.
|
|
100
102
|
*
|
|
101
103
|
* Deliberately fail-OPEN: an unknown future kind defaults to the user
|
|
102
|
-
* lane
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* result
|
|
106
|
-
*
|
|
104
|
+
* lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
|
|
105
|
+
* attribute this" marker, which gets the same safe default. Misrouting a
|
|
106
|
+
* USER result into the autonomous lane hangs the prompt un-detectably
|
|
107
|
+
* (the result is skipped, its trailing idle absorbed as owed, so the
|
|
108
|
+
* #825 detector can't fire); misrouting an autonomous result into the
|
|
109
|
+
* user lane is the bounded misattribution class this set exists to
|
|
110
|
+
* reduce. */
|
|
107
111
|
const AUTONOMOUS_RESULT_ORIGINS = new Set([
|
|
108
112
|
"task-notification",
|
|
109
113
|
"peer",
|
|
@@ -154,9 +158,118 @@ const PROVIDER_ID = "main";
|
|
|
154
158
|
*/
|
|
155
159
|
const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
|
|
156
160
|
const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
|
|
161
|
+
// This is a protocol namespace, not user-facing branding: `jetbrains` owns the
|
|
162
|
+
// non-standard contract and `air` identifies the client defining its rendering
|
|
163
|
+
// semantics, without affecting unrelated JetBrains ACP clients.
|
|
164
|
+
const JETBRAINS_META_KEY = "jetbrains";
|
|
165
|
+
const AIR_META_KEY = "air";
|
|
166
|
+
const AIR_EXTENSION_VERSION_KEY = "version";
|
|
167
|
+
const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities";
|
|
168
|
+
const AIR_SESSION_FAILURE_KEY = "sessionFailure";
|
|
169
|
+
const AIR_EXTENSION_VERSION = 1;
|
|
157
170
|
function supportsSubagentTranscript(capabilities) {
|
|
158
171
|
return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
|
|
159
172
|
}
|
|
173
|
+
const AIR_FAILURE_PRESENTATION = {
|
|
174
|
+
// A non-fatal notice whose wording comes from the adapter or the SDK, so this message is only the
|
|
175
|
+
// fallback for an advisory published without one.
|
|
176
|
+
advisory: {
|
|
177
|
+
message: "Claude reported a notice.",
|
|
178
|
+
retryable: false,
|
|
179
|
+
actions: [],
|
|
180
|
+
},
|
|
181
|
+
auth_required: {
|
|
182
|
+
message: "Sign in to continue using Claude.",
|
|
183
|
+
retryable: false,
|
|
184
|
+
actions: ["login"],
|
|
185
|
+
},
|
|
186
|
+
bad_request: {
|
|
187
|
+
message: "Claude could not process this request.",
|
|
188
|
+
retryable: false,
|
|
189
|
+
actions: ["new_turn"],
|
|
190
|
+
},
|
|
191
|
+
budget_exhausted: {
|
|
192
|
+
message: "This Claude session reached its configured budget.",
|
|
193
|
+
retryable: false,
|
|
194
|
+
actions: ["new_session"],
|
|
195
|
+
},
|
|
196
|
+
context_exhausted: {
|
|
197
|
+
message: "This Claude turn reached its configured limit.",
|
|
198
|
+
retryable: false,
|
|
199
|
+
actions: ["new_turn"],
|
|
200
|
+
},
|
|
201
|
+
internal_error: {
|
|
202
|
+
message: "Claude Agent encountered an internal error.",
|
|
203
|
+
retryable: true,
|
|
204
|
+
actions: ["retry"],
|
|
205
|
+
},
|
|
206
|
+
overloaded: {
|
|
207
|
+
message: "Claude is temporarily overloaded.",
|
|
208
|
+
retryable: true,
|
|
209
|
+
actions: ["retry"],
|
|
210
|
+
},
|
|
211
|
+
provider_error: {
|
|
212
|
+
message: "The model provider reported an error.",
|
|
213
|
+
retryable: true,
|
|
214
|
+
actions: ["retry"],
|
|
215
|
+
},
|
|
216
|
+
quota_exhausted: {
|
|
217
|
+
message: "The Claude account has no available quota.",
|
|
218
|
+
retryable: false,
|
|
219
|
+
actions: ["new_session"],
|
|
220
|
+
},
|
|
221
|
+
rate_limited: {
|
|
222
|
+
message: "Claude is temporarily rate limited.",
|
|
223
|
+
retryable: true,
|
|
224
|
+
actions: ["retry"],
|
|
225
|
+
},
|
|
226
|
+
transport_lost: {
|
|
227
|
+
message: "The connection to Claude was lost.",
|
|
228
|
+
retryable: false,
|
|
229
|
+
actions: ["new_session"],
|
|
230
|
+
},
|
|
231
|
+
worker_shutdown: {
|
|
232
|
+
message: "The Claude worker is shutting down.",
|
|
233
|
+
retryable: false,
|
|
234
|
+
actions: ["new_session"],
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
function supportsAirSessionFailures(capabilities) {
|
|
238
|
+
const jetbrains = capabilities?._meta?.[JETBRAINS_META_KEY];
|
|
239
|
+
const air = jetbrains?.[AIR_META_KEY];
|
|
240
|
+
const version = air?.[AIR_EXTENSION_VERSION_KEY];
|
|
241
|
+
const advertised = air?.[AIR_EXTENSION_CAPABILITIES_KEY];
|
|
242
|
+
return (typeof version === "number" &&
|
|
243
|
+
Number.isFinite(version) &&
|
|
244
|
+
Number.isInteger(version) &&
|
|
245
|
+
version >= AIR_EXTENSION_VERSION &&
|
|
246
|
+
Array.isArray(advertised) &&
|
|
247
|
+
advertised.some((capability) => capability === AIR_SESSION_FAILURE_KEY));
|
|
248
|
+
}
|
|
249
|
+
function providerFailureCategory(errorKind) {
|
|
250
|
+
switch (errorKind) {
|
|
251
|
+
case "authentication_failed":
|
|
252
|
+
case "oauth_org_not_allowed":
|
|
253
|
+
return "auth_required";
|
|
254
|
+
case "billing_error":
|
|
255
|
+
return "quota_exhausted";
|
|
256
|
+
case "rate_limit":
|
|
257
|
+
return "rate_limited";
|
|
258
|
+
case "overloaded":
|
|
259
|
+
return "overloaded";
|
|
260
|
+
case "invalid_request":
|
|
261
|
+
case "model_not_found":
|
|
262
|
+
return "bad_request";
|
|
263
|
+
case "max_output_tokens":
|
|
264
|
+
return "context_exhausted";
|
|
265
|
+
case "server_error":
|
|
266
|
+
case "unknown":
|
|
267
|
+
case undefined:
|
|
268
|
+
return "provider_error";
|
|
269
|
+
default:
|
|
270
|
+
return "provider_error";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
160
273
|
function parentToolUseIdOf(message) {
|
|
161
274
|
if (!("parent_tool_use_id" in message))
|
|
162
275
|
return null;
|
|
@@ -733,9 +846,20 @@ export class ClaudeAcpAgent {
|
|
|
733
846
|
// steering extension contract: advertises the `_session/steering` request
|
|
734
847
|
// so clients know they may inject a follow-up into a running turn.
|
|
735
848
|
_meta: {
|
|
849
|
+
[JETBRAINS_META_KEY]: {
|
|
850
|
+
[AIR_META_KEY]: {
|
|
851
|
+
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
|
|
852
|
+
[AIR_EXTENSION_CAPABILITIES_KEY]: [AIR_SESSION_FAILURE_KEY],
|
|
853
|
+
},
|
|
854
|
+
},
|
|
736
855
|
steering: {
|
|
737
856
|
supported: true,
|
|
738
857
|
},
|
|
858
|
+
goal: {
|
|
859
|
+
version: GOAL_EXTENSION_VERSION,
|
|
860
|
+
controlMethod: GOAL_CONTROL_METHOD,
|
|
861
|
+
actions: [...GOAL_ACTIONS],
|
|
862
|
+
},
|
|
739
863
|
},
|
|
740
864
|
};
|
|
741
865
|
}
|
|
@@ -960,6 +1084,9 @@ export class ClaudeAcpAgent {
|
|
|
960
1084
|
if (session.queryClosed) {
|
|
961
1085
|
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
962
1086
|
}
|
|
1087
|
+
if (Array.from(session.taskState.values()).some((task) => task.status !== "completed")) {
|
|
1088
|
+
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
1089
|
+
}
|
|
963
1090
|
const userMessage = promptToClaude(params);
|
|
964
1091
|
const promptUuid = randomUUID();
|
|
965
1092
|
userMessage.uuid = promptUuid;
|
|
@@ -986,8 +1113,73 @@ export class ClaudeAcpAgent {
|
|
|
986
1113
|
session.turnQueue.push(turn);
|
|
987
1114
|
session.input.push(userMessage);
|
|
988
1115
|
this.ensureConsumer(session, params.sessionId);
|
|
1116
|
+
await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid);
|
|
989
1117
|
return response;
|
|
990
1118
|
}
|
|
1119
|
+
async goal(params) {
|
|
1120
|
+
const command = params.action === "set" ? `/goal ${params.objective}` : "/goal clear";
|
|
1121
|
+
const prompt = [{ type: "text", text: command }];
|
|
1122
|
+
const steering = await this.steer({
|
|
1123
|
+
sessionId: params.sessionId,
|
|
1124
|
+
prompt,
|
|
1125
|
+
_meta: { steering: { idleBehavior: "promptRequired" } },
|
|
1126
|
+
});
|
|
1127
|
+
if (steering.outcome === "promptRequired") {
|
|
1128
|
+
await this.prompt({ sessionId: params.sessionId, prompt });
|
|
1129
|
+
}
|
|
1130
|
+
return {};
|
|
1131
|
+
}
|
|
1132
|
+
async publishGoal(sessionId, goal) {
|
|
1133
|
+
const session = this.sessions[sessionId];
|
|
1134
|
+
if (session) {
|
|
1135
|
+
session.lastPublishedGoal = goal;
|
|
1136
|
+
}
|
|
1137
|
+
await this.client.sessionUpdate({
|
|
1138
|
+
sessionId,
|
|
1139
|
+
update: {
|
|
1140
|
+
sessionUpdate: "session_info_update",
|
|
1141
|
+
_meta: { goal },
|
|
1142
|
+
},
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
async publishTaskPlan(sessionId, taskState) {
|
|
1146
|
+
await this.client.sessionUpdate({
|
|
1147
|
+
sessionId,
|
|
1148
|
+
update: {
|
|
1149
|
+
sessionUpdate: "plan",
|
|
1150
|
+
entries: taskStateToPlanEntries(taskState),
|
|
1151
|
+
},
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
|
|
1155
|
+
const goalUpdate = goalUpdateFromPrompt(prompt);
|
|
1156
|
+
if (goalUpdate !== undefined) {
|
|
1157
|
+
const session = this.sessions[sessionId];
|
|
1158
|
+
if (session) {
|
|
1159
|
+
session.pendingGoalUpdate = {
|
|
1160
|
+
commandUuid,
|
|
1161
|
+
expected: goalUpdate,
|
|
1162
|
+
previous: session.lastPublishedGoal,
|
|
1163
|
+
started: false,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
await this.publishGoal(sessionId, goalUpdate);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
async publishRuntimeGoal(sessionId, goal) {
|
|
1170
|
+
const session = this.sessions[sessionId];
|
|
1171
|
+
const pending = session?.pendingGoalUpdate;
|
|
1172
|
+
if (pending) {
|
|
1173
|
+
const matchesPending = pending.expected === null
|
|
1174
|
+
? goal === null
|
|
1175
|
+
: goal !== null && goal.objective === pending.expected.objective;
|
|
1176
|
+
if (!matchesPending) {
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
session.pendingGoalUpdate = undefined;
|
|
1180
|
+
}
|
|
1181
|
+
await this.publishGoal(sessionId, goal);
|
|
1182
|
+
}
|
|
991
1183
|
/** Steer the session per the ACP steering wire protocol: inject a follow-up
|
|
992
1184
|
* message into the turn that is currently running. If that turn already
|
|
993
1185
|
* settled, the established default starts a new detached turn; Hosts may opt
|
|
@@ -1068,6 +1260,8 @@ export class ClaudeAcpAgent {
|
|
|
1068
1260
|
turnInFlight.deferredSettle = undefined;
|
|
1069
1261
|
}
|
|
1070
1262
|
session.input.push(userMessage);
|
|
1263
|
+
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
|
1264
|
+
await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
|
|
1071
1265
|
return { outcome: "injected" };
|
|
1072
1266
|
}
|
|
1073
1267
|
/** Lazily start the per-session consumer that drains the SDK query stream for
|
|
@@ -1157,6 +1351,115 @@ export class ClaudeAcpAgent {
|
|
|
1157
1351
|
}
|
|
1158
1352
|
await this.client.sessionUpdate(notification);
|
|
1159
1353
|
};
|
|
1354
|
+
const isAdvisory = (failure) => failure.severity === "warning";
|
|
1355
|
+
const failureRevisions = new Map();
|
|
1356
|
+
const sessionFailureEpoch = randomUUID();
|
|
1357
|
+
const activeSessionFailures = new Map();
|
|
1358
|
+
let pendingWorkerShutdown = false;
|
|
1359
|
+
const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
|
|
1360
|
+
const sessionFailureMeta = (failure, phase) => {
|
|
1361
|
+
const presentation = AIR_FAILURE_PRESENTATION[failure.category];
|
|
1362
|
+
return {
|
|
1363
|
+
[JETBRAINS_META_KEY]: {
|
|
1364
|
+
[AIR_META_KEY]: {
|
|
1365
|
+
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
|
|
1366
|
+
[AIR_SESSION_FAILURE_KEY]: {
|
|
1367
|
+
id: failure.id,
|
|
1368
|
+
revision: failure.revision,
|
|
1369
|
+
phase,
|
|
1370
|
+
category: failure.category,
|
|
1371
|
+
source: "claude",
|
|
1372
|
+
safeMessage: failure.safeMessage ?? presentation.message,
|
|
1373
|
+
retryable: presentation.retryable,
|
|
1374
|
+
actions: presentation.actions,
|
|
1375
|
+
...(failure.turnId ? { turnId: failure.turnId } : {}),
|
|
1376
|
+
...(failure.severity ? { severity: failure.severity } : {}),
|
|
1377
|
+
},
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
};
|
|
1381
|
+
};
|
|
1382
|
+
const emitSessionFailure = async (failure, phase) => {
|
|
1383
|
+
if (!isCurrentConsumer()) {
|
|
1384
|
+
this.logger.error(`Session ${params.sessionId}: ignored AIR session failure from a stale query consumer`);
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
try {
|
|
1388
|
+
await sendUpdate({
|
|
1389
|
+
sessionId: params.sessionId,
|
|
1390
|
+
update: {
|
|
1391
|
+
sessionUpdate: "session_info_update",
|
|
1392
|
+
_meta: sessionFailureMeta(failure, phase),
|
|
1393
|
+
},
|
|
1394
|
+
});
|
|
1395
|
+
return true;
|
|
1396
|
+
}
|
|
1397
|
+
catch (error) {
|
|
1398
|
+
this.logger.error(`Session ${params.sessionId}: failed to publish AIR session failure: ${error}`);
|
|
1399
|
+
return false;
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
const clearSessionFailure = async (shouldClear = () => true) => {
|
|
1403
|
+
if (!supportsAirSessionFailures(this.clientCapabilities))
|
|
1404
|
+
return;
|
|
1405
|
+
for (const failure of [...activeSessionFailures.values()]) {
|
|
1406
|
+
if (!shouldClear(failure))
|
|
1407
|
+
continue;
|
|
1408
|
+
const cleared = {
|
|
1409
|
+
...failure,
|
|
1410
|
+
revision: failure.revision + 1,
|
|
1411
|
+
};
|
|
1412
|
+
if (await emitSessionFailure(cleared, "cleared")) {
|
|
1413
|
+
failureRevisions.set(cleared.id, cleared.revision);
|
|
1414
|
+
activeSessionFailures.delete(cleared.id);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
const createSessionFailure = async (category, options = {}) => {
|
|
1419
|
+
if (!supportsAirSessionFailures(this.clientCapabilities) || !isCurrentConsumer()) {
|
|
1420
|
+
return undefined;
|
|
1421
|
+
}
|
|
1422
|
+
// An advisory is not a failure of the turn: it keeps its own id namespace and its own slot, so
|
|
1423
|
+
// it neither clears an actionable error nor is cleared by one. Only the newest advisory
|
|
1424
|
+
// matters, so they all share one id and bump its revision.
|
|
1425
|
+
if (category === "advisory") {
|
|
1426
|
+
const id = `${params.sessionId}:notice:${sessionFailureEpoch}`;
|
|
1427
|
+
return {
|
|
1428
|
+
id,
|
|
1429
|
+
revision: (failureRevisions.get(id) ?? 0) + 1,
|
|
1430
|
+
category,
|
|
1431
|
+
severity: "warning",
|
|
1432
|
+
...(options.safeMessage ? { safeMessage: options.safeMessage } : {}),
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
|
1436
|
+
const id = turnId
|
|
1437
|
+
? `${turnId}:error`
|
|
1438
|
+
: `${params.sessionId}:session-error:${sessionFailureEpoch}`;
|
|
1439
|
+
await clearSessionFailure((failure) => failure.id !== id && !isAdvisory(failure));
|
|
1440
|
+
return {
|
|
1441
|
+
id,
|
|
1442
|
+
revision: (failureRevisions.get(id) ?? 0) + 1,
|
|
1443
|
+
category,
|
|
1444
|
+
...(turnId ? { turnId } : {}),
|
|
1445
|
+
};
|
|
1446
|
+
};
|
|
1447
|
+
const publishSessionFailure = async (category, options = {}) => {
|
|
1448
|
+
const failure = await createSessionFailure(category, options);
|
|
1449
|
+
if (!failure)
|
|
1450
|
+
return;
|
|
1451
|
+
if (await emitSessionFailure(failure, "active")) {
|
|
1452
|
+
failureRevisions.set(failure.id, failure.revision);
|
|
1453
|
+
activeSessionFailures.set(failure.id, failure);
|
|
1454
|
+
}
|
|
1455
|
+
};
|
|
1456
|
+
const clearFailuresFromEarlierTurns = async () => {
|
|
1457
|
+
const activeTurnId = session.activeTurn?.promptUuid;
|
|
1458
|
+
// Advisories carry no turnId, so without the guard every turn boundary would sweep them away.
|
|
1459
|
+
// They are session-scoped and stay until superseded or dismissed by the user.
|
|
1460
|
+
await clearSessionFailure((failure) => failure.turnId !== activeTurnId && !isAdvisory(failure));
|
|
1461
|
+
};
|
|
1462
|
+
const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
|
|
1160
1463
|
const resetTurnScratch = () => {
|
|
1161
1464
|
lastAssistantTotalUsage = null;
|
|
1162
1465
|
lastAssistantUsage = null;
|
|
@@ -1440,6 +1743,7 @@ export class ClaudeAcpAgent {
|
|
|
1440
1743
|
disarmForceCancel(session);
|
|
1441
1744
|
const turn = session.activeTurn;
|
|
1442
1745
|
if (!turn || turn.settled) {
|
|
1746
|
+
this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
|
|
1443
1747
|
return;
|
|
1444
1748
|
}
|
|
1445
1749
|
turn.settled = true;
|
|
@@ -1453,6 +1757,32 @@ export class ClaudeAcpAgent {
|
|
|
1453
1757
|
session.emittedAssistantText = false;
|
|
1454
1758
|
turn.reject(error);
|
|
1455
1759
|
};
|
|
1760
|
+
/** Complete a negotiated terminal failure on the prompt response itself,
|
|
1761
|
+
* which is the canonical AIR carrier. Legacy clients keep the historical
|
|
1762
|
+
* JSON-RPC rejection path. */
|
|
1763
|
+
const failActiveWithSessionFailure = async (category, error) => {
|
|
1764
|
+
if (!supportsAirSessionFailures(this.clientCapabilities)) {
|
|
1765
|
+
failActive(error);
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
if (!session.activeTurn || session.activeTurn.settled) {
|
|
1769
|
+
this.logger.error(`Session ${params.sessionId}: cannot attach ${category} to a prompt response because no active turn exists; publishing a session-scoped failure`);
|
|
1770
|
+
await publishSessionFailure(category, { turnScoped: false });
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const failure = await createSessionFailure(category);
|
|
1774
|
+
if (!failure) {
|
|
1775
|
+
failActive(error);
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
failureRevisions.set(failure.id, failure.revision);
|
|
1779
|
+
activeSessionFailures.set(failure.id, failure);
|
|
1780
|
+
settleActive({
|
|
1781
|
+
stopReason: "end_turn",
|
|
1782
|
+
usage: sessionUsage(session),
|
|
1783
|
+
_meta: sessionFailureMeta(failure, "active"),
|
|
1784
|
+
});
|
|
1785
|
+
};
|
|
1456
1786
|
/** Reject every in-flight turn — used when the stream dies. */
|
|
1457
1787
|
const failAllTurns = (error) => {
|
|
1458
1788
|
disarmForceCancel(session);
|
|
@@ -1570,6 +1900,24 @@ export class ClaudeAcpAgent {
|
|
|
1570
1900
|
pendingNext = null;
|
|
1571
1901
|
const { value: message, done } = raced.result;
|
|
1572
1902
|
if (done || !message) {
|
|
1903
|
+
if (pendingWorkerShutdown) {
|
|
1904
|
+
pendingWorkerShutdown = false;
|
|
1905
|
+
if (session.activeTurn) {
|
|
1906
|
+
if (!isHeldOpen(session.activeTurn)) {
|
|
1907
|
+
await failActiveWithSessionFailure("worker_shutdown", internalErrorForClient({ errorKind: "worker_shutdown" }));
|
|
1908
|
+
}
|
|
1909
|
+
else {
|
|
1910
|
+
// The held turn already has its authoritative terminal outcome,
|
|
1911
|
+
// but EOF permanently closes the non-revivable Query. Preserve
|
|
1912
|
+
// the turn result below and report the independent session-health
|
|
1913
|
+
// failure without a turnId so AIR can offer a new session.
|
|
1914
|
+
await publishSessionFailure("worker_shutdown", { turnScoped: false });
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
else {
|
|
1918
|
+
await publishSessionFailure("worker_shutdown", { turnScoped: false });
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1573
1921
|
// The stream ended. Settle the in-flight turns FIRST, then release the
|
|
1574
1922
|
// stream resources — same order as the error paths (failAllTurns before
|
|
1575
1923
|
// closeQueryStream). Settling is the user-facing contract; resource
|
|
@@ -1698,6 +2046,14 @@ export class ClaudeAcpAgent {
|
|
|
1698
2046
|
}
|
|
1699
2047
|
continue;
|
|
1700
2048
|
}
|
|
2049
|
+
// `active_goal` is emitted by the Claude runtime but is not currently
|
|
2050
|
+
// included in the public SDKMessage union. Handle it before the
|
|
2051
|
+
// exhaustive switch and publish only the provider-neutral ACP shape.
|
|
2052
|
+
if (message.type === "active_goal") {
|
|
2053
|
+
const activeGoal = message;
|
|
2054
|
+
await this.publishRuntimeGoal(params.sessionId, toGoalSnapshot(activeGoal));
|
|
2055
|
+
continue;
|
|
2056
|
+
}
|
|
1701
2057
|
switch (message.type) {
|
|
1702
2058
|
case "system":
|
|
1703
2059
|
switch (message.subtype) {
|
|
@@ -1713,6 +2069,23 @@ export class ClaudeAcpAgent {
|
|
|
1713
2069
|
// updated Fast mode state; reconcile it with what we seeded at
|
|
1714
2070
|
// session creation.
|
|
1715
2071
|
await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
|
|
2072
|
+
// Terminal-bound slash commands (absent when none, and on
|
|
2073
|
+
// older CLIs). The session/new advertisement runs before any
|
|
2074
|
+
// init frame can be observed, so the first latch (or a
|
|
2075
|
+
// genuine change) re-publishes the now-filtered list.
|
|
2076
|
+
if (message.terminal_slash_commands &&
|
|
2077
|
+
JSON.stringify(message.terminal_slash_commands) !==
|
|
2078
|
+
JSON.stringify(session.terminalSlashCommands)) {
|
|
2079
|
+
session.terminalSlashCommands = message.terminal_slash_commands;
|
|
2080
|
+
try {
|
|
2081
|
+
await this.sendAvailableCommandsUpdate(message.session_id);
|
|
2082
|
+
}
|
|
2083
|
+
catch (error) {
|
|
2084
|
+
// Advisory reconcile only — the client keeps its current
|
|
2085
|
+
// (unfiltered) list; never fail the turn over it.
|
|
2086
|
+
this.logger.error(`Failed to re-advertise slash commands: ${error}`);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
1716
2089
|
break;
|
|
1717
2090
|
case "status": {
|
|
1718
2091
|
// These banners count as delivered text (via sendUpdate), so
|
|
@@ -1905,7 +2278,7 @@ export class ClaudeAcpAgent {
|
|
|
1905
2278
|
// the next prompt; only a timer could tell those apart.
|
|
1906
2279
|
this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
|
|
1907
2280
|
`for the active turn; failing the in-flight prompt (issue #825)`);
|
|
1908
|
-
|
|
2281
|
+
await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
|
|
1909
2282
|
}
|
|
1910
2283
|
// The SDK generates the session title in a background task and
|
|
1911
2284
|
// persists it to the session file; `idle` is the turn-over
|
|
@@ -1963,7 +2336,7 @@ export class ClaudeAcpAgent {
|
|
|
1963
2336
|
sessionId: message.session_id,
|
|
1964
2337
|
update: {
|
|
1965
2338
|
sessionUpdate: "available_commands_update",
|
|
1966
|
-
availableCommands: getAvailableSlashCommands(message.commands),
|
|
2339
|
+
availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
|
|
1967
2340
|
},
|
|
1968
2341
|
});
|
|
1969
2342
|
break;
|
|
@@ -2105,9 +2478,14 @@ export class ClaudeAcpAgent {
|
|
|
2105
2478
|
}
|
|
2106
2479
|
break;
|
|
2107
2480
|
case "worker_shutting_down":
|
|
2108
|
-
//
|
|
2109
|
-
//
|
|
2110
|
-
//
|
|
2481
|
+
// Defer until stream end. The announcement is durable and may be
|
|
2482
|
+
// replayed before later frames, but those frames do not prove that
|
|
2483
|
+
// a new worker epoch began: they can be buffered output from the
|
|
2484
|
+
// shutting-down worker. Keep the signal armed until the transport
|
|
2485
|
+
// actually ends. Deliberately do not add a quiet-period timer: the
|
|
2486
|
+
// iterator has no replay/live boundary, so a timeout could publish
|
|
2487
|
+
// while a slow replay is still in flight.
|
|
2488
|
+
pendingWorkerShutdown = true;
|
|
2111
2489
|
break;
|
|
2112
2490
|
case "elicitation_complete": {
|
|
2113
2491
|
// A url-mode MCP elicitation finished server-side. Let the client
|
|
@@ -2144,7 +2522,15 @@ export class ClaudeAcpAgent {
|
|
|
2144
2522
|
// CLIs, where "revert" marked a turn-only fallback — for that
|
|
2145
2523
|
// direction the session stays on the original model, so skip
|
|
2146
2524
|
// the persistent-swap claim and the state sync.
|
|
2147
|
-
|
|
2525
|
+
//
|
|
2526
|
+
// `scope` (CLI 2.1.232+) marks WHERE the fallback happened:
|
|
2527
|
+
// "local" means a subagent / side-question / background fork
|
|
2528
|
+
// response fell back and the session model is unchanged, so
|
|
2529
|
+
// syncing the picker would advertise a model the session
|
|
2530
|
+
// isn't running. Absent scope means an older CLI, where every
|
|
2531
|
+
// retry was a session-level swap — treat as "session".
|
|
2532
|
+
const local = message.scope === "local";
|
|
2533
|
+
const persistent = message.direction !== "revert" && !local;
|
|
2148
2534
|
const category = message.api_refusal_category
|
|
2149
2535
|
? ` (${message.api_refusal_category})`
|
|
2150
2536
|
: "";
|
|
@@ -2153,17 +2539,29 @@ export class ClaudeAcpAgent {
|
|
|
2153
2539
|
: "";
|
|
2154
2540
|
const outcome = persistent
|
|
2155
2541
|
? `The session will continue on ${message.fallback_model}.`
|
|
2156
|
-
:
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2542
|
+
: local
|
|
2543
|
+
? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
|
|
2544
|
+
: `The session stays on ${message.original_model}.`;
|
|
2545
|
+
const fallbackNotice = `${message.original_model} declined this request${category}; ` +
|
|
2546
|
+
`retried with ${message.fallback_model}. ${outcome}${explanation}`;
|
|
2547
|
+
// A silent model swap is a session-level advisory, not something the model said.
|
|
2548
|
+
// Clients that negotiated typed records get it as one; the rest keep the bold-label
|
|
2549
|
+
// transcript line, which was the only way to flag it before.
|
|
2550
|
+
if (supportsAirSessionFailures(this.clientCapabilities)) {
|
|
2551
|
+
await publishSessionFailure("advisory", { safeMessage: fallbackNotice });
|
|
2552
|
+
}
|
|
2553
|
+
else {
|
|
2554
|
+
await sendUpdate({
|
|
2555
|
+
sessionId: message.session_id,
|
|
2556
|
+
update: {
|
|
2557
|
+
sessionUpdate: "agent_message_chunk",
|
|
2558
|
+
content: {
|
|
2559
|
+
type: "text",
|
|
2560
|
+
text: `**Model fallback:** ${fallbackNotice}`,
|
|
2561
|
+
},
|
|
2164
2562
|
},
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2167
2565
|
if (persistent) {
|
|
2168
2566
|
await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
|
|
2169
2567
|
}
|
|
@@ -2268,6 +2666,20 @@ export class ClaudeAcpAgent {
|
|
|
2268
2666
|
if (!isAutonomousResult) {
|
|
2269
2667
|
recordResultForOrphanCommands();
|
|
2270
2668
|
ensureActiveTurn();
|
|
2669
|
+
// Once the submitted goal command has produced its own result,
|
|
2670
|
+
// no older runtime update can still precede it in the ordered
|
|
2671
|
+
// SDK stream. Stop suppressing updates even when this runtime
|
|
2672
|
+
// omitted the matching active_goal notification entirely.
|
|
2673
|
+
if (session.pendingGoalUpdate?.started) {
|
|
2674
|
+
const pendingGoalUpdate = session.pendingGoalUpdate;
|
|
2675
|
+
session.pendingGoalUpdate = undefined;
|
|
2676
|
+
const goalCommandFailed = message.is_error ||
|
|
2677
|
+
message.stop_reason === "refusal" ||
|
|
2678
|
+
("result" in message && message.result.includes("Please run /login"));
|
|
2679
|
+
if (goalCommandFailed) {
|
|
2680
|
+
await this.publishGoal(params.sessionId, pendingGoalUpdate.previous ?? null);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2271
2683
|
}
|
|
2272
2684
|
// A result closes the stretch of output it terminates: snapshot
|
|
2273
2685
|
// the delivery record — AFTER ensureActiveTurn, whose held-turn
|
|
@@ -2387,6 +2799,7 @@ export class ClaudeAcpAgent {
|
|
|
2387
2799
|
}
|
|
2388
2800
|
if (session.cancelled) {
|
|
2389
2801
|
if (!isAutonomousResult) {
|
|
2802
|
+
await clearFailuresFromEarlierTurns();
|
|
2390
2803
|
stopReason = "cancelled";
|
|
2391
2804
|
}
|
|
2392
2805
|
break;
|
|
@@ -2422,6 +2835,7 @@ export class ClaudeAcpAgent {
|
|
|
2422
2835
|
}
|
|
2423
2836
|
break;
|
|
2424
2837
|
}
|
|
2838
|
+
await clearFailuresFromEarlierTurns();
|
|
2425
2839
|
// A refusal can arrive on any result subtype (and may even set
|
|
2426
2840
|
// is_error), so handle it before the subtype switch — otherwise the
|
|
2427
2841
|
// is_error throw below would surface it as an internal error. The
|
|
@@ -2447,10 +2861,24 @@ export class ClaudeAcpAgent {
|
|
|
2447
2861
|
settleOrDefer({ stopReason: "refusal", usage: sessionUsage(session) });
|
|
2448
2862
|
break;
|
|
2449
2863
|
}
|
|
2864
|
+
// A priority:'now' steer can make the SDK terminate the
|
|
2865
|
+
// interrupted cycle with an error-shaped diagnostic result
|
|
2866
|
+
// before it replays the injected message's echo. That result is
|
|
2867
|
+
// not the steered command's outcome: keep it on the steer lane
|
|
2868
|
+
// and let the cycle after the pending echo decide the turn.
|
|
2869
|
+
if (message.is_error &&
|
|
2870
|
+
isSteering(session.activeTurn) &&
|
|
2871
|
+
session.activeTurn.steeredEchoes.size > 0) {
|
|
2872
|
+
settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
|
|
2873
|
+
break;
|
|
2874
|
+
}
|
|
2875
|
+
if (!message.is_error) {
|
|
2876
|
+
await clearSessionFailure();
|
|
2877
|
+
}
|
|
2450
2878
|
switch (message.subtype) {
|
|
2451
2879
|
case "success": {
|
|
2452
2880
|
if (message.result.includes("Please run /login")) {
|
|
2453
|
-
|
|
2881
|
+
await failActiveWithSessionFailure("auth_required", RequestError.authRequired());
|
|
2454
2882
|
break;
|
|
2455
2883
|
}
|
|
2456
2884
|
if (message.stop_reason === "max_tokens") {
|
|
@@ -2458,7 +2886,7 @@ export class ClaudeAcpAgent {
|
|
|
2458
2886
|
break;
|
|
2459
2887
|
}
|
|
2460
2888
|
if (message.is_error) {
|
|
2461
|
-
|
|
2889
|
+
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError), internalErrorForClient(errorKindData(lastAssistantError), message.result));
|
|
2462
2890
|
break;
|
|
2463
2891
|
}
|
|
2464
2892
|
// The result text is forwarded in two cases. Local-only
|
|
@@ -2494,17 +2922,29 @@ export class ClaudeAcpAgent {
|
|
|
2494
2922
|
break;
|
|
2495
2923
|
}
|
|
2496
2924
|
if (message.is_error) {
|
|
2497
|
-
|
|
2925
|
+
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
2498
2926
|
break;
|
|
2499
2927
|
}
|
|
2500
2928
|
stopReason = "end_turn";
|
|
2501
2929
|
break;
|
|
2502
2930
|
}
|
|
2503
2931
|
case "error_max_budget_usd":
|
|
2932
|
+
if (message.is_error) {
|
|
2933
|
+
await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
2934
|
+
break;
|
|
2935
|
+
}
|
|
2936
|
+
stopReason = "max_turn_requests";
|
|
2937
|
+
break;
|
|
2504
2938
|
case "error_max_turns":
|
|
2939
|
+
if (message.is_error) {
|
|
2940
|
+
await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
2941
|
+
break;
|
|
2942
|
+
}
|
|
2943
|
+
stopReason = "max_turn_requests";
|
|
2944
|
+
break;
|
|
2505
2945
|
case "error_max_structured_output_retries":
|
|
2506
2946
|
if (message.is_error) {
|
|
2507
|
-
|
|
2947
|
+
await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
2508
2948
|
break;
|
|
2509
2949
|
}
|
|
2510
2950
|
stopReason = "max_turn_requests";
|
|
@@ -2683,6 +3123,9 @@ export class ClaudeAcpAgent {
|
|
|
2683
3123
|
// is still promoted — activateTurn() clears the flag. The turn's own
|
|
2684
3124
|
// echo is then dropped from the feed (the client already shows it).
|
|
2685
3125
|
if (message.type === "user" && "uuid" in message && message.uuid) {
|
|
3126
|
+
if (session.pendingGoalUpdate?.commandUuid === message.uuid) {
|
|
3127
|
+
session.pendingGoalUpdate.started = true;
|
|
3128
|
+
}
|
|
2686
3129
|
const queued = findUnsettledTurn(message.uuid);
|
|
2687
3130
|
if (queued) {
|
|
2688
3131
|
// Only (re)activate if this isn't already the active turn — a
|
|
@@ -2833,7 +3276,7 @@ export class ClaudeAcpAgent {
|
|
|
2833
3276
|
break;
|
|
2834
3277
|
}
|
|
2835
3278
|
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
2836
|
-
|
|
3279
|
+
await failActiveWithSessionFailure("auth_required", RequestError.authRequired());
|
|
2837
3280
|
break;
|
|
2838
3281
|
}
|
|
2839
3282
|
let content;
|
|
@@ -2995,13 +3438,22 @@ export class ClaudeAcpAgent {
|
|
|
2995
3438
|
}
|
|
2996
3439
|
break;
|
|
2997
3440
|
}
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3441
|
+
case "conversation_reset": {
|
|
3442
|
+
// The SDK has switched to a fresh conversation, whose Task* IDs
|
|
3443
|
+
// and task store are independent of the previous transcript.
|
|
3444
|
+
// Clear both the in-memory snapshot and the client's visible plan
|
|
3445
|
+
// before any follow-up prompt can republish stale tasks.
|
|
3446
|
+
session.taskState.clear();
|
|
3447
|
+
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
3448
|
+
break;
|
|
3449
|
+
}
|
|
3001
3450
|
case "tool_use_summary":
|
|
3002
|
-
case "auth_status":
|
|
3003
3451
|
case "prompt_suggestion":
|
|
3004
|
-
|
|
3452
|
+
break;
|
|
3453
|
+
case "auth_status":
|
|
3454
|
+
if (!message.isAuthenticating && message.error === undefined) {
|
|
3455
|
+
await clearSessionFailure((failure) => failure.category === "auth_required");
|
|
3456
|
+
}
|
|
3005
3457
|
break;
|
|
3006
3458
|
default:
|
|
3007
3459
|
unreachable(message, this.logger);
|
|
@@ -3023,6 +3475,19 @@ export class ClaudeAcpAgent {
|
|
|
3023
3475
|
message.includes("process exited with") ||
|
|
3024
3476
|
message.includes("process terminated by signal") ||
|
|
3025
3477
|
message.includes("Failed to write to process stdin"));
|
|
3478
|
+
if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
|
|
3479
|
+
if (!isHeldOpen(session.activeTurn)) {
|
|
3480
|
+
await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
|
|
3481
|
+
}
|
|
3482
|
+
else {
|
|
3483
|
+
// The held turn keeps its recorded PromptResponse outcome, while the
|
|
3484
|
+
// exhausted Query makes the session independently unrecoverable.
|
|
3485
|
+
await publishSessionFailure("transport_lost", { turnScoped: false });
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
else {
|
|
3489
|
+
await publishSessionFailure("transport_lost", { turnScoped: false });
|
|
3490
|
+
}
|
|
3026
3491
|
// Either way the query iterator is finished and the consumer is exiting,
|
|
3027
3492
|
// so release its resources via closeQueryStream (idempotent). A process
|
|
3028
3493
|
// death is unrecoverable, so additionally evict the session so the client
|
|
@@ -3036,7 +3501,9 @@ export class ClaudeAcpAgent {
|
|
|
3036
3501
|
}
|
|
3037
3502
|
else {
|
|
3038
3503
|
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
|
|
3039
|
-
failAllTurns(
|
|
3504
|
+
failAllTurns(supportsAirSessionFailures(this.clientCapabilities)
|
|
3505
|
+
? internalErrorForClient({ errorKind: "transport_lost" })
|
|
3506
|
+
: error);
|
|
3040
3507
|
this.closeQueryStream(session);
|
|
3041
3508
|
}
|
|
3042
3509
|
}
|
|
@@ -3078,6 +3545,14 @@ export class ClaudeAcpAgent {
|
|
|
3078
3545
|
return;
|
|
3079
3546
|
}
|
|
3080
3547
|
session.cancelled = true;
|
|
3548
|
+
// A priority steer may still be queued in the SDK when cancellation
|
|
3549
|
+
// settles its owning turn. Its later echo matches no live turn, and its
|
|
3550
|
+
// result must be skipped rather than promoted onto the next prompt.
|
|
3551
|
+
if (isSteering(session.activeTurn)) {
|
|
3552
|
+
for (const uuid of session.activeTurn.steeredEchoes) {
|
|
3553
|
+
this.trackOrphanCommand(session, uuid, "pending");
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3081
3556
|
// Capture the orphan-accounting lane before anything can await: the
|
|
3082
3557
|
// consumer latches msgLifecycleV1 when it drains the first system/init,
|
|
3083
3558
|
// which can happen DURING the awaited interrupt() below — the receipt
|
|
@@ -3884,7 +4359,7 @@ export class ClaudeAcpAgent {
|
|
|
3884
4359
|
sessionId,
|
|
3885
4360
|
update: {
|
|
3886
4361
|
sessionUpdate: "available_commands_update",
|
|
3887
|
-
availableCommands: getAvailableSlashCommands(commands),
|
|
4362
|
+
availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
|
|
3888
4363
|
},
|
|
3889
4364
|
});
|
|
3890
4365
|
}
|
|
@@ -4398,15 +4873,7 @@ export class ClaudeAcpAgent {
|
|
|
4398
4873
|
hooks: [
|
|
4399
4874
|
createTaskHook({
|
|
4400
4875
|
taskState,
|
|
4401
|
-
onChange:
|
|
4402
|
-
await this.client.sessionUpdate({
|
|
4403
|
-
sessionId,
|
|
4404
|
-
update: {
|
|
4405
|
-
sessionUpdate: "plan",
|
|
4406
|
-
entries: taskStateToPlanEntries(taskState),
|
|
4407
|
-
},
|
|
4408
|
-
});
|
|
4409
|
-
},
|
|
4876
|
+
onChange: () => this.publishTaskPlan(sessionId, taskState),
|
|
4410
4877
|
}),
|
|
4411
4878
|
],
|
|
4412
4879
|
},
|
|
@@ -4417,15 +4884,7 @@ export class ClaudeAcpAgent {
|
|
|
4417
4884
|
hooks: [
|
|
4418
4885
|
createTaskHook({
|
|
4419
4886
|
taskState,
|
|
4420
|
-
onChange:
|
|
4421
|
-
await this.client.sessionUpdate({
|
|
4422
|
-
sessionId,
|
|
4423
|
-
update: {
|
|
4424
|
-
sessionUpdate: "plan",
|
|
4425
|
-
entries: taskStateToPlanEntries(taskState),
|
|
4426
|
-
},
|
|
4427
|
-
});
|
|
4428
|
-
},
|
|
4887
|
+
onChange: () => this.publishTaskPlan(sessionId, taskState),
|
|
4429
4888
|
}),
|
|
4430
4889
|
],
|
|
4431
4890
|
},
|
|
@@ -5005,11 +5464,21 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
|
|
|
5005
5464
|
category: "model",
|
|
5006
5465
|
type: "select",
|
|
5007
5466
|
currentValue: models.currentModelId,
|
|
5008
|
-
options: models.availableModels.map((m) =>
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5467
|
+
options: models.availableModels.map((m) => {
|
|
5468
|
+
if (m.modelId === "default") {
|
|
5469
|
+
const defaultInfo = modelInfos.find((mi) => mi.value === "default");
|
|
5470
|
+
const resolvedModel = defaultInfo?.resolvedModel;
|
|
5471
|
+
if (resolvedModel) {
|
|
5472
|
+
const namedMatch = modelInfos.find((mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel);
|
|
5473
|
+
return {
|
|
5474
|
+
value: m.modelId,
|
|
5475
|
+
name: m.name,
|
|
5476
|
+
description: namedMatch?.displayName ?? resolvedModel,
|
|
5477
|
+
};
|
|
5478
|
+
}
|
|
5479
|
+
}
|
|
5480
|
+
return { value: m.modelId, name: m.name, description: m.description ?? undefined };
|
|
5481
|
+
}),
|
|
5013
5482
|
},
|
|
5014
5483
|
];
|
|
5015
5484
|
// Add effort level option based on the currently selected model
|
|
@@ -5441,7 +5910,11 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
|
|
|
5441
5910
|
resumedContextWindow,
|
|
5442
5911
|
};
|
|
5443
5912
|
}
|
|
5444
|
-
function getAvailableSlashCommands(commands
|
|
5913
|
+
function getAvailableSlashCommands(commands,
|
|
5914
|
+
// Names the CLI tagged terminal-bound on `system`/init (their UX lives in
|
|
5915
|
+
// the CLI's own terminal, which ACP clients aren't) — filtered alongside
|
|
5916
|
+
// the static list. Raw CLI names, matched before the MCP rename.
|
|
5917
|
+
terminalCommands) {
|
|
5445
5918
|
const UNSUPPORTED_COMMANDS = [
|
|
5446
5919
|
"clear",
|
|
5447
5920
|
"cost",
|
|
@@ -5453,6 +5926,7 @@ function getAvailableSlashCommands(commands) {
|
|
|
5453
5926
|
"todos",
|
|
5454
5927
|
];
|
|
5455
5928
|
return commands
|
|
5929
|
+
.filter((command) => !terminalCommands?.includes(command.name))
|
|
5456
5930
|
.map((command) => {
|
|
5457
5931
|
const input = command.argumentHint
|
|
5458
5932
|
? {
|
|
@@ -5631,7 +6105,7 @@ function shouldEmitToolCall(toolName) {
|
|
|
5631
6105
|
* are kept out of ACP's standard `title`, which clients may use as the shell
|
|
5632
6106
|
* command preview, while still giving clients access to Claude's concise
|
|
5633
6107
|
* human-readable title. */
|
|
5634
|
-
function claudeCodeMetaFromToolUse(toolUse) {
|
|
6108
|
+
function claudeCodeMetaFromToolUse(toolUse, cwd) {
|
|
5635
6109
|
const description = toolUse.name === "Bash" &&
|
|
5636
6110
|
toolUse.input !== null &&
|
|
5637
6111
|
typeof toolUse.input === "object" &&
|
|
@@ -5639,12 +6113,54 @@ function claudeCodeMetaFromToolUse(toolUse) {
|
|
|
5639
6113
|
typeof toolUse.input.description === "string"
|
|
5640
6114
|
? toolUse.input.description
|
|
5641
6115
|
: undefined;
|
|
6116
|
+
const skillName = toolUse.name === "Skill"
|
|
6117
|
+
? toolUse.input?.skill
|
|
6118
|
+
: undefined;
|
|
6119
|
+
const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
|
|
5642
6120
|
return {
|
|
5643
6121
|
toolName: toolUse.name,
|
|
5644
6122
|
...(description ? { title: description } : {}),
|
|
5645
6123
|
...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
|
|
6124
|
+
...(skillName ? { skill: skillName } : {}),
|
|
6125
|
+
...(skillPath ? { skillPath } : {}),
|
|
5646
6126
|
};
|
|
5647
6127
|
}
|
|
6128
|
+
/** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
|
|
6129
|
+
const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
|
|
6130
|
+
/**
|
|
6131
|
+
* Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
|
|
6132
|
+
*
|
|
6133
|
+
* The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
|
|
6134
|
+
* actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
|
|
6135
|
+
* for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
|
|
6136
|
+
* directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
|
|
6137
|
+
* on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
|
|
6138
|
+
*/
|
|
6139
|
+
function resolveSkillPath(skillName, cwd) {
|
|
6140
|
+
if (!cwd) {
|
|
6141
|
+
return undefined;
|
|
6142
|
+
}
|
|
6143
|
+
const colon = skillName.indexOf(":");
|
|
6144
|
+
const scope = colon < 0 ? undefined : skillName.slice(0, colon);
|
|
6145
|
+
const name = colon < 0 ? skillName : skillName.slice(colon + 1);
|
|
6146
|
+
if (!name) {
|
|
6147
|
+
return undefined;
|
|
6148
|
+
}
|
|
6149
|
+
const candidates = [];
|
|
6150
|
+
const addCandidates = (base) => {
|
|
6151
|
+
for (const container of SKILL_CONTAINER_DIRS) {
|
|
6152
|
+
candidates.push(path.join(base, container, name, "SKILL.md"));
|
|
6153
|
+
}
|
|
6154
|
+
};
|
|
6155
|
+
if (scope) {
|
|
6156
|
+
// A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
|
|
6157
|
+
addCandidates(path.join(cwd, scope));
|
|
6158
|
+
candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
|
|
6159
|
+
}
|
|
6160
|
+
addCandidates(cwd);
|
|
6161
|
+
addCandidates(os.homedir());
|
|
6162
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
6163
|
+
}
|
|
5648
6164
|
/** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
|
|
5649
6165
|
* notification for a tool_use. Shared by every site that surfaces a tool call:
|
|
5650
6166
|
* the streamed tool_use path (first encounter → tool_call, later encounter →
|
|
@@ -5655,7 +6171,7 @@ function claudeCodeMetaFromToolUse(toolUse) {
|
|
|
5655
6171
|
function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
|
|
5656
6172
|
if (refine) {
|
|
5657
6173
|
return {
|
|
5658
|
-
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
|
|
6174
|
+
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) },
|
|
5659
6175
|
toolCallId: toolUse.id,
|
|
5660
6176
|
sessionUpdate: "tool_call_update",
|
|
5661
6177
|
rawInput,
|
|
@@ -5664,7 +6180,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
|
|
|
5664
6180
|
}
|
|
5665
6181
|
return {
|
|
5666
6182
|
_meta: {
|
|
5667
|
-
claudeCode: claudeCodeMetaFromToolUse(toolUse),
|
|
6183
|
+
claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
|
|
5668
6184
|
...(toolUse.name === "Bash" && supportsTerminalOutput
|
|
5669
6185
|
? { terminal_info: { terminal_id: toolUse.id } }
|
|
5670
6186
|
: {}),
|
|
@@ -5690,7 +6206,7 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
|
5690
6206
|
const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
|
|
5691
6207
|
return {
|
|
5692
6208
|
_meta: {
|
|
5693
|
-
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
|
|
6209
|
+
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
|
|
5694
6210
|
},
|
|
5695
6211
|
toolCallId: toolUse.id,
|
|
5696
6212
|
sessionUpdate: "tool_call_update",
|
|
@@ -5973,20 +6489,40 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
5973
6489
|
}
|
|
5974
6490
|
if (isTaskTool(toolUse.name)) {
|
|
5975
6491
|
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
|
|
5976
|
-
// TaskCreate / TaskUpdate mutate the accumulated task list
|
|
5977
|
-
//
|
|
5978
|
-
//
|
|
5979
|
-
//
|
|
6492
|
+
// TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
|
|
6493
|
+
// reconciles it from the SDK's authoritative snapshot, which repairs
|
|
6494
|
+
// resumed or compacted sessions whose creating calls are no longer in
|
|
6495
|
+
// replay history. TaskGet is read-only and remains suppressed. Plan
|
|
6496
|
+
// updates always carry the full accumulated snapshot, mirroring the
|
|
6497
|
+
// legacy TodoWrite behavior.
|
|
5980
6498
|
const isError = "is_error" in chunk && chunk.is_error;
|
|
6499
|
+
let shouldEmitTaskPlan = false;
|
|
5981
6500
|
if (!isError) {
|
|
5982
6501
|
if (toolUse.name === "TaskCreate") {
|
|
5983
|
-
applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
|
|
6502
|
+
applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content));
|
|
6503
|
+
shouldEmitTaskPlan = true;
|
|
5984
6504
|
}
|
|
5985
6505
|
else if (toolUse.name === "TaskUpdate") {
|
|
5986
|
-
|
|
6506
|
+
const input = toolUse.input;
|
|
6507
|
+
const output = parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
|
|
6508
|
+
parseTaskUpdateOutput(chunk.content, input?.taskId);
|
|
6509
|
+
// Older CLI transcripts have no structured output, so retain the
|
|
6510
|
+
// input-based fallback. When an output is available, only apply a
|
|
6511
|
+
// confirmed update for the same task.
|
|
6512
|
+
if (!output || (output.success && output.taskId === input?.taskId)) {
|
|
6513
|
+
applyTaskUpdate(taskState, input);
|
|
6514
|
+
shouldEmitTaskPlan = true;
|
|
6515
|
+
}
|
|
6516
|
+
}
|
|
6517
|
+
else if (toolUse.name === "TaskList") {
|
|
6518
|
+
const output = parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
|
|
6519
|
+
if (output) {
|
|
6520
|
+
applyTaskList(taskState, output);
|
|
6521
|
+
shouldEmitTaskPlan = true;
|
|
6522
|
+
}
|
|
5987
6523
|
}
|
|
5988
6524
|
}
|
|
5989
|
-
if (
|
|
6525
|
+
if (shouldEmitTaskPlan) {
|
|
5990
6526
|
update = {
|
|
5991
6527
|
sessionUpdate: "plan",
|
|
5992
6528
|
entries: taskStateToPlanEntries(taskState),
|
|
@@ -6235,6 +6771,7 @@ export function runAcp() {
|
|
|
6235
6771
|
.onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
|
|
6236
6772
|
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
|
|
6237
6773
|
.onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
|
|
6774
|
+
.onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
|
|
6238
6775
|
.connect(stream);
|
|
6239
6776
|
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
|
6240
6777
|
return { connection, agent };
|