@agentclientprotocol/claude-agent-acp 0.67.0 → 0.69.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 -21
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +359 -299
- package/dist/file-change-audit.d.ts +56 -0
- package/dist/file-change-audit.d.ts.map +1 -0
- package/dist/file-change-audit.js +379 -0
- package/dist/session-failure-extension.d.ts +104 -0
- package/dist/session-failure-extension.d.ts.map +1 -0
- package/dist/session-failure-extension.js +351 -0
- package/package.json +1 -1
package/dist/acp-agent.js
CHANGED
|
@@ -11,11 +11,14 @@ import { promisify } from "node:util";
|
|
|
11
11
|
import packageJson from "../package.json" with { type: "json" };
|
|
12
12
|
import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
|
|
13
13
|
import { SettingsManager } from "./settings.js";
|
|
14
|
+
import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
|
|
15
|
+
import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, agentFileChangeReportRequestId, containsFileChangeAuditMarker, createFileChangeAuditSupport, createFileChangeAuditTurnState, FILE_CHANGE_AUDIT_SERVER_NAME, isFileChangeAuditReportPhase, isFileChangeAuditTool, supportsAgentFileChangeReport, } from "./file-change-audit.js";
|
|
14
16
|
import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
15
17
|
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
16
18
|
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
17
19
|
const execFileAsync = promisify(execFile);
|
|
18
20
|
const MAX_TITLE_LENGTH = 256;
|
|
21
|
+
const MAX_INLINE_FAILURE_TITLE_LENGTH = 256;
|
|
19
22
|
function sanitizeTitle(text) {
|
|
20
23
|
// Replace newlines and collapse whitespace
|
|
21
24
|
const sanitized = text
|
|
@@ -146,130 +149,13 @@ function computeSessionFingerprint(params) {
|
|
|
146
149
|
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
|
147
150
|
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
|
148
151
|
}
|
|
149
|
-
/**
|
|
150
|
-
* The single provider ID this agent exposes via `providers/*`. Claude Code has
|
|
151
|
-
* one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
|
|
152
|
-
* is exactly one configurable provider.
|
|
153
|
-
*/
|
|
154
|
-
const PROVIDER_ID = "main";
|
|
155
|
-
/**
|
|
156
|
-
* Protocols the `main` provider can be configured with. These mirror the
|
|
157
|
-
* env-var mappings understood by {@link createEnvForProvider}.
|
|
158
|
-
*/
|
|
159
152
|
const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
|
|
153
|
+
const PROVIDER_ID = "main";
|
|
154
|
+
const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
160
155
|
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;
|
|
170
156
|
function supportsSubagentTranscript(capabilities) {
|
|
171
157
|
return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
|
|
172
158
|
}
|
|
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
|
-
}
|
|
273
159
|
function parentToolUseIdOf(message) {
|
|
274
160
|
if (!("parent_tool_use_id" in message))
|
|
275
161
|
return null;
|
|
@@ -690,9 +576,7 @@ export class ClaudeAcpAgent {
|
|
|
690
576
|
clientCapabilities;
|
|
691
577
|
logger;
|
|
692
578
|
gatewayAuthRequest;
|
|
693
|
-
/**
|
|
694
|
-
* never persisted to disk (see the Configurable LLM Providers RFD). When
|
|
695
|
-
* set, it takes precedence over {@link gatewayAuthRequest}. */
|
|
579
|
+
/** Set while ACP overrides the agent's native provider configuration. */
|
|
696
580
|
providerConfig;
|
|
697
581
|
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
|
698
582
|
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
@@ -846,12 +730,7 @@ export class ClaudeAcpAgent {
|
|
|
846
730
|
// steering extension contract: advertises the `_session/steering` request
|
|
847
731
|
// so clients know they may inject a follow-up into a running turn.
|
|
848
732
|
_meta: {
|
|
849
|
-
|
|
850
|
-
[AIR_META_KEY]: {
|
|
851
|
-
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
|
|
852
|
-
[AIR_EXTENSION_CAPABILITIES_KEY]: [AIR_SESSION_FAILURE_KEY],
|
|
853
|
-
},
|
|
854
|
-
},
|
|
733
|
+
...airSessionFailureCapabilityMeta(AGENT_FILE_CHANGE_REPORT_CAPABILITY),
|
|
855
734
|
steering: {
|
|
856
735
|
supported: true,
|
|
857
736
|
},
|
|
@@ -965,21 +844,13 @@ export class ClaudeAcpAgent {
|
|
|
965
844
|
}
|
|
966
845
|
throw new Error("Method not implemented.");
|
|
967
846
|
}
|
|
968
|
-
/**
|
|
969
|
-
* `providers/list` — returns the single client-configurable custom gateway
|
|
970
|
-
* provider (`main`). `current` carries only non-secret routing (never headers,
|
|
971
|
-
* which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
|
|
972
|
-
* display, and is `null` when the provider is not configured/disabled. The
|
|
973
|
-
* provider is optional (`required: false`): while disabled/unconfigured the
|
|
974
|
-
* agent falls back to its own default routing (normal Claude login).
|
|
975
|
-
*/
|
|
976
847
|
async unstable_listProviders(_params) {
|
|
977
|
-
const config = this.
|
|
848
|
+
const config = this.providerConfig ?? this.defaultProviderConfig();
|
|
978
849
|
const provider = {
|
|
979
850
|
providerId: PROVIDER_ID,
|
|
980
851
|
supported: SUPPORTED_PROTOCOLS,
|
|
981
852
|
required: false,
|
|
982
|
-
current:
|
|
853
|
+
current: { apiType: config.apiType, baseUrl: config.baseUrl },
|
|
983
854
|
};
|
|
984
855
|
return { providers: [provider] };
|
|
985
856
|
}
|
|
@@ -1021,30 +892,43 @@ export class ClaudeAcpAgent {
|
|
|
1021
892
|
return {};
|
|
1022
893
|
}
|
|
1023
894
|
/**
|
|
1024
|
-
* `providers/disable`
|
|
1025
|
-
*
|
|
1026
|
-
* so the agent reverts to its own default routing and `providers/list` reports
|
|
1027
|
-
* `current: null`. Disabling any other (unknown) ID is treated as a successful
|
|
1028
|
-
* no-op per the RFD's idempotency rule.
|
|
895
|
+
* `providers/disable` ends ACP ownership of the single mutually exclusive
|
|
896
|
+
* backend slot and restores the agent's native routing state.
|
|
1029
897
|
*/
|
|
1030
898
|
async unstable_disableProvider(params) {
|
|
1031
899
|
if (params.providerId === PROVIDER_ID) {
|
|
1032
900
|
this.providerConfig = undefined;
|
|
1033
|
-
this.gatewayAuthRequest = undefined;
|
|
1034
901
|
}
|
|
1035
902
|
// Unknown provider: idempotent success.
|
|
1036
903
|
return {};
|
|
1037
904
|
}
|
|
1038
|
-
/**
|
|
1039
|
-
* Resolve the effective client-managed routing config. `providers/set` takes
|
|
1040
|
-
* precedence; otherwise fall back to the legacy gateway auth request. Returns
|
|
1041
|
-
* `null` when neither is configured.
|
|
1042
|
-
*/
|
|
1043
905
|
resolveProviderConfig() {
|
|
1044
|
-
|
|
1045
|
-
|
|
906
|
+
return this.providerConfig ?? gatewayRequestToProviderConfig(this.gatewayAuthRequest);
|
|
907
|
+
}
|
|
908
|
+
defaultProviderConfig() {
|
|
909
|
+
const gatewayConfig = gatewayRequestToProviderConfig(this.gatewayAuthRequest);
|
|
910
|
+
if (gatewayConfig) {
|
|
911
|
+
return gatewayConfig;
|
|
1046
912
|
}
|
|
1047
|
-
|
|
913
|
+
if (process.env.CLAUDE_CODE_USE_BEDROCK) {
|
|
914
|
+
return {
|
|
915
|
+
apiType: "bedrock",
|
|
916
|
+
baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
|
|
917
|
+
headers: {},
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
if (process.env.CLAUDE_CODE_USE_VERTEX) {
|
|
921
|
+
return {
|
|
922
|
+
apiType: "vertex",
|
|
923
|
+
baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? "https://aiplatform.googleapis.com",
|
|
924
|
+
headers: {},
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
return {
|
|
928
|
+
apiType: "anthropic",
|
|
929
|
+
baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
|
|
930
|
+
headers: {},
|
|
931
|
+
};
|
|
1048
932
|
}
|
|
1049
933
|
async logout(_params) {
|
|
1050
934
|
// Clear in-memory gateway credentials supplied via `authenticate` and any
|
|
@@ -1094,6 +978,15 @@ export class ClaudeAcpAgent {
|
|
|
1094
978
|
// user message, so the consumer can't promote the turn from the echo.
|
|
1095
979
|
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
|
1096
980
|
const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
|
|
981
|
+
const fileChangeReportRequestId = supportsAgentFileChangeReport(this.clientCapabilities)
|
|
982
|
+
? agentFileChangeReportRequestId(params._meta)
|
|
983
|
+
: undefined;
|
|
984
|
+
let fileChangeAudit;
|
|
985
|
+
if (fileChangeReportRequestId &&
|
|
986
|
+
!session.fileChangeReportRequestIds.has(fileChangeReportRequestId)) {
|
|
987
|
+
session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
|
|
988
|
+
fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
|
|
989
|
+
}
|
|
1097
990
|
// Each prompt is a Turn whose deferred the persistent consumer settles once
|
|
1098
991
|
// the turn's outcome is known. `prompt()` owns no loop: it enqueues the
|
|
1099
992
|
// turn, pushes the user message onto the streaming input, makes sure the
|
|
@@ -1101,6 +994,7 @@ export class ClaudeAcpAgent {
|
|
|
1101
994
|
const turn = {
|
|
1102
995
|
promptUuid,
|
|
1103
996
|
isLocalOnlyCommand,
|
|
997
|
+
...(fileChangeAudit ? { fileChangeAudit } : {}),
|
|
1104
998
|
settled: false,
|
|
1105
999
|
resolve: () => { },
|
|
1106
1000
|
reject: () => { },
|
|
@@ -1264,6 +1158,15 @@ export class ClaudeAcpAgent {
|
|
|
1264
1158
|
await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
|
|
1265
1159
|
return { outcome: "injected" };
|
|
1266
1160
|
}
|
|
1161
|
+
/** Publish the audit terminal for every turn path that did not reach the
|
|
1162
|
+
* report tool. The support flips the turn state synchronously before its
|
|
1163
|
+
* transport await, so callers can stay fail-open and settle the ACP prompt
|
|
1164
|
+
* immediately without allowing a racing lifecycle path to publish twice. */
|
|
1165
|
+
finishFileChangeAudit(session, turn, reason) {
|
|
1166
|
+
if (!turn.fileChangeAudit || !session.fileChangeAuditSupport)
|
|
1167
|
+
return;
|
|
1168
|
+
void session.fileChangeAuditSupport.finishUnavailable(turn.fileChangeAudit, reason);
|
|
1169
|
+
}
|
|
1267
1170
|
/** Lazily start the per-session consumer that drains the SDK query stream for
|
|
1268
1171
|
* the session's whole life. Idempotent: only the first `prompt()` starts it. */
|
|
1269
1172
|
ensureConsumer(session, sessionId) {
|
|
@@ -1298,6 +1201,8 @@ export class ClaudeAcpAgent {
|
|
|
1298
1201
|
// it here so the subsequent `RequestError.internalError` can forward it to
|
|
1299
1202
|
// clients as structured `data`, sparing them from pattern-matching on text.
|
|
1300
1203
|
let lastAssistantError;
|
|
1204
|
+
let lastAssistantWasUsageLimit = false;
|
|
1205
|
+
let lastAssistantFailureTitle;
|
|
1301
1206
|
// When a streaming classifier refuses a turn, the assistant message carries
|
|
1302
1207
|
// stop_reason "refusal" and structured stop_details. We capture the
|
|
1303
1208
|
// human-readable explanation so the terminal `result` can surface it.
|
|
@@ -1343,6 +1248,14 @@ export class ClaudeAcpAgent {
|
|
|
1343
1248
|
* as the turn's answer. */
|
|
1344
1249
|
const sendUpdate = async (notification) => {
|
|
1345
1250
|
const { update } = notification;
|
|
1251
|
+
if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
|
|
1252
|
+
(update.sessionUpdate === "agent_message_chunk" ||
|
|
1253
|
+
update.sessionUpdate === "agent_thought_chunk" ||
|
|
1254
|
+
update.sessionUpdate === "user_message_chunk" ||
|
|
1255
|
+
update.sessionUpdate === "tool_call" ||
|
|
1256
|
+
update.sessionUpdate === "tool_call_update")) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1346
1259
|
if (update.sessionUpdate === "agent_message_chunk") {
|
|
1347
1260
|
const claudeMeta = update._meta?.claudeCode;
|
|
1348
1261
|
if (!claudeMeta?.parentToolUseId) {
|
|
@@ -1351,113 +1264,41 @@ export class ClaudeAcpAgent {
|
|
|
1351
1264
|
}
|
|
1352
1265
|
await this.client.sessionUpdate(notification);
|
|
1353
1266
|
};
|
|
1354
|
-
const isAdvisory = (failure) => failure.severity === "warning";
|
|
1355
|
-
const failureRevisions = new Map();
|
|
1356
|
-
const sessionFailureEpoch = randomUUID();
|
|
1357
|
-
const activeSessionFailures = new Map();
|
|
1358
1267
|
let pendingWorkerShutdown = false;
|
|
1359
1268
|
const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
|
|
1360
|
-
const
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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
|
-
}
|
|
1269
|
+
const sessionFailures = new SessionFailureController({
|
|
1270
|
+
sessionId: params.sessionId,
|
|
1271
|
+
state: session.sessionFailureState,
|
|
1272
|
+
capabilities: this.clientCapabilities,
|
|
1273
|
+
isCurrent: isCurrentConsumer,
|
|
1274
|
+
sendUpdate,
|
|
1275
|
+
logger: this.logger,
|
|
1276
|
+
});
|
|
1277
|
+
const createSessionFailure = async (kind, options = {}) => {
|
|
1435
1278
|
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
:
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
category,
|
|
1444
|
-
...(turnId ? { turnId } : {}),
|
|
1445
|
-
};
|
|
1279
|
+
return sessionFailures.prepare(kind, {
|
|
1280
|
+
turnId,
|
|
1281
|
+
sessionScoped: options.turnScoped === false,
|
|
1282
|
+
title: options.title,
|
|
1283
|
+
details: options.details,
|
|
1284
|
+
severity: options.severity,
|
|
1285
|
+
});
|
|
1446
1286
|
};
|
|
1447
|
-
const publishSessionFailure = async (
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1287
|
+
const publishSessionFailure = async (kind, options = {}) => {
|
|
1288
|
+
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
|
1289
|
+
await sessionFailures.publish(kind, {
|
|
1290
|
+
turnId,
|
|
1291
|
+
sessionScoped: options.turnScoped === false,
|
|
1292
|
+
title: options.title,
|
|
1293
|
+
details: options.details,
|
|
1294
|
+
severity: options.severity,
|
|
1295
|
+
});
|
|
1455
1296
|
};
|
|
1456
1297
|
const clearFailuresFromEarlierTurns = async () => {
|
|
1457
1298
|
const activeTurnId = session.activeTurn?.promptUuid;
|
|
1458
1299
|
// Advisories carry no turnId, so without the guard every turn boundary would sweep them away.
|
|
1459
1300
|
// They are session-scoped and stay until superseded or dismissed by the user.
|
|
1460
|
-
await
|
|
1301
|
+
await sessionFailures.clear((failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId);
|
|
1461
1302
|
};
|
|
1462
1303
|
const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
|
|
1463
1304
|
const resetTurnScratch = () => {
|
|
@@ -1465,6 +1306,8 @@ export class ClaudeAcpAgent {
|
|
|
1465
1306
|
lastAssistantUsage = null;
|
|
1466
1307
|
lastAssistantModel = null;
|
|
1467
1308
|
lastAssistantError = undefined;
|
|
1309
|
+
lastAssistantWasUsageLimit = false;
|
|
1310
|
+
lastAssistantFailureTitle = undefined;
|
|
1468
1311
|
lastRefusalExplanation = null;
|
|
1469
1312
|
compactionInProgress = false;
|
|
1470
1313
|
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
|
|
@@ -1708,11 +1551,14 @@ export class ClaudeAcpAgent {
|
|
|
1708
1551
|
};
|
|
1709
1552
|
/** Settle the active turn's deferred exactly once, disarm the force-cancel
|
|
1710
1553
|
* backstop (the turn is over), and drop it from the queue. */
|
|
1711
|
-
const settleActive = (result
|
|
1554
|
+
const settleActive = (result, auditReason = result.stopReason === "cancelled"
|
|
1555
|
+
? "cancelled"
|
|
1556
|
+
: "notReported") => {
|
|
1712
1557
|
const turn = session.activeTurn;
|
|
1713
1558
|
if (!turn || turn.settled) {
|
|
1714
1559
|
return;
|
|
1715
1560
|
}
|
|
1561
|
+
this.finishFileChangeAudit(session, turn, auditReason);
|
|
1716
1562
|
// Captured before the settled flip below (isHeldOpen tests !settled).
|
|
1717
1563
|
const wasHeld = isHeldOpen(turn);
|
|
1718
1564
|
turn.settled = true;
|
|
@@ -1746,6 +1592,7 @@ export class ClaudeAcpAgent {
|
|
|
1746
1592
|
this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
|
|
1747
1593
|
return;
|
|
1748
1594
|
}
|
|
1595
|
+
this.finishFileChangeAudit(session, turn, "providerError");
|
|
1749
1596
|
turn.settled = true;
|
|
1750
1597
|
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
1751
1598
|
session.activeTurn = null;
|
|
@@ -1760,28 +1607,27 @@ export class ClaudeAcpAgent {
|
|
|
1760
1607
|
/** Complete a negotiated terminal failure on the prompt response itself,
|
|
1761
1608
|
* which is the canonical AIR carrier. Legacy clients keep the historical
|
|
1762
1609
|
* JSON-RPC rejection path. */
|
|
1763
|
-
const failActiveWithSessionFailure = async (
|
|
1610
|
+
const failActiveWithSessionFailure = async (kind, error, title) => {
|
|
1764
1611
|
if (!supportsAirSessionFailures(this.clientCapabilities)) {
|
|
1765
1612
|
failActive(error);
|
|
1766
1613
|
return;
|
|
1767
1614
|
}
|
|
1768
1615
|
if (!session.activeTurn || session.activeTurn.settled) {
|
|
1769
|
-
this.logger.error(`Session ${params.sessionId}: cannot attach ${
|
|
1770
|
-
await publishSessionFailure(
|
|
1616
|
+
this.logger.error(`Session ${params.sessionId}: cannot attach ${kind} to a prompt response because no active turn exists; publishing a session-scoped failure`);
|
|
1617
|
+
await publishSessionFailure(kind, { turnScoped: false, title });
|
|
1771
1618
|
return;
|
|
1772
1619
|
}
|
|
1773
|
-
const failure = await createSessionFailure(
|
|
1620
|
+
const failure = await createSessionFailure(kind, { title });
|
|
1774
1621
|
if (!failure) {
|
|
1775
1622
|
failActive(error);
|
|
1776
1623
|
return;
|
|
1777
1624
|
}
|
|
1778
|
-
|
|
1779
|
-
activeSessionFailures.set(failure.id, failure);
|
|
1625
|
+
sessionFailures.recordActive(failure);
|
|
1780
1626
|
settleActive({
|
|
1781
1627
|
stopReason: "end_turn",
|
|
1782
1628
|
usage: sessionUsage(session),
|
|
1783
|
-
_meta: sessionFailureMeta(failure
|
|
1784
|
-
});
|
|
1629
|
+
_meta: sessionFailureMeta(failure),
|
|
1630
|
+
}, "providerError");
|
|
1785
1631
|
};
|
|
1786
1632
|
/** Reject every in-flight turn — used when the stream dies. */
|
|
1787
1633
|
const failAllTurns = (error) => {
|
|
@@ -1793,6 +1639,7 @@ export class ClaudeAcpAgent {
|
|
|
1793
1639
|
session.turnQueue = [];
|
|
1794
1640
|
for (const turn of turns) {
|
|
1795
1641
|
if (!turn.settled) {
|
|
1642
|
+
this.finishFileChangeAudit(session, turn, "providerError");
|
|
1796
1643
|
const wasHeld = isHeldOpen(turn);
|
|
1797
1644
|
turn.settled = true;
|
|
1798
1645
|
if (wasHeld) {
|
|
@@ -1941,6 +1788,7 @@ export class ClaudeAcpAgent {
|
|
|
1941
1788
|
// still here was enqueued afterward and was not part of the cancel.)
|
|
1942
1789
|
for (const queued of [...(session.turnQueue ?? [])]) {
|
|
1943
1790
|
if (!queued.settled) {
|
|
1791
|
+
this.finishFileChangeAudit(session, queued, "providerError");
|
|
1944
1792
|
queued.settled = true;
|
|
1945
1793
|
queued.reject(RequestError.internalError(undefined, SESSION_ENDED_MESSAGE));
|
|
1946
1794
|
}
|
|
@@ -2278,7 +2126,7 @@ export class ClaudeAcpAgent {
|
|
|
2278
2126
|
// the next prompt; only a timer could tell those apart.
|
|
2279
2127
|
this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
|
|
2280
2128
|
`for the active turn; failing the in-flight prompt (issue #825)`);
|
|
2281
|
-
await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
|
|
2129
|
+
await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
|
|
2282
2130
|
}
|
|
2283
2131
|
// The SDK generates the session title in a background task and
|
|
2284
2132
|
// persists it to the session file; `idle` is the turn-over
|
|
@@ -2505,10 +2353,21 @@ export class ClaudeAcpAgent {
|
|
|
2505
2353
|
}
|
|
2506
2354
|
case "plugin_install":
|
|
2507
2355
|
case "notification":
|
|
2508
|
-
case "api_retry":
|
|
2509
2356
|
case "thinking_tokens":
|
|
2510
2357
|
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
2511
2358
|
break;
|
|
2359
|
+
case "api_retry": {
|
|
2360
|
+
const title = message.error_status === null
|
|
2361
|
+
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.`
|
|
2362
|
+
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.`;
|
|
2363
|
+
await publishSessionFailure(message.error_status === null
|
|
2364
|
+
? "transport_lost"
|
|
2365
|
+
: providerFailureCategory(message.error), {
|
|
2366
|
+
title,
|
|
2367
|
+
severity: "warning",
|
|
2368
|
+
});
|
|
2369
|
+
break;
|
|
2370
|
+
}
|
|
2512
2371
|
case "model_refusal_fallback": {
|
|
2513
2372
|
// The SDK retried a refused turn on the fallback model and made
|
|
2514
2373
|
// the swap persistent for the session. Without a notice the
|
|
@@ -2534,21 +2393,28 @@ export class ClaudeAcpAgent {
|
|
|
2534
2393
|
const category = message.api_refusal_category
|
|
2535
2394
|
? ` (${message.api_refusal_category})`
|
|
2536
2395
|
: "";
|
|
2537
|
-
const explanation = message.api_refusal_explanation
|
|
2538
|
-
? `\n\n${message.api_refusal_explanation}`
|
|
2539
|
-
: "";
|
|
2540
2396
|
const outcome = persistent
|
|
2541
2397
|
? `The session will continue on ${message.fallback_model}.`
|
|
2542
2398
|
: local
|
|
2543
2399
|
? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
|
|
2544
2400
|
: `The session stays on ${message.original_model}.`;
|
|
2545
|
-
const
|
|
2546
|
-
`retried with ${message.fallback_model}. ${outcome}
|
|
2401
|
+
const fallbackSummary = `${message.original_model} declined this request${category}; ` +
|
|
2402
|
+
`retried with ${message.fallback_model}. ${outcome}`;
|
|
2403
|
+
const explanation = message.api_refusal_explanation || undefined;
|
|
2404
|
+
const fallbackNotice = explanation
|
|
2405
|
+
? `${fallbackSummary}\n\n${explanation}`
|
|
2406
|
+
: fallbackSummary;
|
|
2547
2407
|
// A silent model swap is a session-level advisory, not something the model said.
|
|
2548
2408
|
// Clients that negotiated typed records get it as one; the rest keep the bold-label
|
|
2549
2409
|
// transcript line, which was the only way to flag it before.
|
|
2550
2410
|
if (supportsAirSessionFailures(this.clientCapabilities)) {
|
|
2551
|
-
|
|
2411
|
+
const useDetails = explanation !== undefined &&
|
|
2412
|
+
fallbackSummary.length + 2 + explanation.length >
|
|
2413
|
+
MAX_INLINE_FAILURE_TITLE_LENGTH;
|
|
2414
|
+
await publishSessionFailure("advisory", {
|
|
2415
|
+
title: useDetails ? fallbackSummary : fallbackNotice,
|
|
2416
|
+
...(useDetails ? { details: explanation } : {}),
|
|
2417
|
+
});
|
|
2552
2418
|
}
|
|
2553
2419
|
else {
|
|
2554
2420
|
await sendUpdate({
|
|
@@ -2872,13 +2738,15 @@ export class ClaudeAcpAgent {
|
|
|
2872
2738
|
settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
|
|
2873
2739
|
break;
|
|
2874
2740
|
}
|
|
2875
|
-
if (!message.is_error) {
|
|
2876
|
-
|
|
2741
|
+
if (!message.is_error && lastAssistantModel !== null) {
|
|
2742
|
+
const activeTurnId = session.activeTurn?.promptUuid;
|
|
2743
|
+
await sessionFailures.clear((failure) => failure.recoveryPolicy === "real_model_success" ||
|
|
2744
|
+
(failure.severity === "warning" && failure.turnId === activeTurnId));
|
|
2877
2745
|
}
|
|
2878
2746
|
switch (message.subtype) {
|
|
2879
2747
|
case "success": {
|
|
2880
2748
|
if (message.result.includes("Please run /login")) {
|
|
2881
|
-
await failActiveWithSessionFailure("auth_required", RequestError.authRequired());
|
|
2749
|
+
await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), message.result);
|
|
2882
2750
|
break;
|
|
2883
2751
|
}
|
|
2884
2752
|
if (message.stop_reason === "max_tokens") {
|
|
@@ -2886,7 +2754,7 @@ export class ClaudeAcpAgent {
|
|
|
2886
2754
|
break;
|
|
2887
2755
|
}
|
|
2888
2756
|
if (message.is_error) {
|
|
2889
|
-
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError), internalErrorForClient(errorKindData(lastAssistantError), message.result));
|
|
2757
|
+
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.result), lastAssistantFailureTitle ?? message.result);
|
|
2890
2758
|
break;
|
|
2891
2759
|
}
|
|
2892
2760
|
// The result text is forwarded in two cases. Local-only
|
|
@@ -2922,7 +2790,7 @@ export class ClaudeAcpAgent {
|
|
|
2922
2790
|
break;
|
|
2923
2791
|
}
|
|
2924
2792
|
if (message.is_error) {
|
|
2925
|
-
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
2793
|
+
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype));
|
|
2926
2794
|
break;
|
|
2927
2795
|
}
|
|
2928
2796
|
stopReason = "end_turn";
|
|
@@ -2930,21 +2798,21 @@ export class ClaudeAcpAgent {
|
|
|
2930
2798
|
}
|
|
2931
2799
|
case "error_max_budget_usd":
|
|
2932
2800
|
if (message.is_error) {
|
|
2933
|
-
await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
2801
|
+
await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2934
2802
|
break;
|
|
2935
2803
|
}
|
|
2936
2804
|
stopReason = "max_turn_requests";
|
|
2937
2805
|
break;
|
|
2938
2806
|
case "error_max_turns":
|
|
2939
2807
|
if (message.is_error) {
|
|
2940
|
-
await failActiveWithSessionFailure("
|
|
2808
|
+
await failActiveWithSessionFailure("context_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2941
2809
|
break;
|
|
2942
2810
|
}
|
|
2943
2811
|
stopReason = "max_turn_requests";
|
|
2944
2812
|
break;
|
|
2945
2813
|
case "error_max_structured_output_retries":
|
|
2946
2814
|
if (message.is_error) {
|
|
2947
|
-
await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
2815
|
+
await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2948
2816
|
break;
|
|
2949
2817
|
}
|
|
2950
2818
|
stopReason = "max_turn_requests";
|
|
@@ -3218,6 +3086,10 @@ export class ClaudeAcpAgent {
|
|
|
3218
3086
|
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
3219
3087
|
lastAssistantUsage = snapshotFromUsage(message.message.usage);
|
|
3220
3088
|
lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
|
|
3089
|
+
lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
|
|
3090
|
+
if (message.error || lastAssistantWasUsageLimit) {
|
|
3091
|
+
lastAssistantFailureTitle = assistantMessageText(message.message);
|
|
3092
|
+
}
|
|
3221
3093
|
if (message.message.model && message.message.model !== "<synthetic>") {
|
|
3222
3094
|
lastAssistantModel = message.message.model;
|
|
3223
3095
|
}
|
|
@@ -3276,7 +3148,18 @@ export class ClaudeAcpAgent {
|
|
|
3276
3148
|
break;
|
|
3277
3149
|
}
|
|
3278
3150
|
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
3279
|
-
await failActiveWithSessionFailure("auth_required", RequestError.authRequired());
|
|
3151
|
+
await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), assistantMessageText(message.message));
|
|
3152
|
+
break;
|
|
3153
|
+
}
|
|
3154
|
+
// AIR receives this provider condition on the terminal prompt
|
|
3155
|
+
// response as a typed failure whose title is the exact assistant
|
|
3156
|
+
// error text captured above. Do not duplicate that text as an
|
|
3157
|
+
// ordinary assistant message; legacy clients retain the historical
|
|
3158
|
+
// transcript behavior.
|
|
3159
|
+
if (message.type === "assistant" &&
|
|
3160
|
+
message.parent_tool_use_id === null &&
|
|
3161
|
+
(message.error || isSyntheticUsageLimitMessage(message.message)) &&
|
|
3162
|
+
supportsAirSessionFailures(this.clientCapabilities)) {
|
|
3280
3163
|
break;
|
|
3281
3164
|
}
|
|
3282
3165
|
let content;
|
|
@@ -3452,7 +3335,7 @@ export class ClaudeAcpAgent {
|
|
|
3452
3335
|
break;
|
|
3453
3336
|
case "auth_status":
|
|
3454
3337
|
if (!message.isAuthenticating && message.error === undefined) {
|
|
3455
|
-
await
|
|
3338
|
+
await sessionFailures.clear((failure) => failure.kind === "auth_required");
|
|
3456
3339
|
}
|
|
3457
3340
|
break;
|
|
3458
3341
|
default:
|
|
@@ -3568,6 +3451,7 @@ export class ClaudeAcpAgent {
|
|
|
3568
3451
|
if (session.turnQueue) {
|
|
3569
3452
|
for (const turn of session.turnQueue) {
|
|
3570
3453
|
if (turn !== session.activeTurn && !turn.settled) {
|
|
3454
|
+
this.finishFileChangeAudit(session, turn, "cancelled");
|
|
3571
3455
|
turn.settled = true;
|
|
3572
3456
|
// Deliberately no `usage`: a queued turn never ran, so the session
|
|
3573
3457
|
// accumulator (the active turn's tally) is not its spend.
|
|
@@ -3629,6 +3513,7 @@ export class ClaudeAcpAgent {
|
|
|
3629
3513
|
{
|
|
3630
3514
|
const active = session.activeTurn;
|
|
3631
3515
|
if (isHeldOpen(active)) {
|
|
3516
|
+
this.finishFileChangeAudit(session, active, "cancelled");
|
|
3632
3517
|
active.settled = true;
|
|
3633
3518
|
// Mirror settleActive's invariants (it is consumer-scoped and
|
|
3634
3519
|
// unreachable from here): disarm the backstop — none should be
|
|
@@ -3951,9 +3836,35 @@ export class ClaudeAcpAgent {
|
|
|
3951
3836
|
async replaySessionHistory(sessionId) {
|
|
3952
3837
|
const toolUseCache = {};
|
|
3953
3838
|
const messages = await getSessionMessages(sessionId);
|
|
3954
|
-
const
|
|
3955
|
-
|
|
3839
|
+
const session = this.sessions[sessionId];
|
|
3840
|
+
const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
|
|
3841
|
+
const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
|
|
3842
|
+
const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
|
|
3843
|
+
const sessionFailures = session && supportsTypedFailures
|
|
3844
|
+
? new SessionFailureController({
|
|
3845
|
+
sessionId,
|
|
3846
|
+
state: session.sessionFailureState,
|
|
3847
|
+
capabilities: this.clientCapabilities,
|
|
3848
|
+
isCurrent: () => this.sessions[sessionId] === session,
|
|
3849
|
+
sendUpdate: (notification) => this.client.sessionUpdate(notification),
|
|
3850
|
+
logger: this.logger,
|
|
3851
|
+
})
|
|
3852
|
+
: undefined;
|
|
3853
|
+
let replayTurnId;
|
|
3854
|
+
// Stop-hook additionalContext is persisted as an internal user message.
|
|
3855
|
+
// Once that marker (or the internal tool itself) appears, suppress the
|
|
3856
|
+
// whole audit exchange until its tool result. This also hides a disobedient
|
|
3857
|
+
// model's separate prose message, while an ordinary next user prompt safely
|
|
3858
|
+
// ends an incomplete audit lane.
|
|
3859
|
+
let replayingFileChangeAudit = false;
|
|
3860
|
+
const replayFileChangeAuditToolUseIds = new Set();
|
|
3956
3861
|
for (const message of messages) {
|
|
3862
|
+
if (message.type === "user" &&
|
|
3863
|
+
message.parent_tool_use_id === null &&
|
|
3864
|
+
typeof message.uuid === "string" &&
|
|
3865
|
+
message.uuid.length > 0) {
|
|
3866
|
+
replayTurnId = message.uuid;
|
|
3867
|
+
}
|
|
3957
3868
|
// Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
|
|
3958
3869
|
// observe live (resumed/loaded sessions), so rewind/resume can translate
|
|
3959
3870
|
// a client-supplied id without an extra getSessionMessages read. Not read
|
|
@@ -3969,6 +3880,21 @@ export class ClaudeAcpAgent {
|
|
|
3969
3880
|
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
3970
3881
|
continue;
|
|
3971
3882
|
}
|
|
3883
|
+
// Capable clients saw every synthetic usage-limit message as a typed
|
|
3884
|
+
// failure live, so replay it at the same transcript position. The
|
|
3885
|
+
// preceding persisted user uuid is the live prompt uuid and therefore
|
|
3886
|
+
// recreates the same incident identity. Only the latest limit not
|
|
3887
|
+
// followed by a real model answer remains active internally.
|
|
3888
|
+
if (sessionFailures &&
|
|
3889
|
+
message.type === "assistant" &&
|
|
3890
|
+
message.parent_tool_use_id === null &&
|
|
3891
|
+
isSyntheticUsageLimitMessage(message.message)) {
|
|
3892
|
+
const title = assistantMessageText(message.message);
|
|
3893
|
+
if (title) {
|
|
3894
|
+
await sessionFailures.restore(replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, "quota_exhausted", title, message.uuid === activeUsageLimit?.uuid);
|
|
3895
|
+
}
|
|
3896
|
+
continue;
|
|
3897
|
+
}
|
|
3972
3898
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3973
3899
|
let content = message.message.content;
|
|
3974
3900
|
const parentToolUseId = parentToolUseIdOf(message);
|
|
@@ -3981,6 +3907,57 @@ export class ClaudeAcpAgent {
|
|
|
3981
3907
|
if (content === null)
|
|
3982
3908
|
continue;
|
|
3983
3909
|
}
|
|
3910
|
+
const auditBlocks = Array.isArray(content)
|
|
3911
|
+
? content.filter((block) => typeof block === "object" && block !== null)
|
|
3912
|
+
: [];
|
|
3913
|
+
const hasFileChangeAuditMarker = (typeof content === "string" && containsFileChangeAuditMarker(content)) ||
|
|
3914
|
+
auditBlocks.some((block) => typeof block.text === "string" && containsFileChangeAuditMarker(block.text));
|
|
3915
|
+
const fileChangeAuditToolUseIds = auditBlocks.flatMap((block) => (block.type === "tool_use" ||
|
|
3916
|
+
block.type === "server_tool_use" ||
|
|
3917
|
+
block.type === "mcp_tool_use") &&
|
|
3918
|
+
typeof block.name === "string" &&
|
|
3919
|
+
isFileChangeAuditTool(block.name) &&
|
|
3920
|
+
typeof block.id === "string"
|
|
3921
|
+
? [block.id]
|
|
3922
|
+
: []);
|
|
3923
|
+
const replayMessageRole = message.message
|
|
3924
|
+
?.role;
|
|
3925
|
+
if (hasFileChangeAuditMarker || fileChangeAuditToolUseIds.length > 0) {
|
|
3926
|
+
replayingFileChangeAudit = true;
|
|
3927
|
+
for (const toolUseId of fileChangeAuditToolUseIds) {
|
|
3928
|
+
replayFileChangeAuditToolUseIds.add(toolUseId);
|
|
3929
|
+
}
|
|
3930
|
+
continue;
|
|
3931
|
+
}
|
|
3932
|
+
if (replayingFileChangeAudit) {
|
|
3933
|
+
const toolResultIds = auditBlocks.flatMap((block) => (block.type === "tool_result" || block.type === "mcp_tool_result") &&
|
|
3934
|
+
typeof block.tool_use_id === "string"
|
|
3935
|
+
? [block.tool_use_id]
|
|
3936
|
+
: []);
|
|
3937
|
+
let completedReport = false;
|
|
3938
|
+
for (const toolUseId of toolResultIds) {
|
|
3939
|
+
if (replayFileChangeAuditToolUseIds.delete(toolUseId))
|
|
3940
|
+
completedReport = true;
|
|
3941
|
+
}
|
|
3942
|
+
if (completedReport && replayFileChangeAuditToolUseIds.size === 0) {
|
|
3943
|
+
replayingFileChangeAudit = false;
|
|
3944
|
+
continue;
|
|
3945
|
+
}
|
|
3946
|
+
// A denied attempt to call another tool is still part of the hidden
|
|
3947
|
+
// lane. Its result must not end replay suppression before the report.
|
|
3948
|
+
if (toolResultIds.length > 0) {
|
|
3949
|
+
continue;
|
|
3950
|
+
}
|
|
3951
|
+
// The next real user prompt is already represented by the client and
|
|
3952
|
+
// starts a new turn; do not let a missing audit result hide it or the
|
|
3953
|
+
// rest of the replay.
|
|
3954
|
+
if (replayMessageRole === "user") {
|
|
3955
|
+
replayingFileChangeAudit = false;
|
|
3956
|
+
}
|
|
3957
|
+
else {
|
|
3958
|
+
continue;
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3984
3961
|
for (const notification of toAcpNotifications(
|
|
3985
3962
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3986
3963
|
content,
|
|
@@ -4074,6 +4051,26 @@ export class ClaudeAcpAgent {
|
|
|
4074
4051
|
message: "Session not found",
|
|
4075
4052
|
};
|
|
4076
4053
|
}
|
|
4054
|
+
const fileChangeAudit = session.activeTurn?.fileChangeAudit;
|
|
4055
|
+
if (isFileChangeAuditReportPhase(fileChangeAudit)) {
|
|
4056
|
+
// The hidden continuation is an audit-only lane: it may submit the
|
|
4057
|
+
// wrapper-owned report, but it must not run another command after the
|
|
4058
|
+
// user-visible answer has already completed.
|
|
4059
|
+
if (isFileChangeAuditTool(toolName) && fileChangeAudit?.phase === "collecting") {
|
|
4060
|
+
return { behavior: "allow", updatedInput: toolInput };
|
|
4061
|
+
}
|
|
4062
|
+
return {
|
|
4063
|
+
behavior: "deny",
|
|
4064
|
+
message: "Only the internal file-change report is allowed during the audit.",
|
|
4065
|
+
};
|
|
4066
|
+
}
|
|
4067
|
+
// The tool is intentionally unusable outside a negotiated audit turn.
|
|
4068
|
+
if (isFileChangeAuditTool(toolName)) {
|
|
4069
|
+
return {
|
|
4070
|
+
behavior: "deny",
|
|
4071
|
+
message: "No file-change report was requested for this turn.",
|
|
4072
|
+
};
|
|
4073
|
+
}
|
|
4077
4074
|
// When the tool call originates inside a subagent, attribute the eagerly
|
|
4078
4075
|
// emitted tool_call (and the permission request itself) to the Agent/Task
|
|
4079
4076
|
// tool call that spawned the subagent, mirroring the streamed subagent
|
|
@@ -4771,6 +4768,30 @@ export class ClaudeAcpAgent {
|
|
|
4771
4768
|
// below) so the TaskCreated/TaskCompleted hook callbacks can close over
|
|
4772
4769
|
// the same Map that the streaming message handler will read from.
|
|
4773
4770
|
const taskState = new Map();
|
|
4771
|
+
// Resolve every workspace root once. The hidden report tool uses this same
|
|
4772
|
+
// set for lexical path validation, and the SDK receives it below.
|
|
4773
|
+
const acpAdditionalDirectories = params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
|
|
4774
|
+
const additionalDirectories = [
|
|
4775
|
+
...(userProvidedOptions?.additionalDirectories ?? []),
|
|
4776
|
+
...acpAdditionalDirectories,
|
|
4777
|
+
];
|
|
4778
|
+
const fileChangeAuditSupport = supportsAgentFileChangeReport(this.clientCapabilities)
|
|
4779
|
+
? createFileChangeAuditSupport({
|
|
4780
|
+
cwd: params.cwd,
|
|
4781
|
+
additionalDirectories,
|
|
4782
|
+
getActiveState: () => this.sessions[sessionId]?.activeTurn?.fileChangeAudit,
|
|
4783
|
+
publish: async (result) => {
|
|
4784
|
+
await this.client.sessionUpdate({
|
|
4785
|
+
sessionId,
|
|
4786
|
+
update: {
|
|
4787
|
+
sessionUpdate: "session_info_update",
|
|
4788
|
+
_meta: agentFileChangeReportMeta(result),
|
|
4789
|
+
},
|
|
4790
|
+
});
|
|
4791
|
+
},
|
|
4792
|
+
logError: (message) => this.logger.error(message),
|
|
4793
|
+
})
|
|
4794
|
+
: undefined;
|
|
4774
4795
|
// The exact env the query will be created with. Built (and the provider
|
|
4775
4796
|
// cache key derived from it, below) in one place so the key always
|
|
4776
4797
|
// describes the backend this query actually talks to: `providers/set`,
|
|
@@ -4814,7 +4835,13 @@ export class ClaudeAcpAgent {
|
|
|
4814
4835
|
cwd: params.cwd,
|
|
4815
4836
|
includePartialMessages: true,
|
|
4816
4837
|
forwardSubagentText,
|
|
4817
|
-
mcpServers: {
|
|
4838
|
+
mcpServers: {
|
|
4839
|
+
...(userProvidedOptions?.mcpServers || {}),
|
|
4840
|
+
...mcpServers,
|
|
4841
|
+
...(fileChangeAuditSupport
|
|
4842
|
+
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
|
|
4843
|
+
: {}),
|
|
4844
|
+
},
|
|
4818
4845
|
// If we want bypassPermissions to be an option, we have to allow it here.
|
|
4819
4846
|
// But it doesn't work in root mode, so we only activate it if it will work.
|
|
4820
4847
|
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
|
@@ -4848,6 +4875,14 @@ export class ClaudeAcpAgent {
|
|
|
4848
4875
|
tools,
|
|
4849
4876
|
hooks: {
|
|
4850
4877
|
...userProvidedOptions?.hooks,
|
|
4878
|
+
...(fileChangeAuditSupport
|
|
4879
|
+
? {
|
|
4880
|
+
PreToolUse: [
|
|
4881
|
+
...(userProvidedOptions?.hooks?.PreToolUse || []),
|
|
4882
|
+
{ hooks: [fileChangeAuditSupport.preToolUseHook] },
|
|
4883
|
+
],
|
|
4884
|
+
}
|
|
4885
|
+
: {}),
|
|
4851
4886
|
PostToolUse: [
|
|
4852
4887
|
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
|
4853
4888
|
{
|
|
@@ -4867,6 +4902,14 @@ export class ClaudeAcpAgent {
|
|
|
4867
4902
|
],
|
|
4868
4903
|
},
|
|
4869
4904
|
],
|
|
4905
|
+
...(fileChangeAuditSupport
|
|
4906
|
+
? {
|
|
4907
|
+
Stop: [
|
|
4908
|
+
...(userProvidedOptions?.hooks?.Stop || []),
|
|
4909
|
+
{ hooks: [fileChangeAuditSupport.stopHook] },
|
|
4910
|
+
],
|
|
4911
|
+
}
|
|
4912
|
+
: {}),
|
|
4870
4913
|
TaskCreated: [
|
|
4871
4914
|
...(userProvidedOptions?.hooks?.TaskCreated || []),
|
|
4872
4915
|
{
|
|
@@ -4897,11 +4940,7 @@ export class ClaudeAcpAgent {
|
|
|
4897
4940
|
// legacy `_meta.additionalRoots` extension for clients that haven't been
|
|
4898
4941
|
// updated yet. Either source is merged with directories supplied via
|
|
4899
4942
|
// `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
|
|
4900
|
-
|
|
4901
|
-
options.additionalDirectories = [
|
|
4902
|
-
...(userProvidedOptions?.additionalDirectories ?? []),
|
|
4903
|
-
...acpAdditionalDirectories,
|
|
4904
|
-
];
|
|
4943
|
+
options.additionalDirectories = additionalDirectories;
|
|
4905
4944
|
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
|
4906
4945
|
// Set our own session id if not resuming an existing session.
|
|
4907
4946
|
options.sessionId = sessionId;
|
|
@@ -5113,6 +5152,9 @@ export class ClaudeAcpAgent {
|
|
|
5113
5152
|
emittedAssistantText: false,
|
|
5114
5153
|
owedTrailingIdles: 0,
|
|
5115
5154
|
messageIdToUuid: new Map(),
|
|
5155
|
+
sessionFailureState: createSessionFailureState(),
|
|
5156
|
+
fileChangeReportRequestIds: new Set(),
|
|
5157
|
+
fileChangeAuditSupport,
|
|
5116
5158
|
};
|
|
5117
5159
|
return {
|
|
5118
5160
|
sessionId,
|
|
@@ -6099,7 +6141,7 @@ function isTaskTool(toolName) {
|
|
|
6099
6141
|
* permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
|
|
6100
6142
|
* resolved explicitly at tool_result time. */
|
|
6101
6143
|
function shouldEmitToolCall(toolName) {
|
|
6102
|
-
return toolName !== "TodoWrite" && !isTaskTool(toolName);
|
|
6144
|
+
return toolName !== "TodoWrite" && !isTaskTool(toolName) && !isFileChangeAuditTool(toolName);
|
|
6103
6145
|
}
|
|
6104
6146
|
/** Build the Claude Code-specific metadata for a tool call. Bash descriptions
|
|
6105
6147
|
* are kept out of ACP's standard `title`, which clients may use as the shell
|
|
@@ -6252,7 +6294,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6252
6294
|
const registerHooks = options?.registerHooks !== false;
|
|
6253
6295
|
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
6254
6296
|
if (typeof content === "string") {
|
|
6255
|
-
if (content.length === 0) {
|
|
6297
|
+
if (content.length === 0 || containsFileChangeAuditMarker(content)) {
|
|
6256
6298
|
return [];
|
|
6257
6299
|
}
|
|
6258
6300
|
const update = {
|
|
@@ -6286,6 +6328,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6286
6328
|
// Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
|
|
6287
6329
|
// messages need no single-block guard.
|
|
6288
6330
|
const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
|
|
6331
|
+
// A report-phase assistant message may contain a short text preface and the
|
|
6332
|
+
// internal tool call in the same content array. Hide the whole message, not
|
|
6333
|
+
// only the tool block, so it stays absent on session replay as well as live.
|
|
6334
|
+
const containsFileChangeAuditToolUse = content.some((chunk) => (chunk.type === "tool_use" ||
|
|
6335
|
+
chunk.type === "server_tool_use" ||
|
|
6336
|
+
chunk.type === "mcp_tool_use") &&
|
|
6337
|
+
isFileChangeAuditTool(chunk.name));
|
|
6289
6338
|
const output = [];
|
|
6290
6339
|
// Only handle the first chunk for streaming; extend as needed for batching
|
|
6291
6340
|
for (const chunk of content) {
|
|
@@ -6293,7 +6342,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6293
6342
|
switch (chunk.type) {
|
|
6294
6343
|
case "text":
|
|
6295
6344
|
case "text_delta": {
|
|
6296
|
-
if (chunk.text
|
|
6345
|
+
if (chunk.text &&
|
|
6346
|
+
!containsFileChangeAuditToolUse &&
|
|
6347
|
+
!containsFileChangeAuditMarker(chunk.text)) {
|
|
6297
6348
|
update = {
|
|
6298
6349
|
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
6299
6350
|
content: {
|
|
@@ -6305,21 +6356,22 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6305
6356
|
break;
|
|
6306
6357
|
}
|
|
6307
6358
|
case "image":
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6359
|
+
if (!containsFileChangeAuditToolUse)
|
|
6360
|
+
update = {
|
|
6361
|
+
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
6362
|
+
content: {
|
|
6363
|
+
type: "image",
|
|
6364
|
+
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
|
6365
|
+
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
|
|
6366
|
+
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
|
|
6367
|
+
},
|
|
6368
|
+
};
|
|
6317
6369
|
break;
|
|
6318
6370
|
case "thinking":
|
|
6319
6371
|
case "thinking_delta": {
|
|
6320
6372
|
// Recent models default `thinking.display` to "omitted", which streams
|
|
6321
6373
|
// signature-only thinking blocks whose text is empty.
|
|
6322
|
-
if (chunk.thinking) {
|
|
6374
|
+
if (chunk.thinking && !containsFileChangeAuditToolUse) {
|
|
6323
6375
|
update = {
|
|
6324
6376
|
sessionUpdate: "agent_thought_chunk",
|
|
6325
6377
|
content: {
|
|
@@ -6335,7 +6387,11 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6335
6387
|
case "mcp_tool_use": {
|
|
6336
6388
|
const alreadyCached = chunk.id in toolUseCache;
|
|
6337
6389
|
toolUseCache[chunk.id] = chunk;
|
|
6338
|
-
if (chunk.name
|
|
6390
|
+
if (isFileChangeAuditTool(chunk.name)) {
|
|
6391
|
+
// Wrapper-owned audit protocol: never surface or register generic
|
|
6392
|
+
// PostToolUse callbacks for the internal tool.
|
|
6393
|
+
}
|
|
6394
|
+
else if (chunk.name === "TodoWrite") {
|
|
6339
6395
|
// @ts-expect-error - sometimes input is empty object or undefined
|
|
6340
6396
|
if (Array.isArray(chunk.input?.todos)) {
|
|
6341
6397
|
update = {
|
|
@@ -6459,6 +6515,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6459
6515
|
logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
|
|
6460
6516
|
break;
|
|
6461
6517
|
}
|
|
6518
|
+
if (isFileChangeAuditTool(toolUse.name)) {
|
|
6519
|
+
delete toolUseCache[chunk.tool_use_id];
|
|
6520
|
+
break;
|
|
6521
|
+
}
|
|
6462
6522
|
// A permission request may have surfaced a plan-rendered (TodoWrite) or
|
|
6463
6523
|
// suppressed (Task*) tool as a real tool_call so the request referenced
|
|
6464
6524
|
// a tool call the client knows about (see `ensureToolCallEmitted`,
|