@agentclientprotocol/claude-agent-acp 0.66.0 → 0.68.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 +17 -21
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +424 -112
- 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/dist/tools.d.ts +4 -6
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +127 -15
- package/package.json +8 -8
package/dist/acp-agent.js
CHANGED
|
@@ -3,6 +3,7 @@ import { deleteSession, getSessionInfo, getSessionMessages, listSessions, query,
|
|
|
3
3
|
import { GOAL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, goalUpdateFromPrompt, parseGoalRequest, toGoalSnapshot, } from "./goal-extension.js";
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
6
7
|
import * as fs from "node:fs/promises";
|
|
7
8
|
import * as os from "node:os";
|
|
8
9
|
import * as path from "node:path";
|
|
@@ -10,11 +11,13 @@ import { promisify } from "node:util";
|
|
|
10
11
|
import packageJson from "../package.json" with { type: "json" };
|
|
11
12
|
import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
|
|
12
13
|
import { SettingsManager } from "./settings.js";
|
|
13
|
-
import {
|
|
14
|
+
import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
|
|
15
|
+
import { applyTaskCreate, applyTaskList, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
14
16
|
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
15
17
|
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
16
18
|
const execFileAsync = promisify(execFile);
|
|
17
19
|
const MAX_TITLE_LENGTH = 256;
|
|
20
|
+
const MAX_INLINE_FAILURE_TITLE_LENGTH = 256;
|
|
18
21
|
function sanitizeTitle(text) {
|
|
19
22
|
// Replace newlines and collapse whitespace
|
|
20
23
|
const sanitized = text
|
|
@@ -100,11 +103,13 @@ function parseSteerRequest(params) {
|
|
|
100
103
|
* result is the turn's real terminal.
|
|
101
104
|
*
|
|
102
105
|
* Deliberately fail-OPEN: an unknown future kind defaults to the user
|
|
103
|
-
* lane
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* result
|
|
107
|
-
*
|
|
106
|
+
* lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
|
|
107
|
+
* attribute this" marker, which gets the same safe default. Misrouting a
|
|
108
|
+
* USER result into the autonomous lane hangs the prompt un-detectably
|
|
109
|
+
* (the result is skipped, its trailing idle absorbed as owed, so the
|
|
110
|
+
* #825 detector can't fire); misrouting an autonomous result into the
|
|
111
|
+
* user lane is the bounded misattribution class this set exists to
|
|
112
|
+
* reduce. */
|
|
108
113
|
const AUTONOMOUS_RESULT_ORIGINS = new Set([
|
|
109
114
|
"task-notification",
|
|
110
115
|
"peer",
|
|
@@ -143,17 +148,9 @@ function computeSessionFingerprint(params) {
|
|
|
143
148
|
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
|
144
149
|
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
|
145
150
|
}
|
|
146
|
-
/**
|
|
147
|
-
* The single provider ID this agent exposes via `providers/*`. Claude Code has
|
|
148
|
-
* one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
|
|
149
|
-
* is exactly one configurable provider.
|
|
150
|
-
*/
|
|
151
|
-
const PROVIDER_ID = "main";
|
|
152
|
-
/**
|
|
153
|
-
* Protocols the `main` provider can be configured with. These mirror the
|
|
154
|
-
* env-var mappings understood by {@link createEnvForProvider}.
|
|
155
|
-
*/
|
|
156
151
|
const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
|
|
152
|
+
const PROVIDER_ID = "main";
|
|
153
|
+
const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
157
154
|
const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
|
|
158
155
|
function supportsSubagentTranscript(capabilities) {
|
|
159
156
|
return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
|
|
@@ -578,9 +575,7 @@ export class ClaudeAcpAgent {
|
|
|
578
575
|
clientCapabilities;
|
|
579
576
|
logger;
|
|
580
577
|
gatewayAuthRequest;
|
|
581
|
-
/**
|
|
582
|
-
* never persisted to disk (see the Configurable LLM Providers RFD). When
|
|
583
|
-
* set, it takes precedence over {@link gatewayAuthRequest}. */
|
|
578
|
+
/** Set while ACP overrides the agent's native provider configuration. */
|
|
584
579
|
providerConfig;
|
|
585
580
|
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
|
586
581
|
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
@@ -734,6 +729,7 @@ export class ClaudeAcpAgent {
|
|
|
734
729
|
// steering extension contract: advertises the `_session/steering` request
|
|
735
730
|
// so clients know they may inject a follow-up into a running turn.
|
|
736
731
|
_meta: {
|
|
732
|
+
...airSessionFailureCapabilityMeta(),
|
|
737
733
|
steering: {
|
|
738
734
|
supported: true,
|
|
739
735
|
},
|
|
@@ -847,21 +843,13 @@ export class ClaudeAcpAgent {
|
|
|
847
843
|
}
|
|
848
844
|
throw new Error("Method not implemented.");
|
|
849
845
|
}
|
|
850
|
-
/**
|
|
851
|
-
* `providers/list` — returns the single client-configurable custom gateway
|
|
852
|
-
* provider (`main`). `current` carries only non-secret routing (never headers,
|
|
853
|
-
* which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
|
|
854
|
-
* display, and is `null` when the provider is not configured/disabled. The
|
|
855
|
-
* provider is optional (`required: false`): while disabled/unconfigured the
|
|
856
|
-
* agent falls back to its own default routing (normal Claude login).
|
|
857
|
-
*/
|
|
858
846
|
async unstable_listProviders(_params) {
|
|
859
|
-
const config = this.
|
|
847
|
+
const config = this.providerConfig ?? this.defaultProviderConfig();
|
|
860
848
|
const provider = {
|
|
861
849
|
providerId: PROVIDER_ID,
|
|
862
850
|
supported: SUPPORTED_PROTOCOLS,
|
|
863
851
|
required: false,
|
|
864
|
-
current:
|
|
852
|
+
current: { apiType: config.apiType, baseUrl: config.baseUrl },
|
|
865
853
|
};
|
|
866
854
|
return { providers: [provider] };
|
|
867
855
|
}
|
|
@@ -903,30 +891,43 @@ export class ClaudeAcpAgent {
|
|
|
903
891
|
return {};
|
|
904
892
|
}
|
|
905
893
|
/**
|
|
906
|
-
* `providers/disable`
|
|
907
|
-
*
|
|
908
|
-
* so the agent reverts to its own default routing and `providers/list` reports
|
|
909
|
-
* `current: null`. Disabling any other (unknown) ID is treated as a successful
|
|
910
|
-
* no-op per the RFD's idempotency rule.
|
|
894
|
+
* `providers/disable` ends ACP ownership of the single mutually exclusive
|
|
895
|
+
* backend slot and restores the agent's native routing state.
|
|
911
896
|
*/
|
|
912
897
|
async unstable_disableProvider(params) {
|
|
913
898
|
if (params.providerId === PROVIDER_ID) {
|
|
914
899
|
this.providerConfig = undefined;
|
|
915
|
-
this.gatewayAuthRequest = undefined;
|
|
916
900
|
}
|
|
917
901
|
// Unknown provider: idempotent success.
|
|
918
902
|
return {};
|
|
919
903
|
}
|
|
920
|
-
/**
|
|
921
|
-
* Resolve the effective client-managed routing config. `providers/set` takes
|
|
922
|
-
* precedence; otherwise fall back to the legacy gateway auth request. Returns
|
|
923
|
-
* `null` when neither is configured.
|
|
924
|
-
*/
|
|
925
904
|
resolveProviderConfig() {
|
|
926
|
-
|
|
927
|
-
|
|
905
|
+
return this.providerConfig ?? gatewayRequestToProviderConfig(this.gatewayAuthRequest);
|
|
906
|
+
}
|
|
907
|
+
defaultProviderConfig() {
|
|
908
|
+
const gatewayConfig = gatewayRequestToProviderConfig(this.gatewayAuthRequest);
|
|
909
|
+
if (gatewayConfig) {
|
|
910
|
+
return gatewayConfig;
|
|
911
|
+
}
|
|
912
|
+
if (process.env.CLAUDE_CODE_USE_BEDROCK) {
|
|
913
|
+
return {
|
|
914
|
+
apiType: "bedrock",
|
|
915
|
+
baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
|
|
916
|
+
headers: {},
|
|
917
|
+
};
|
|
928
918
|
}
|
|
929
|
-
|
|
919
|
+
if (process.env.CLAUDE_CODE_USE_VERTEX) {
|
|
920
|
+
return {
|
|
921
|
+
apiType: "vertex",
|
|
922
|
+
baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? "https://aiplatform.googleapis.com",
|
|
923
|
+
headers: {},
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
return {
|
|
927
|
+
apiType: "anthropic",
|
|
928
|
+
baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
|
|
929
|
+
headers: {},
|
|
930
|
+
};
|
|
930
931
|
}
|
|
931
932
|
async logout(_params) {
|
|
932
933
|
// Clear in-memory gateway credentials supplied via `authenticate` and any
|
|
@@ -966,6 +967,9 @@ export class ClaudeAcpAgent {
|
|
|
966
967
|
if (session.queryClosed) {
|
|
967
968
|
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
968
969
|
}
|
|
970
|
+
if (Array.from(session.taskState.values()).some((task) => task.status !== "completed")) {
|
|
971
|
+
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
972
|
+
}
|
|
969
973
|
const userMessage = promptToClaude(params);
|
|
970
974
|
const promptUuid = randomUUID();
|
|
971
975
|
userMessage.uuid = promptUuid;
|
|
@@ -1021,6 +1025,15 @@ export class ClaudeAcpAgent {
|
|
|
1021
1025
|
},
|
|
1022
1026
|
});
|
|
1023
1027
|
}
|
|
1028
|
+
async publishTaskPlan(sessionId, taskState) {
|
|
1029
|
+
await this.client.sessionUpdate({
|
|
1030
|
+
sessionId,
|
|
1031
|
+
update: {
|
|
1032
|
+
sessionUpdate: "plan",
|
|
1033
|
+
entries: taskStateToPlanEntries(taskState),
|
|
1034
|
+
},
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1024
1037
|
async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
|
|
1025
1038
|
const goalUpdate = goalUpdateFromPrompt(prompt);
|
|
1026
1039
|
if (goalUpdate !== undefined) {
|
|
@@ -1168,6 +1181,8 @@ export class ClaudeAcpAgent {
|
|
|
1168
1181
|
// it here so the subsequent `RequestError.internalError` can forward it to
|
|
1169
1182
|
// clients as structured `data`, sparing them from pattern-matching on text.
|
|
1170
1183
|
let lastAssistantError;
|
|
1184
|
+
let lastAssistantWasUsageLimit = false;
|
|
1185
|
+
let lastAssistantFailureTitle;
|
|
1171
1186
|
// When a streaming classifier refuses a turn, the assistant message carries
|
|
1172
1187
|
// stop_reason "refusal" and structured stop_details. We capture the
|
|
1173
1188
|
// human-readable explanation so the terminal `result` can surface it.
|
|
@@ -1221,11 +1236,50 @@ export class ClaudeAcpAgent {
|
|
|
1221
1236
|
}
|
|
1222
1237
|
await this.client.sessionUpdate(notification);
|
|
1223
1238
|
};
|
|
1239
|
+
let pendingWorkerShutdown = false;
|
|
1240
|
+
const isCurrentConsumer = () => this.sessions[params.sessionId] === session;
|
|
1241
|
+
const sessionFailures = new SessionFailureController({
|
|
1242
|
+
sessionId: params.sessionId,
|
|
1243
|
+
state: session.sessionFailureState,
|
|
1244
|
+
capabilities: this.clientCapabilities,
|
|
1245
|
+
isCurrent: isCurrentConsumer,
|
|
1246
|
+
sendUpdate,
|
|
1247
|
+
logger: this.logger,
|
|
1248
|
+
});
|
|
1249
|
+
const createSessionFailure = async (kind, options = {}) => {
|
|
1250
|
+
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
|
1251
|
+
return sessionFailures.prepare(kind, {
|
|
1252
|
+
turnId,
|
|
1253
|
+
sessionScoped: options.turnScoped === false,
|
|
1254
|
+
title: options.title,
|
|
1255
|
+
details: options.details,
|
|
1256
|
+
severity: options.severity,
|
|
1257
|
+
});
|
|
1258
|
+
};
|
|
1259
|
+
const publishSessionFailure = async (kind, options = {}) => {
|
|
1260
|
+
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
|
1261
|
+
await sessionFailures.publish(kind, {
|
|
1262
|
+
turnId,
|
|
1263
|
+
sessionScoped: options.turnScoped === false,
|
|
1264
|
+
title: options.title,
|
|
1265
|
+
details: options.details,
|
|
1266
|
+
severity: options.severity,
|
|
1267
|
+
});
|
|
1268
|
+
};
|
|
1269
|
+
const clearFailuresFromEarlierTurns = async () => {
|
|
1270
|
+
const activeTurnId = session.activeTurn?.promptUuid;
|
|
1271
|
+
// Advisories carry no turnId, so without the guard every turn boundary would sweep them away.
|
|
1272
|
+
// They are session-scoped and stay until superseded or dismissed by the user.
|
|
1273
|
+
await sessionFailures.clear((failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId);
|
|
1274
|
+
};
|
|
1275
|
+
const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
|
|
1224
1276
|
const resetTurnScratch = () => {
|
|
1225
1277
|
lastAssistantTotalUsage = null;
|
|
1226
1278
|
lastAssistantUsage = null;
|
|
1227
1279
|
lastAssistantModel = null;
|
|
1228
1280
|
lastAssistantError = undefined;
|
|
1281
|
+
lastAssistantWasUsageLimit = false;
|
|
1282
|
+
lastAssistantFailureTitle = undefined;
|
|
1229
1283
|
lastRefusalExplanation = null;
|
|
1230
1284
|
compactionInProgress = false;
|
|
1231
1285
|
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
|
|
@@ -1504,6 +1558,7 @@ export class ClaudeAcpAgent {
|
|
|
1504
1558
|
disarmForceCancel(session);
|
|
1505
1559
|
const turn = session.activeTurn;
|
|
1506
1560
|
if (!turn || turn.settled) {
|
|
1561
|
+
this.logger.error(`Session ${params.sessionId}: cannot fail active turn because no unsettled active turn exists: ${error}`);
|
|
1507
1562
|
return;
|
|
1508
1563
|
}
|
|
1509
1564
|
turn.settled = true;
|
|
@@ -1517,6 +1572,31 @@ export class ClaudeAcpAgent {
|
|
|
1517
1572
|
session.emittedAssistantText = false;
|
|
1518
1573
|
turn.reject(error);
|
|
1519
1574
|
};
|
|
1575
|
+
/** Complete a negotiated terminal failure on the prompt response itself,
|
|
1576
|
+
* which is the canonical AIR carrier. Legacy clients keep the historical
|
|
1577
|
+
* JSON-RPC rejection path. */
|
|
1578
|
+
const failActiveWithSessionFailure = async (kind, error, title) => {
|
|
1579
|
+
if (!supportsAirSessionFailures(this.clientCapabilities)) {
|
|
1580
|
+
failActive(error);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
if (!session.activeTurn || session.activeTurn.settled) {
|
|
1584
|
+
this.logger.error(`Session ${params.sessionId}: cannot attach ${kind} to a prompt response because no active turn exists; publishing a session-scoped failure`);
|
|
1585
|
+
await publishSessionFailure(kind, { turnScoped: false, title });
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
const failure = await createSessionFailure(kind, { title });
|
|
1589
|
+
if (!failure) {
|
|
1590
|
+
failActive(error);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
sessionFailures.recordActive(failure);
|
|
1594
|
+
settleActive({
|
|
1595
|
+
stopReason: "end_turn",
|
|
1596
|
+
usage: sessionUsage(session),
|
|
1597
|
+
_meta: sessionFailureMeta(failure),
|
|
1598
|
+
});
|
|
1599
|
+
};
|
|
1520
1600
|
/** Reject every in-flight turn — used when the stream dies. */
|
|
1521
1601
|
const failAllTurns = (error) => {
|
|
1522
1602
|
disarmForceCancel(session);
|
|
@@ -1634,6 +1714,24 @@ export class ClaudeAcpAgent {
|
|
|
1634
1714
|
pendingNext = null;
|
|
1635
1715
|
const { value: message, done } = raced.result;
|
|
1636
1716
|
if (done || !message) {
|
|
1717
|
+
if (pendingWorkerShutdown) {
|
|
1718
|
+
pendingWorkerShutdown = false;
|
|
1719
|
+
if (session.activeTurn) {
|
|
1720
|
+
if (!isHeldOpen(session.activeTurn)) {
|
|
1721
|
+
await failActiveWithSessionFailure("worker_shutdown", internalErrorForClient({ errorKind: "worker_shutdown" }));
|
|
1722
|
+
}
|
|
1723
|
+
else {
|
|
1724
|
+
// The held turn already has its authoritative terminal outcome,
|
|
1725
|
+
// but EOF permanently closes the non-revivable Query. Preserve
|
|
1726
|
+
// the turn result below and report the independent session-health
|
|
1727
|
+
// failure without a turnId so AIR can offer a new session.
|
|
1728
|
+
await publishSessionFailure("worker_shutdown", { turnScoped: false });
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
else {
|
|
1732
|
+
await publishSessionFailure("worker_shutdown", { turnScoped: false });
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1637
1735
|
// The stream ended. Settle the in-flight turns FIRST, then release the
|
|
1638
1736
|
// stream resources — same order as the error paths (failAllTurns before
|
|
1639
1737
|
// closeQueryStream). Settling is the user-facing contract; resource
|
|
@@ -1785,6 +1883,23 @@ export class ClaudeAcpAgent {
|
|
|
1785
1883
|
// updated Fast mode state; reconcile it with what we seeded at
|
|
1786
1884
|
// session creation.
|
|
1787
1885
|
await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
|
|
1886
|
+
// Terminal-bound slash commands (absent when none, and on
|
|
1887
|
+
// older CLIs). The session/new advertisement runs before any
|
|
1888
|
+
// init frame can be observed, so the first latch (or a
|
|
1889
|
+
// genuine change) re-publishes the now-filtered list.
|
|
1890
|
+
if (message.terminal_slash_commands &&
|
|
1891
|
+
JSON.stringify(message.terminal_slash_commands) !==
|
|
1892
|
+
JSON.stringify(session.terminalSlashCommands)) {
|
|
1893
|
+
session.terminalSlashCommands = message.terminal_slash_commands;
|
|
1894
|
+
try {
|
|
1895
|
+
await this.sendAvailableCommandsUpdate(message.session_id);
|
|
1896
|
+
}
|
|
1897
|
+
catch (error) {
|
|
1898
|
+
// Advisory reconcile only — the client keeps its current
|
|
1899
|
+
// (unfiltered) list; never fail the turn over it.
|
|
1900
|
+
this.logger.error(`Failed to re-advertise slash commands: ${error}`);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1788
1903
|
break;
|
|
1789
1904
|
case "status": {
|
|
1790
1905
|
// These banners count as delivered text (via sendUpdate), so
|
|
@@ -1977,7 +2092,7 @@ export class ClaudeAcpAgent {
|
|
|
1977
2092
|
// the next prompt; only a timer could tell those apart.
|
|
1978
2093
|
this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
|
|
1979
2094
|
`for the active turn; failing the in-flight prompt (issue #825)`);
|
|
1980
|
-
|
|
2095
|
+
await failActiveWithSessionFailure("internal_error", RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE), TURN_NO_RESULT_MESSAGE);
|
|
1981
2096
|
}
|
|
1982
2097
|
// The SDK generates the session title in a background task and
|
|
1983
2098
|
// persists it to the session file; `idle` is the turn-over
|
|
@@ -2035,7 +2150,7 @@ export class ClaudeAcpAgent {
|
|
|
2035
2150
|
sessionId: message.session_id,
|
|
2036
2151
|
update: {
|
|
2037
2152
|
sessionUpdate: "available_commands_update",
|
|
2038
|
-
availableCommands: getAvailableSlashCommands(message.commands),
|
|
2153
|
+
availableCommands: getAvailableSlashCommands(message.commands, session.terminalSlashCommands),
|
|
2039
2154
|
},
|
|
2040
2155
|
});
|
|
2041
2156
|
break;
|
|
@@ -2177,9 +2292,14 @@ export class ClaudeAcpAgent {
|
|
|
2177
2292
|
}
|
|
2178
2293
|
break;
|
|
2179
2294
|
case "worker_shutting_down":
|
|
2180
|
-
//
|
|
2181
|
-
//
|
|
2182
|
-
//
|
|
2295
|
+
// Defer until stream end. The announcement is durable and may be
|
|
2296
|
+
// replayed before later frames, but those frames do not prove that
|
|
2297
|
+
// a new worker epoch began: they can be buffered output from the
|
|
2298
|
+
// shutting-down worker. Keep the signal armed until the transport
|
|
2299
|
+
// actually ends. Deliberately do not add a quiet-period timer: the
|
|
2300
|
+
// iterator has no replay/live boundary, so a timeout could publish
|
|
2301
|
+
// while a slow replay is still in flight.
|
|
2302
|
+
pendingWorkerShutdown = true;
|
|
2183
2303
|
break;
|
|
2184
2304
|
case "elicitation_complete": {
|
|
2185
2305
|
// A url-mode MCP elicitation finished server-side. Let the client
|
|
@@ -2199,10 +2319,21 @@ export class ClaudeAcpAgent {
|
|
|
2199
2319
|
}
|
|
2200
2320
|
case "plugin_install":
|
|
2201
2321
|
case "notification":
|
|
2202
|
-
case "api_retry":
|
|
2203
2322
|
case "thinking_tokens":
|
|
2204
2323
|
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
2205
2324
|
break;
|
|
2325
|
+
case "api_retry": {
|
|
2326
|
+
const title = message.error_status === null
|
|
2327
|
+
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.`
|
|
2328
|
+
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.`;
|
|
2329
|
+
await publishSessionFailure(message.error_status === null
|
|
2330
|
+
? "transport_lost"
|
|
2331
|
+
: providerFailureCategory(message.error), {
|
|
2332
|
+
title,
|
|
2333
|
+
severity: "warning",
|
|
2334
|
+
});
|
|
2335
|
+
break;
|
|
2336
|
+
}
|
|
2206
2337
|
case "model_refusal_fallback": {
|
|
2207
2338
|
// The SDK retried a refused turn on the fallback model and made
|
|
2208
2339
|
// the swap persistent for the session. Without a notice the
|
|
@@ -2216,26 +2347,53 @@ export class ClaudeAcpAgent {
|
|
|
2216
2347
|
// CLIs, where "revert" marked a turn-only fallback — for that
|
|
2217
2348
|
// direction the session stays on the original model, so skip
|
|
2218
2349
|
// the persistent-swap claim and the state sync.
|
|
2219
|
-
|
|
2350
|
+
//
|
|
2351
|
+
// `scope` (CLI 2.1.232+) marks WHERE the fallback happened:
|
|
2352
|
+
// "local" means a subagent / side-question / background fork
|
|
2353
|
+
// response fell back and the session model is unchanged, so
|
|
2354
|
+
// syncing the picker would advertise a model the session
|
|
2355
|
+
// isn't running. Absent scope means an older CLI, where every
|
|
2356
|
+
// retry was a session-level swap — treat as "session".
|
|
2357
|
+
const local = message.scope === "local";
|
|
2358
|
+
const persistent = message.direction !== "revert" && !local;
|
|
2220
2359
|
const category = message.api_refusal_category
|
|
2221
2360
|
? ` (${message.api_refusal_category})`
|
|
2222
2361
|
: "";
|
|
2223
|
-
const explanation = message.api_refusal_explanation
|
|
2224
|
-
? `\n\n${message.api_refusal_explanation}`
|
|
2225
|
-
: "";
|
|
2226
2362
|
const outcome = persistent
|
|
2227
2363
|
? `The session will continue on ${message.fallback_model}.`
|
|
2228
|
-
:
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2364
|
+
: local
|
|
2365
|
+
? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
|
|
2366
|
+
: `The session stays on ${message.original_model}.`;
|
|
2367
|
+
const fallbackSummary = `${message.original_model} declined this request${category}; ` +
|
|
2368
|
+
`retried with ${message.fallback_model}. ${outcome}`;
|
|
2369
|
+
const explanation = message.api_refusal_explanation || undefined;
|
|
2370
|
+
const fallbackNotice = explanation
|
|
2371
|
+
? `${fallbackSummary}\n\n${explanation}`
|
|
2372
|
+
: fallbackSummary;
|
|
2373
|
+
// A silent model swap is a session-level advisory, not something the model said.
|
|
2374
|
+
// Clients that negotiated typed records get it as one; the rest keep the bold-label
|
|
2375
|
+
// transcript line, which was the only way to flag it before.
|
|
2376
|
+
if (supportsAirSessionFailures(this.clientCapabilities)) {
|
|
2377
|
+
const useDetails = explanation !== undefined &&
|
|
2378
|
+
fallbackSummary.length + 2 + explanation.length >
|
|
2379
|
+
MAX_INLINE_FAILURE_TITLE_LENGTH;
|
|
2380
|
+
await publishSessionFailure("advisory", {
|
|
2381
|
+
title: useDetails ? fallbackSummary : fallbackNotice,
|
|
2382
|
+
...(useDetails ? { details: explanation } : {}),
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
else {
|
|
2386
|
+
await sendUpdate({
|
|
2387
|
+
sessionId: message.session_id,
|
|
2388
|
+
update: {
|
|
2389
|
+
sessionUpdate: "agent_message_chunk",
|
|
2390
|
+
content: {
|
|
2391
|
+
type: "text",
|
|
2392
|
+
text: `**Model fallback:** ${fallbackNotice}`,
|
|
2393
|
+
},
|
|
2236
2394
|
},
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2239
2397
|
if (persistent) {
|
|
2240
2398
|
await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
|
|
2241
2399
|
}
|
|
@@ -2473,6 +2631,7 @@ export class ClaudeAcpAgent {
|
|
|
2473
2631
|
}
|
|
2474
2632
|
if (session.cancelled) {
|
|
2475
2633
|
if (!isAutonomousResult) {
|
|
2634
|
+
await clearFailuresFromEarlierTurns();
|
|
2476
2635
|
stopReason = "cancelled";
|
|
2477
2636
|
}
|
|
2478
2637
|
break;
|
|
@@ -2508,6 +2667,7 @@ export class ClaudeAcpAgent {
|
|
|
2508
2667
|
}
|
|
2509
2668
|
break;
|
|
2510
2669
|
}
|
|
2670
|
+
await clearFailuresFromEarlierTurns();
|
|
2511
2671
|
// A refusal can arrive on any result subtype (and may even set
|
|
2512
2672
|
// is_error), so handle it before the subtype switch — otherwise the
|
|
2513
2673
|
// is_error throw below would surface it as an internal error. The
|
|
@@ -2544,10 +2704,15 @@ export class ClaudeAcpAgent {
|
|
|
2544
2704
|
settleOrDefer({ stopReason: "end_turn", usage: sessionUsage(session) });
|
|
2545
2705
|
break;
|
|
2546
2706
|
}
|
|
2707
|
+
if (!message.is_error && lastAssistantModel !== null) {
|
|
2708
|
+
const activeTurnId = session.activeTurn?.promptUuid;
|
|
2709
|
+
await sessionFailures.clear((failure) => failure.recoveryPolicy === "real_model_success" ||
|
|
2710
|
+
(failure.severity === "warning" && failure.turnId === activeTurnId));
|
|
2711
|
+
}
|
|
2547
2712
|
switch (message.subtype) {
|
|
2548
2713
|
case "success": {
|
|
2549
2714
|
if (message.result.includes("Please run /login")) {
|
|
2550
|
-
|
|
2715
|
+
await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), message.result);
|
|
2551
2716
|
break;
|
|
2552
2717
|
}
|
|
2553
2718
|
if (message.stop_reason === "max_tokens") {
|
|
@@ -2555,7 +2720,7 @@ export class ClaudeAcpAgent {
|
|
|
2555
2720
|
break;
|
|
2556
2721
|
}
|
|
2557
2722
|
if (message.is_error) {
|
|
2558
|
-
|
|
2723
|
+
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.result), lastAssistantFailureTitle ?? message.result);
|
|
2559
2724
|
break;
|
|
2560
2725
|
}
|
|
2561
2726
|
// The result text is forwarded in two cases. Local-only
|
|
@@ -2591,17 +2756,29 @@ export class ClaudeAcpAgent {
|
|
|
2591
2756
|
break;
|
|
2592
2757
|
}
|
|
2593
2758
|
if (message.is_error) {
|
|
2594
|
-
|
|
2759
|
+
await failActiveWithSessionFailure(providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype));
|
|
2595
2760
|
break;
|
|
2596
2761
|
}
|
|
2597
2762
|
stopReason = "end_turn";
|
|
2598
2763
|
break;
|
|
2599
2764
|
}
|
|
2600
2765
|
case "error_max_budget_usd":
|
|
2766
|
+
if (message.is_error) {
|
|
2767
|
+
await failActiveWithSessionFailure("budget_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2768
|
+
break;
|
|
2769
|
+
}
|
|
2770
|
+
stopReason = "max_turn_requests";
|
|
2771
|
+
break;
|
|
2601
2772
|
case "error_max_turns":
|
|
2773
|
+
if (message.is_error) {
|
|
2774
|
+
await failActiveWithSessionFailure("context_exhausted", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2775
|
+
break;
|
|
2776
|
+
}
|
|
2777
|
+
stopReason = "max_turn_requests";
|
|
2778
|
+
break;
|
|
2602
2779
|
case "error_max_structured_output_retries":
|
|
2603
2780
|
if (message.is_error) {
|
|
2604
|
-
|
|
2781
|
+
await failActiveWithSessionFailure("provider_error", internalErrorForClient(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype), message.errors.join(", ") || message.subtype);
|
|
2605
2782
|
break;
|
|
2606
2783
|
}
|
|
2607
2784
|
stopReason = "max_turn_requests";
|
|
@@ -2875,6 +3052,10 @@ export class ClaudeAcpAgent {
|
|
|
2875
3052
|
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
|
2876
3053
|
lastAssistantUsage = snapshotFromUsage(message.message.usage);
|
|
2877
3054
|
lastAssistantTotalUsage = totalTokens(lastAssistantUsage);
|
|
3055
|
+
lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message);
|
|
3056
|
+
if (message.error || lastAssistantWasUsageLimit) {
|
|
3057
|
+
lastAssistantFailureTitle = assistantMessageText(message.message);
|
|
3058
|
+
}
|
|
2878
3059
|
if (message.message.model && message.message.model !== "<synthetic>") {
|
|
2879
3060
|
lastAssistantModel = message.message.model;
|
|
2880
3061
|
}
|
|
@@ -2933,7 +3114,18 @@ export class ClaudeAcpAgent {
|
|
|
2933
3114
|
break;
|
|
2934
3115
|
}
|
|
2935
3116
|
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
2936
|
-
|
|
3117
|
+
await failActiveWithSessionFailure("auth_required", RequestError.authRequired(), assistantMessageText(message.message));
|
|
3118
|
+
break;
|
|
3119
|
+
}
|
|
3120
|
+
// AIR receives this provider condition on the terminal prompt
|
|
3121
|
+
// response as a typed failure whose title is the exact assistant
|
|
3122
|
+
// error text captured above. Do not duplicate that text as an
|
|
3123
|
+
// ordinary assistant message; legacy clients retain the historical
|
|
3124
|
+
// transcript behavior.
|
|
3125
|
+
if (message.type === "assistant" &&
|
|
3126
|
+
message.parent_tool_use_id === null &&
|
|
3127
|
+
(message.error || isSyntheticUsageLimitMessage(message.message)) &&
|
|
3128
|
+
supportsAirSessionFailures(this.clientCapabilities)) {
|
|
2937
3129
|
break;
|
|
2938
3130
|
}
|
|
2939
3131
|
let content;
|
|
@@ -3095,13 +3287,22 @@ export class ClaudeAcpAgent {
|
|
|
3095
3287
|
}
|
|
3096
3288
|
break;
|
|
3097
3289
|
}
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3290
|
+
case "conversation_reset": {
|
|
3291
|
+
// The SDK has switched to a fresh conversation, whose Task* IDs
|
|
3292
|
+
// and task store are independent of the previous transcript.
|
|
3293
|
+
// Clear both the in-memory snapshot and the client's visible plan
|
|
3294
|
+
// before any follow-up prompt can republish stale tasks.
|
|
3295
|
+
session.taskState.clear();
|
|
3296
|
+
await this.publishTaskPlan(params.sessionId, session.taskState);
|
|
3297
|
+
break;
|
|
3298
|
+
}
|
|
3101
3299
|
case "tool_use_summary":
|
|
3102
|
-
case "auth_status":
|
|
3103
3300
|
case "prompt_suggestion":
|
|
3104
|
-
|
|
3301
|
+
break;
|
|
3302
|
+
case "auth_status":
|
|
3303
|
+
if (!message.isAuthenticating && message.error === undefined) {
|
|
3304
|
+
await sessionFailures.clear((failure) => failure.kind === "auth_required");
|
|
3305
|
+
}
|
|
3105
3306
|
break;
|
|
3106
3307
|
default:
|
|
3107
3308
|
unreachable(message, this.logger);
|
|
@@ -3123,6 +3324,19 @@ export class ClaudeAcpAgent {
|
|
|
3123
3324
|
message.includes("process exited with") ||
|
|
3124
3325
|
message.includes("process terminated by signal") ||
|
|
3125
3326
|
message.includes("Failed to write to process stdin"));
|
|
3327
|
+
if (supportsAirSessionFailures(this.clientCapabilities) && session.activeTurn) {
|
|
3328
|
+
if (!isHeldOpen(session.activeTurn)) {
|
|
3329
|
+
await failActiveWithSessionFailure("transport_lost", internalErrorForClient({ errorKind: "transport_lost" }));
|
|
3330
|
+
}
|
|
3331
|
+
else {
|
|
3332
|
+
// The held turn keeps its recorded PromptResponse outcome, while the
|
|
3333
|
+
// exhausted Query makes the session independently unrecoverable.
|
|
3334
|
+
await publishSessionFailure("transport_lost", { turnScoped: false });
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
else {
|
|
3338
|
+
await publishSessionFailure("transport_lost", { turnScoped: false });
|
|
3339
|
+
}
|
|
3126
3340
|
// Either way the query iterator is finished and the consumer is exiting,
|
|
3127
3341
|
// so release its resources via closeQueryStream (idempotent). A process
|
|
3128
3342
|
// death is unrecoverable, so additionally evict the session so the client
|
|
@@ -3136,7 +3350,9 @@ export class ClaudeAcpAgent {
|
|
|
3136
3350
|
}
|
|
3137
3351
|
else {
|
|
3138
3352
|
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
|
|
3139
|
-
failAllTurns(
|
|
3353
|
+
failAllTurns(supportsAirSessionFailures(this.clientCapabilities)
|
|
3354
|
+
? internalErrorForClient({ errorKind: "transport_lost" })
|
|
3355
|
+
: error);
|
|
3140
3356
|
this.closeQueryStream(session);
|
|
3141
3357
|
}
|
|
3142
3358
|
}
|
|
@@ -3584,9 +3800,28 @@ export class ClaudeAcpAgent {
|
|
|
3584
3800
|
async replaySessionHistory(sessionId) {
|
|
3585
3801
|
const toolUseCache = {};
|
|
3586
3802
|
const messages = await getSessionMessages(sessionId);
|
|
3587
|
-
const
|
|
3588
|
-
|
|
3803
|
+
const session = this.sessions[sessionId];
|
|
3804
|
+
const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
|
|
3805
|
+
const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
|
|
3806
|
+
const activeUsageLimit = supportsTypedFailures ? activeUsageLimitMessage(messages) : undefined;
|
|
3807
|
+
const sessionFailures = session && supportsTypedFailures
|
|
3808
|
+
? new SessionFailureController({
|
|
3809
|
+
sessionId,
|
|
3810
|
+
state: session.sessionFailureState,
|
|
3811
|
+
capabilities: this.clientCapabilities,
|
|
3812
|
+
isCurrent: () => this.sessions[sessionId] === session,
|
|
3813
|
+
sendUpdate: (notification) => this.client.sessionUpdate(notification),
|
|
3814
|
+
logger: this.logger,
|
|
3815
|
+
})
|
|
3816
|
+
: undefined;
|
|
3817
|
+
let replayTurnId;
|
|
3589
3818
|
for (const message of messages) {
|
|
3819
|
+
if (message.type === "user" &&
|
|
3820
|
+
message.parent_tool_use_id === null &&
|
|
3821
|
+
typeof message.uuid === "string" &&
|
|
3822
|
+
message.uuid.length > 0) {
|
|
3823
|
+
replayTurnId = message.uuid;
|
|
3824
|
+
}
|
|
3590
3825
|
// Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
|
|
3591
3826
|
// observe live (resumed/loaded sessions), so rewind/resume can translate
|
|
3592
3827
|
// a client-supplied id without an extra getSessionMessages read. Not read
|
|
@@ -3602,6 +3837,21 @@ export class ClaudeAcpAgent {
|
|
|
3602
3837
|
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
3603
3838
|
continue;
|
|
3604
3839
|
}
|
|
3840
|
+
// Capable clients saw every synthetic usage-limit message as a typed
|
|
3841
|
+
// failure live, so replay it at the same transcript position. The
|
|
3842
|
+
// preceding persisted user uuid is the live prompt uuid and therefore
|
|
3843
|
+
// recreates the same incident identity. Only the latest limit not
|
|
3844
|
+
// followed by a real model answer remains active internally.
|
|
3845
|
+
if (sessionFailures &&
|
|
3846
|
+
message.type === "assistant" &&
|
|
3847
|
+
message.parent_tool_use_id === null &&
|
|
3848
|
+
isSyntheticUsageLimitMessage(message.message)) {
|
|
3849
|
+
const title = assistantMessageText(message.message);
|
|
3850
|
+
if (title) {
|
|
3851
|
+
await sessionFailures.restore(replayTurnId ? `${replayTurnId}:error` : `${sessionId}:history-error:${message.uuid}`, "quota_exhausted", title, message.uuid === activeUsageLimit?.uuid);
|
|
3852
|
+
}
|
|
3853
|
+
continue;
|
|
3854
|
+
}
|
|
3605
3855
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3606
3856
|
let content = message.message.content;
|
|
3607
3857
|
const parentToolUseId = parentToolUseIdOf(message);
|
|
@@ -3992,7 +4242,7 @@ export class ClaudeAcpAgent {
|
|
|
3992
4242
|
sessionId,
|
|
3993
4243
|
update: {
|
|
3994
4244
|
sessionUpdate: "available_commands_update",
|
|
3995
|
-
availableCommands: getAvailableSlashCommands(commands),
|
|
4245
|
+
availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands),
|
|
3996
4246
|
},
|
|
3997
4247
|
});
|
|
3998
4248
|
}
|
|
@@ -4506,15 +4756,7 @@ export class ClaudeAcpAgent {
|
|
|
4506
4756
|
hooks: [
|
|
4507
4757
|
createTaskHook({
|
|
4508
4758
|
taskState,
|
|
4509
|
-
onChange:
|
|
4510
|
-
await this.client.sessionUpdate({
|
|
4511
|
-
sessionId,
|
|
4512
|
-
update: {
|
|
4513
|
-
sessionUpdate: "plan",
|
|
4514
|
-
entries: taskStateToPlanEntries(taskState),
|
|
4515
|
-
},
|
|
4516
|
-
});
|
|
4517
|
-
},
|
|
4759
|
+
onChange: () => this.publishTaskPlan(sessionId, taskState),
|
|
4518
4760
|
}),
|
|
4519
4761
|
],
|
|
4520
4762
|
},
|
|
@@ -4525,15 +4767,7 @@ export class ClaudeAcpAgent {
|
|
|
4525
4767
|
hooks: [
|
|
4526
4768
|
createTaskHook({
|
|
4527
4769
|
taskState,
|
|
4528
|
-
onChange:
|
|
4529
|
-
await this.client.sessionUpdate({
|
|
4530
|
-
sessionId,
|
|
4531
|
-
update: {
|
|
4532
|
-
sessionUpdate: "plan",
|
|
4533
|
-
entries: taskStateToPlanEntries(taskState),
|
|
4534
|
-
},
|
|
4535
|
-
});
|
|
4536
|
-
},
|
|
4770
|
+
onChange: () => this.publishTaskPlan(sessionId, taskState),
|
|
4537
4771
|
}),
|
|
4538
4772
|
],
|
|
4539
4773
|
},
|
|
@@ -4762,6 +4996,7 @@ export class ClaudeAcpAgent {
|
|
|
4762
4996
|
emittedAssistantText: false,
|
|
4763
4997
|
owedTrailingIdles: 0,
|
|
4764
4998
|
messageIdToUuid: new Map(),
|
|
4999
|
+
sessionFailureState: createSessionFailureState(),
|
|
4765
5000
|
};
|
|
4766
5001
|
return {
|
|
4767
5002
|
sessionId,
|
|
@@ -5113,11 +5348,21 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
|
|
|
5113
5348
|
category: "model",
|
|
5114
5349
|
type: "select",
|
|
5115
5350
|
currentValue: models.currentModelId,
|
|
5116
|
-
options: models.availableModels.map((m) =>
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5351
|
+
options: models.availableModels.map((m) => {
|
|
5352
|
+
if (m.modelId === "default") {
|
|
5353
|
+
const defaultInfo = modelInfos.find((mi) => mi.value === "default");
|
|
5354
|
+
const resolvedModel = defaultInfo?.resolvedModel;
|
|
5355
|
+
if (resolvedModel) {
|
|
5356
|
+
const namedMatch = modelInfos.find((mi) => mi.value !== "default" && mi.resolvedModel === resolvedModel);
|
|
5357
|
+
return {
|
|
5358
|
+
value: m.modelId,
|
|
5359
|
+
name: m.name,
|
|
5360
|
+
description: namedMatch?.displayName ?? resolvedModel,
|
|
5361
|
+
};
|
|
5362
|
+
}
|
|
5363
|
+
}
|
|
5364
|
+
return { value: m.modelId, name: m.name, description: m.description ?? undefined };
|
|
5365
|
+
}),
|
|
5121
5366
|
},
|
|
5122
5367
|
];
|
|
5123
5368
|
// Add effort level option based on the currently selected model
|
|
@@ -5549,7 +5794,11 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
|
|
|
5549
5794
|
resumedContextWindow,
|
|
5550
5795
|
};
|
|
5551
5796
|
}
|
|
5552
|
-
function getAvailableSlashCommands(commands
|
|
5797
|
+
function getAvailableSlashCommands(commands,
|
|
5798
|
+
// Names the CLI tagged terminal-bound on `system`/init (their UX lives in
|
|
5799
|
+
// the CLI's own terminal, which ACP clients aren't) — filtered alongside
|
|
5800
|
+
// the static list. Raw CLI names, matched before the MCP rename.
|
|
5801
|
+
terminalCommands) {
|
|
5553
5802
|
const UNSUPPORTED_COMMANDS = [
|
|
5554
5803
|
"clear",
|
|
5555
5804
|
"cost",
|
|
@@ -5561,6 +5810,7 @@ function getAvailableSlashCommands(commands) {
|
|
|
5561
5810
|
"todos",
|
|
5562
5811
|
];
|
|
5563
5812
|
return commands
|
|
5813
|
+
.filter((command) => !terminalCommands?.includes(command.name))
|
|
5564
5814
|
.map((command) => {
|
|
5565
5815
|
const input = command.argumentHint
|
|
5566
5816
|
? {
|
|
@@ -5739,7 +5989,7 @@ function shouldEmitToolCall(toolName) {
|
|
|
5739
5989
|
* are kept out of ACP's standard `title`, which clients may use as the shell
|
|
5740
5990
|
* command preview, while still giving clients access to Claude's concise
|
|
5741
5991
|
* human-readable title. */
|
|
5742
|
-
function claudeCodeMetaFromToolUse(toolUse) {
|
|
5992
|
+
function claudeCodeMetaFromToolUse(toolUse, cwd) {
|
|
5743
5993
|
const description = toolUse.name === "Bash" &&
|
|
5744
5994
|
toolUse.input !== null &&
|
|
5745
5995
|
typeof toolUse.input === "object" &&
|
|
@@ -5747,12 +5997,54 @@ function claudeCodeMetaFromToolUse(toolUse) {
|
|
|
5747
5997
|
typeof toolUse.input.description === "string"
|
|
5748
5998
|
? toolUse.input.description
|
|
5749
5999
|
: undefined;
|
|
6000
|
+
const skillName = toolUse.name === "Skill"
|
|
6001
|
+
? toolUse.input?.skill
|
|
6002
|
+
: undefined;
|
|
6003
|
+
const skillPath = skillName ? resolveSkillPath(skillName, cwd) : undefined;
|
|
5750
6004
|
return {
|
|
5751
6005
|
toolName: toolUse.name,
|
|
5752
6006
|
...(description ? { title: description } : {}),
|
|
5753
6007
|
...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
|
|
6008
|
+
...(skillName ? { skill: skillName } : {}),
|
|
6009
|
+
...(skillPath ? { skillPath } : {}),
|
|
5754
6010
|
};
|
|
5755
6011
|
}
|
|
6012
|
+
/** Roots a skill's directory may sit under, relative to the directory the scope resolves to. */
|
|
6013
|
+
const SKILL_CONTAINER_DIRS = [".claude/skills", ".agents/skills"];
|
|
6014
|
+
/**
|
|
6015
|
+
* Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
|
|
6016
|
+
*
|
|
6017
|
+
* The `Skill` tool reports only the skill's name, so the file has to be located by probing the layouts skills
|
|
6018
|
+
* actually use: project- and user-level `.claude/skills` (plus this repo's `.agents/skills` source of truth), and
|
|
6019
|
+
* for a `<prefix>:<name>` spelling either a plugin (`.claude/plugins/<prefix>/skills/<name>`) or a
|
|
6020
|
+
* directory-scoped skill (`<prefix>/.claude/skills/<name>`), which share that spelling. Only a path that exists
|
|
6021
|
+
* on disk is returned, so a wrong guess costs nothing and clients never render a link to a missing file.
|
|
6022
|
+
*/
|
|
6023
|
+
function resolveSkillPath(skillName, cwd) {
|
|
6024
|
+
if (!cwd) {
|
|
6025
|
+
return undefined;
|
|
6026
|
+
}
|
|
6027
|
+
const colon = skillName.indexOf(":");
|
|
6028
|
+
const scope = colon < 0 ? undefined : skillName.slice(0, colon);
|
|
6029
|
+
const name = colon < 0 ? skillName : skillName.slice(colon + 1);
|
|
6030
|
+
if (!name) {
|
|
6031
|
+
return undefined;
|
|
6032
|
+
}
|
|
6033
|
+
const candidates = [];
|
|
6034
|
+
const addCandidates = (base) => {
|
|
6035
|
+
for (const container of SKILL_CONTAINER_DIRS) {
|
|
6036
|
+
candidates.push(path.join(base, container, name, "SKILL.md"));
|
|
6037
|
+
}
|
|
6038
|
+
};
|
|
6039
|
+
if (scope) {
|
|
6040
|
+
// A `<prefix>:<name>` skill is either directory-scoped or a plugin's; both spellings look identical.
|
|
6041
|
+
addCandidates(path.join(cwd, scope));
|
|
6042
|
+
candidates.push(path.join(cwd, ".claude/plugins", scope, "skills", name, "SKILL.md"));
|
|
6043
|
+
}
|
|
6044
|
+
addCandidates(cwd);
|
|
6045
|
+
addCandidates(os.homedir());
|
|
6046
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
6047
|
+
}
|
|
5756
6048
|
/** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
|
|
5757
6049
|
* notification for a tool_use. Shared by every site that surfaces a tool call:
|
|
5758
6050
|
* the streamed tool_use path (first encounter → tool_call, later encounter →
|
|
@@ -5763,7 +6055,7 @@ function claudeCodeMetaFromToolUse(toolUse) {
|
|
|
5763
6055
|
function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
|
|
5764
6056
|
if (refine) {
|
|
5765
6057
|
return {
|
|
5766
|
-
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
|
|
6058
|
+
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd) },
|
|
5767
6059
|
toolCallId: toolUse.id,
|
|
5768
6060
|
sessionUpdate: "tool_call_update",
|
|
5769
6061
|
rawInput,
|
|
@@ -5772,7 +6064,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
|
|
|
5772
6064
|
}
|
|
5773
6065
|
return {
|
|
5774
6066
|
_meta: {
|
|
5775
|
-
claudeCode: claudeCodeMetaFromToolUse(toolUse),
|
|
6067
|
+
claudeCode: claudeCodeMetaFromToolUse(toolUse, cwd),
|
|
5776
6068
|
...(toolUse.name === "Bash" && supportsTerminalOutput
|
|
5777
6069
|
? { terminal_info: { terminal_id: toolUse.id } }
|
|
5778
6070
|
: {}),
|
|
@@ -5798,7 +6090,7 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
|
5798
6090
|
const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
|
|
5799
6091
|
return {
|
|
5800
6092
|
_meta: {
|
|
5801
|
-
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
|
|
6093
|
+
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }, cwd),
|
|
5802
6094
|
},
|
|
5803
6095
|
toolCallId: toolUse.id,
|
|
5804
6096
|
sessionUpdate: "tool_call_update",
|
|
@@ -6081,20 +6373,40 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6081
6373
|
}
|
|
6082
6374
|
if (isTaskTool(toolUse.name)) {
|
|
6083
6375
|
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
|
|
6084
|
-
// TaskCreate / TaskUpdate mutate the accumulated task list
|
|
6085
|
-
//
|
|
6086
|
-
//
|
|
6087
|
-
//
|
|
6376
|
+
// TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
|
|
6377
|
+
// reconciles it from the SDK's authoritative snapshot, which repairs
|
|
6378
|
+
// resumed or compacted sessions whose creating calls are no longer in
|
|
6379
|
+
// replay history. TaskGet is read-only and remains suppressed. Plan
|
|
6380
|
+
// updates always carry the full accumulated snapshot, mirroring the
|
|
6381
|
+
// legacy TodoWrite behavior.
|
|
6088
6382
|
const isError = "is_error" in chunk && chunk.is_error;
|
|
6383
|
+
let shouldEmitTaskPlan = false;
|
|
6089
6384
|
if (!isError) {
|
|
6090
6385
|
if (toolUse.name === "TaskCreate") {
|
|
6091
|
-
applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(chunk.content));
|
|
6386
|
+
applyTaskCreate(taskState, toolUse.input, parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content));
|
|
6387
|
+
shouldEmitTaskPlan = true;
|
|
6092
6388
|
}
|
|
6093
6389
|
else if (toolUse.name === "TaskUpdate") {
|
|
6094
|
-
|
|
6390
|
+
const input = toolUse.input;
|
|
6391
|
+
const output = parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
|
|
6392
|
+
parseTaskUpdateOutput(chunk.content, input?.taskId);
|
|
6393
|
+
// Older CLI transcripts have no structured output, so retain the
|
|
6394
|
+
// input-based fallback. When an output is available, only apply a
|
|
6395
|
+
// confirmed update for the same task.
|
|
6396
|
+
if (!output || (output.success && output.taskId === input?.taskId)) {
|
|
6397
|
+
applyTaskUpdate(taskState, input);
|
|
6398
|
+
shouldEmitTaskPlan = true;
|
|
6399
|
+
}
|
|
6400
|
+
}
|
|
6401
|
+
else if (toolUse.name === "TaskList") {
|
|
6402
|
+
const output = parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
|
|
6403
|
+
if (output) {
|
|
6404
|
+
applyTaskList(taskState, output);
|
|
6405
|
+
shouldEmitTaskPlan = true;
|
|
6406
|
+
}
|
|
6095
6407
|
}
|
|
6096
6408
|
}
|
|
6097
|
-
if (
|
|
6409
|
+
if (shouldEmitTaskPlan) {
|
|
6098
6410
|
update = {
|
|
6099
6411
|
sessionUpdate: "plan",
|
|
6100
6412
|
entries: taskStateToPlanEntries(taskState),
|