@agentclientprotocol/claude-agent-acp 0.73.0 → 0.74.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/dist/acp-agent.d.ts +86 -3
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +527 -205
- package/dist/hide-claude-auth.d.ts +145 -0
- package/dist/hide-claude-auth.d.ts.map +1 -0
- package/dist/hide-claude-auth.js +231 -0
- package/dist/session-failure-extension.d.ts +15 -0
- package/dist/session-failure-extension.d.ts.map +1 -1
- package/dist/session-failure-extension.js +22 -0
- package/package.json +1 -1
package/dist/acp-agent.js
CHANGED
|
@@ -23,6 +23,7 @@ import { buildClaudePermissionPresentation } from "./permissions/presentation.js
|
|
|
23
23
|
import { decodeClaudePermissionResponse } from "./permissions/response.js";
|
|
24
24
|
import { SettingsManager } from "./settings.js";
|
|
25
25
|
import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
|
|
26
|
+
import { billsClaudeSubscription, CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE, CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON, claudeLoginRequiredError, claudeSubscriptionNotSupportedError, holdsNonSubscriptionCredential, refuseClaudeSubscriptionTurn, shouldHideClaudeAuth, warnClaudeSubscriptionGuardDegraded, } from "./hide-claude-auth.js";
|
|
26
27
|
import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, agentFileChangeReportRequestId, containsFileChangeAuditMarker, createFileChangeAuditSupport, createFileChangeAuditTurnState, FILE_CHANGE_AUDIT_SERVER_NAME, isFileChangeAuditReportPhase, isFileChangeAuditTool, supportsAgentFileChangeReport, } from "./file-change-audit.js";
|
|
27
28
|
import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, unregisterHookCallback, } from "./tools.js";
|
|
28
29
|
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
@@ -208,6 +209,9 @@ function computeSessionFingerprint(params) {
|
|
|
208
209
|
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
|
209
210
|
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
|
210
211
|
}
|
|
212
|
+
/** The JSON-RPC code ACP assigns to `authRequired`. Read from the SDK so it
|
|
213
|
+
* cannot drift from the errors this agent throws. */
|
|
214
|
+
const AUTH_REQUIRED_CODE = RequestError.authRequired().code;
|
|
211
215
|
const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
|
|
212
216
|
const PROVIDER_ID = "main";
|
|
213
217
|
const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
@@ -354,9 +358,6 @@ function isMuslLibc() {
|
|
|
354
358
|
const report = process.report?.getReport();
|
|
355
359
|
return !report?.header?.glibcVersionRuntime;
|
|
356
360
|
}
|
|
357
|
-
function shouldHideClaudeAuth() {
|
|
358
|
-
return process.argv.includes("--hide-claude-auth");
|
|
359
|
-
}
|
|
360
361
|
/** Returned to clients when a prompt or cancel targets a session whose SDK
|
|
361
362
|
* query stream has already ended (ran to `done` or died). The stream is not
|
|
362
363
|
* revivable, so the only recovery is a fresh session. */
|
|
@@ -927,8 +928,29 @@ export class ClaudeAcpAgent {
|
|
|
927
928
|
sessions,
|
|
928
929
|
};
|
|
929
930
|
}
|
|
931
|
+
/**
|
|
932
|
+
* `authenticate` — the legacy gateway methods store a provider override that
|
|
933
|
+
* every later session reads. Validate the payload here, with the same base
|
|
934
|
+
* URL rule as `providers/set`. An unchecked payload either throws a
|
|
935
|
+
* `TypeError` deep in session creation, or installs an empty base URL that
|
|
936
|
+
* silently turns the `--hide-claude-auth` subscription guard off.
|
|
937
|
+
*
|
|
938
|
+
* A call that carries no gateway payload at all keeps its historical
|
|
939
|
+
* meaning: it installs no override and succeeds. That has always been a
|
|
940
|
+
* no-op here, and a client that probes the method this way must keep
|
|
941
|
+
* working. Only a payload that IS present has to be usable.
|
|
942
|
+
*/
|
|
930
943
|
async authenticate(_params) {
|
|
931
944
|
if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
|
|
945
|
+
const gateway = _params._meta?.gateway;
|
|
946
|
+
if (gateway !== undefined && gateway !== null) {
|
|
947
|
+
if (typeof gateway !== "object" || !isValidBaseUrl(gateway.baseUrl)) {
|
|
948
|
+
throw RequestError.invalidParams({ baseUrl: gateway.baseUrl }, "`_meta.gateway.baseUrl` must be a non-empty absolute http(s) URL.");
|
|
949
|
+
}
|
|
950
|
+
if (gateway.headers !== undefined && typeof gateway.headers !== "object") {
|
|
951
|
+
throw RequestError.invalidParams(undefined, "`_meta.gateway.headers` must be an object of header names to values.");
|
|
952
|
+
}
|
|
953
|
+
}
|
|
932
954
|
this.gatewayAuthRequest = _params;
|
|
933
955
|
return;
|
|
934
956
|
}
|
|
@@ -1053,16 +1075,22 @@ export class ClaudeAcpAgent {
|
|
|
1053
1075
|
async prompt(params) {
|
|
1054
1076
|
if (this.providerUpdate)
|
|
1055
1077
|
await this.providerUpdate;
|
|
1056
|
-
|
|
1078
|
+
let session = this.sessions[params.sessionId];
|
|
1057
1079
|
if (!session) {
|
|
1058
1080
|
throw new Error("Session not found");
|
|
1059
1081
|
}
|
|
1082
|
+
const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session);
|
|
1083
|
+
if (signOutRespawn)
|
|
1084
|
+
session = await signOutRespawn;
|
|
1060
1085
|
// The SDK query stream already terminated (see `queryClosed`); its iterator
|
|
1061
1086
|
// can't be revived, so enqueueing here would hang on a deferred that never
|
|
1062
1087
|
// settles. Fail clearly and let the client start a fresh session.
|
|
1063
1088
|
if (session.queryClosed) {
|
|
1064
1089
|
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1065
1090
|
}
|
|
1091
|
+
const subscriptionGuard = this.runClaudeSubscriptionGuard(params.sessionId, session);
|
|
1092
|
+
if (subscriptionGuard)
|
|
1093
|
+
await subscriptionGuard;
|
|
1066
1094
|
if (session.autoModeFallbackWarningPending) {
|
|
1067
1095
|
await this.sessionModes.publishFallbackWarning(params.sessionId, session);
|
|
1068
1096
|
}
|
|
@@ -1119,6 +1147,139 @@ export class ClaudeAcpAgent {
|
|
|
1119
1147
|
await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid);
|
|
1120
1148
|
return response;
|
|
1121
1149
|
}
|
|
1150
|
+
/** `--hide-claude-auth` applies only to the CLI's own login. A provider
|
|
1151
|
+
* override (`providers/set` or gateway `authenticate`) routes traffic away
|
|
1152
|
+
* from it, so the subscription guard is off while one is active. */
|
|
1153
|
+
claudeSubscriptionGuardActive() {
|
|
1154
|
+
return shouldHideClaudeAuth() && this.resolveProviderConfig() === null;
|
|
1155
|
+
}
|
|
1156
|
+
/** Record that the CLI signed out during this session, and end the query.
|
|
1157
|
+
*
|
|
1158
|
+
* Only under `--hide-claude-auth`. The account cached at `initialize` is
|
|
1159
|
+
* this integration's source of truth, and a sign-out proves that account
|
|
1160
|
+
* wrong: the credential behind it is gone, and whatever replaces it is
|
|
1161
|
+
* invisible until a new `initialize` runs. Closing the query here makes the
|
|
1162
|
+
* next turn recreate it, so the creation guard decides on the real account.
|
|
1163
|
+
*
|
|
1164
|
+
* Idempotent: one sign-out reaches this twice (the synthetic login message
|
|
1165
|
+
* and the turn's error-shaped result), and the second call must not close
|
|
1166
|
+
* a stream the first one already closed. Closing settles the queued turns
|
|
1167
|
+
* through the consumer's end-of-stream path, which rejects each one. */
|
|
1168
|
+
markSessionForSignOutRespawn(sessionId, session) {
|
|
1169
|
+
if (!shouldHideClaudeAuth() || session.needsSignOutRespawn) {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (!session.creationParams) {
|
|
1173
|
+
this.logger.error(`Session ${sessionId}: signed out, but the creation params are missing; cannot recreate the query`);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
session.needsSignOutRespawn = true;
|
|
1177
|
+
this.logger.log(`Session ${sessionId}: signed out; the query is recreated on the next turn`);
|
|
1178
|
+
this.closeQueryStream(session);
|
|
1179
|
+
}
|
|
1180
|
+
/** Recreate the query of a signed-out session, keeping the ACP session id and
|
|
1181
|
+
* resuming the Claude session so the history survives. Returns the session
|
|
1182
|
+
* the caller must go on with: the new one, or the argument when no
|
|
1183
|
+
* recreation is due.
|
|
1184
|
+
*
|
|
1185
|
+
* When the CLI never persisted that conversation — the first turn was the
|
|
1186
|
+
* one that signed out — the resume cannot succeed, so a fresh query starts
|
|
1187
|
+
* under the same id. The session then keeps working, with an empty history.
|
|
1188
|
+
*
|
|
1189
|
+
* The recreation runs the full creation guard, so a subscription account is
|
|
1190
|
+
* refused with the subscription reason, a still-signed-out account with the
|
|
1191
|
+
* plain sign-out error, and an accepted credential proceeds. A refusal keeps
|
|
1192
|
+
* the old husk in the session map, so the client can sign in and retry on
|
|
1193
|
+
* the same session instead of meeting "Session not found".
|
|
1194
|
+
*
|
|
1195
|
+
* Returns `undefined`, not a resolved promise, when no recreation is due:
|
|
1196
|
+
* the callers enqueue their turn in one synchronous section, and an extra
|
|
1197
|
+
* microtask there would let the caller observe a half-built turn. */
|
|
1198
|
+
respawnSignedOutSession(sessionId, session) {
|
|
1199
|
+
if (!session.needsSignOutRespawn) {
|
|
1200
|
+
return undefined;
|
|
1201
|
+
}
|
|
1202
|
+
return this.awaitSignOutRespawn(sessionId, session);
|
|
1203
|
+
}
|
|
1204
|
+
async awaitSignOutRespawn(sessionId, session) {
|
|
1205
|
+
const respawn = (session.signOutRespawn ??= this.recreateSignedOutQuery(sessionId, session).finally(() => {
|
|
1206
|
+
session.signOutRespawn = undefined;
|
|
1207
|
+
}));
|
|
1208
|
+
await respawn;
|
|
1209
|
+
const respawned = this.sessions[sessionId];
|
|
1210
|
+
if (!respawned) {
|
|
1211
|
+
throw new Error("Session not found");
|
|
1212
|
+
}
|
|
1213
|
+
return respawned;
|
|
1214
|
+
}
|
|
1215
|
+
async recreateSignedOutQuery(sessionId, session) {
|
|
1216
|
+
const creationParams = session.creationParams;
|
|
1217
|
+
if (!creationParams) {
|
|
1218
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1219
|
+
}
|
|
1220
|
+
this.logger.log(`Recreating Claude session ${sessionId} after a sign-out`);
|
|
1221
|
+
// Already closed by `markSessionForSignOutRespawn`; idempotent here so a
|
|
1222
|
+
// husk that reached this by another route still releases its resources.
|
|
1223
|
+
this.closeQueryStream(session);
|
|
1224
|
+
try {
|
|
1225
|
+
// `resume` names the Claude session, which shares the ACP session id, so
|
|
1226
|
+
// the new query continues the same conversation under the same id.
|
|
1227
|
+
await this.createSession(creationParams, { resume: sessionId });
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
if (error instanceof RequestError && error.code === RequestError.resourceNotFound().code) {
|
|
1231
|
+
// The CLI never wrote this conversation, because the very first turn
|
|
1232
|
+
// was the one that signed out. Resuming it fails for ever, so start a
|
|
1233
|
+
// fresh query under the same ACP session id: an empty history is a far
|
|
1234
|
+
// better answer than a session the client can never use again.
|
|
1235
|
+
this.logger.log(`session ${sessionId} was never persisted; starting a fresh query under the same id`);
|
|
1236
|
+
try {
|
|
1237
|
+
await this.createSession(creationParams, { reuseSessionId: sessionId });
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
catch (fallbackError) {
|
|
1241
|
+
if (!this.sessions[sessionId]) {
|
|
1242
|
+
this.sessions[sessionId] = session;
|
|
1243
|
+
}
|
|
1244
|
+
throw fallbackError;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
// Keep the husk addressable: the client answers the refusal with its own
|
|
1248
|
+
// auth flow and then retries this session.
|
|
1249
|
+
if (!this.sessions[sessionId]) {
|
|
1250
|
+
this.sessions[sessionId] = session;
|
|
1251
|
+
}
|
|
1252
|
+
throw error;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/** Run the `--hide-claude-auth` guard before a turn starts. The returned
|
|
1256
|
+
* promise rejects with the `authRequired` error when a claude.ai
|
|
1257
|
+
* subscription would pay, or when the account holds no credential this
|
|
1258
|
+
* integration accepts. Every entry point that starts a turn must await it,
|
|
1259
|
+
* so the refusal reaches the client instead of a detached promise.
|
|
1260
|
+
*
|
|
1261
|
+
* Returns `undefined`, not a resolved promise, while the guard is off: the
|
|
1262
|
+
* callers run in the same synchronous section as the turn they enqueue, and
|
|
1263
|
+
* an extra microtask there would let the caller observe a half-built turn. */
|
|
1264
|
+
runClaudeSubscriptionGuard(sessionId, session) {
|
|
1265
|
+
if (!this.claudeSubscriptionGuardActive()) {
|
|
1266
|
+
return undefined;
|
|
1267
|
+
}
|
|
1268
|
+
return refuseClaudeSubscriptionTurn({
|
|
1269
|
+
sessionId,
|
|
1270
|
+
query: session.query,
|
|
1271
|
+
guardState: (session.claudeSubscriptionGuard ??= {}),
|
|
1272
|
+
logger: this.logger,
|
|
1273
|
+
sessionFailures: () => new SessionFailureController({
|
|
1274
|
+
sessionId,
|
|
1275
|
+
state: session.sessionFailureState,
|
|
1276
|
+
capabilities: this.clientCapabilities,
|
|
1277
|
+
isCurrent: () => this.sessions[sessionId] === session,
|
|
1278
|
+
sendUpdate: (notification) => this.client.sessionUpdate(notification),
|
|
1279
|
+
logger: this.logger,
|
|
1280
|
+
}),
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1122
1283
|
async goal(params) {
|
|
1123
1284
|
const command = params.action === "set" ? `/goal ${params.objective}` : "/goal clear";
|
|
1124
1285
|
const prompt = [{ type: "text", text: command }];
|
|
@@ -1212,10 +1373,13 @@ export class ClaudeAcpAgent {
|
|
|
1212
1373
|
* `startedNewTurn` result are preserved for compatibility. */
|
|
1213
1374
|
async steer(params) {
|
|
1214
1375
|
const sessionId = params.sessionId;
|
|
1215
|
-
|
|
1376
|
+
let session = this.sessions[sessionId];
|
|
1216
1377
|
if (!session) {
|
|
1217
1378
|
throw new Error("Session not found");
|
|
1218
1379
|
}
|
|
1380
|
+
const signOutRespawn = this.respawnSignedOutSession(sessionId, session);
|
|
1381
|
+
if (signOutRespawn)
|
|
1382
|
+
session = await signOutRespawn;
|
|
1219
1383
|
if (session.queryClosed) {
|
|
1220
1384
|
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1221
1385
|
}
|
|
@@ -1235,6 +1399,13 @@ export class ClaudeAcpAgent {
|
|
|
1235
1399
|
// a normal session/prompt whose lifecycle owns the continuation result.
|
|
1236
1400
|
return { outcome: "promptRequired", reason: "noRunningTurn" };
|
|
1237
1401
|
}
|
|
1402
|
+
// The detached `prompt()` below swallows its own rejection, so run the
|
|
1403
|
+
// `--hide-claude-auth` guard here and let it reject `steer` itself.
|
|
1404
|
+
// Otherwise the refusal never reaches the client and the Host reads
|
|
1405
|
+
// "startedNewTurn" for a turn that never started.
|
|
1406
|
+
const subscriptionGuard = this.runClaudeSubscriptionGuard(sessionId, session);
|
|
1407
|
+
if (subscriptionGuard)
|
|
1408
|
+
await subscriptionGuard;
|
|
1238
1409
|
// Preserve the established default for Hosts that do not opt in. This is
|
|
1239
1410
|
// intentionally detached for compatibility with the existing contract.
|
|
1240
1411
|
this.prompt(promptRequest).catch((error) => {
|
|
@@ -1805,8 +1976,40 @@ export class ClaudeAcpAgent {
|
|
|
1805
1976
|
};
|
|
1806
1977
|
/** Complete a negotiated terminal failure on the prompt response itself,
|
|
1807
1978
|
* which is the canonical AIR carrier. Legacy clients keep the historical
|
|
1808
|
-
* JSON-RPC rejection path.
|
|
1979
|
+
* JSON-RPC rejection path.
|
|
1980
|
+
*
|
|
1981
|
+
* `auth_required` is the exception: ACP defines its JSON-RPC error as the
|
|
1982
|
+
* signal that starts the client's own auth flow (AIR parks the refused
|
|
1983
|
+
* prompt, shows its method chooser, and resumes the prompt after sign-in),
|
|
1984
|
+
* so replacing it with a successful `end_turn` would disable that flow.
|
|
1985
|
+
* Every client keeps the rejection; capable clients additionally receive
|
|
1986
|
+
* the signed-out *state* as one session-scoped failure whose `login`
|
|
1987
|
+
* action remains the way back in after the client's auth prompt is
|
|
1988
|
+
* dismissed. Its title stays the policy's client-neutral fallback — the
|
|
1989
|
+
* CLI's own text ("… Please run /login") is TUI advice, meaningless in an
|
|
1990
|
+
* ACP client, so it travels as expandable details instead. */
|
|
1809
1991
|
const failActiveWithSessionFailure = async (kind, error, title) => {
|
|
1992
|
+
if (kind === "auth_required") {
|
|
1993
|
+
// One sign-out arrives twice — the synthetic login assistant message
|
|
1994
|
+
// and the turn's error-shaped result repeat the same text — and the
|
|
1995
|
+
// signed-out state does not change in between: publish once, and skip
|
|
1996
|
+
// republishing while the previous sign-out is still active. A retry
|
|
1997
|
+
// warning of the same kind is not a sign-out and must not suppress it.
|
|
1998
|
+
if (!sessionFailures.hasActiveSessionError(kind)) {
|
|
1999
|
+
await publishSessionFailure(kind, { turnScoped: false, details: title });
|
|
2000
|
+
}
|
|
2001
|
+
// Preserve legacy codes: only explicit `/login` signals trigger ACP's auth flow.
|
|
2002
|
+
// The first delivery rejects the turn, so the second one finds it settled.
|
|
2003
|
+
// That is the normal course here, not the lost-failure case `failActive`
|
|
2004
|
+
// logs an error for.
|
|
2005
|
+
if (session.activeTurn && !session.activeTurn.settled)
|
|
2006
|
+
failActive(error);
|
|
2007
|
+
// The row is published and the turn is rejected. Under
|
|
2008
|
+
// `--hide-claude-auth` the account cached at `initialize` is now
|
|
2009
|
+
// wrong, so end the query and let the next turn recreate it.
|
|
2010
|
+
this.markSessionForSignOutRespawn(params.sessionId, session);
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
1810
2013
|
if (!supportsAirSessionFailures(this.clientCapabilities)) {
|
|
1811
2014
|
failActive(error);
|
|
1812
2015
|
return;
|
|
@@ -2607,15 +2810,20 @@ export class ClaudeAcpAgent {
|
|
|
2607
2810
|
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
2608
2811
|
break;
|
|
2609
2812
|
case "api_retry": {
|
|
2813
|
+
const kind = message.error_status === null
|
|
2814
|
+
? "transport_lost"
|
|
2815
|
+
: providerFailureCategory(message.error);
|
|
2816
|
+
// A 401 retry is the CLI's credential re-check: it gives up on
|
|
2817
|
+
// the first attempt and reports the sign-out, which is the real
|
|
2818
|
+
// signal and carries the `login` action. A "Retrying" warning
|
|
2819
|
+
// here would outlive the `auth_required` rejection with no
|
|
2820
|
+
// action to clear it (issue #1072).
|
|
2821
|
+
if (kind === "auth_required")
|
|
2822
|
+
break;
|
|
2610
2823
|
const title = message.error_status === null
|
|
2611
2824
|
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.`
|
|
2612
2825
|
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.`;
|
|
2613
|
-
await publishSessionFailure(
|
|
2614
|
-
? "transport_lost"
|
|
2615
|
-
: providerFailureCategory(message.error), {
|
|
2616
|
-
title,
|
|
2617
|
-
severity: "warning",
|
|
2618
|
-
});
|
|
2826
|
+
await publishSessionFailure(kind, { title, severity: "warning" });
|
|
2619
2827
|
break;
|
|
2620
2828
|
}
|
|
2621
2829
|
case "model_refusal_fallback": {
|
|
@@ -3077,6 +3285,15 @@ export class ClaudeAcpAgent {
|
|
|
3077
3285
|
if (!message.is_error && lastAssistantModel !== null) {
|
|
3078
3286
|
const activeTurnId = session.activeTurn?.promptUuid;
|
|
3079
3287
|
await sessionFailures.clear((failure) => failure.recoveryPolicy === "real_model_success" ||
|
|
3288
|
+
// A real model answered, so the session is signed in. The
|
|
3289
|
+
// `auth_status` message is the primary recovery signal, but
|
|
3290
|
+
// it reports a login the query process itself runs, and a
|
|
3291
|
+
// client can sign the user in out of band (AIR runs
|
|
3292
|
+
// `claude /login` in a terminal, in a separate process).
|
|
3293
|
+
// Without this a stale signed-out record would outlive the
|
|
3294
|
+
// sign-out and suppress the next one, which the
|
|
3295
|
+
// `auth_required` dedupe below reads.
|
|
3296
|
+
failure.recoveryPolicy === "auth_status" ||
|
|
3080
3297
|
(failure.severity === "warning" && failure.turnId === activeTurnId));
|
|
3081
3298
|
}
|
|
3082
3299
|
switch (message.subtype) {
|
|
@@ -4036,6 +4253,17 @@ export class ClaudeAcpAgent {
|
|
|
4036
4253
|
}
|
|
4037
4254
|
}
|
|
4038
4255
|
}
|
|
4256
|
+
/** Release a query that was spawned but never registered as a session. */
|
|
4257
|
+
discardUnregisteredQuery(q, input, settingsManager) {
|
|
4258
|
+
settingsManager.dispose();
|
|
4259
|
+
input.end();
|
|
4260
|
+
try {
|
|
4261
|
+
q.close();
|
|
4262
|
+
}
|
|
4263
|
+
catch (error) {
|
|
4264
|
+
this.logger.error("Failed to close unregistered Claude query", error);
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4039
4267
|
/** Mark a session's SDK query stream as permanently ended and release the
|
|
4040
4268
|
* resources tied to it: drop the consumer handle, dispose the settings
|
|
4041
4269
|
* watchers, end the input stream, and close the query (which terminates the
|
|
@@ -5150,6 +5378,12 @@ export class ClaudeAcpAgent {
|
|
|
5150
5378
|
else if (creationOpts.resume) {
|
|
5151
5379
|
sessionId = creationOpts.resume;
|
|
5152
5380
|
}
|
|
5381
|
+
else if (creationOpts.reuseSessionId) {
|
|
5382
|
+
// A new conversation that keeps the old id. `resume` stays unset, so the
|
|
5383
|
+
// id below reaches the SDK as `options.sessionId` — the caller-chosen id
|
|
5384
|
+
// of a fresh session.
|
|
5385
|
+
sessionId = creationOpts.reuseSessionId;
|
|
5386
|
+
}
|
|
5153
5387
|
else {
|
|
5154
5388
|
sessionId = randomUUID();
|
|
5155
5389
|
}
|
|
@@ -5467,197 +5701,225 @@ export class ClaudeAcpAgent {
|
|
|
5467
5701
|
prompt: input,
|
|
5468
5702
|
options,
|
|
5469
5703
|
});
|
|
5470
|
-
|
|
5704
|
+
// `query()` spawns the CLI at once. Any throw between here and the
|
|
5705
|
+
// registration in `this.sessions` would leave that child process running
|
|
5706
|
+
// with nobody holding the query, so discard it before propagating.
|
|
5471
5707
|
try {
|
|
5472
|
-
|
|
5708
|
+
let initializationResult;
|
|
5709
|
+
try {
|
|
5710
|
+
initializationResult = await q.initializationResult();
|
|
5711
|
+
}
|
|
5712
|
+
catch (error) {
|
|
5713
|
+
if (creationOpts.resume &&
|
|
5714
|
+
error instanceof Error &&
|
|
5715
|
+
(error.message === "Query closed before response received" ||
|
|
5716
|
+
error.message.includes("No conversation found with session ID"))) {
|
|
5717
|
+
throw RequestError.resourceNotFound(sessionId);
|
|
5718
|
+
}
|
|
5719
|
+
throw error;
|
|
5720
|
+
}
|
|
5721
|
+
// Shared with the per-turn guard, so "warn once" spans the whole session.
|
|
5722
|
+
const claudeSubscriptionGuard = {};
|
|
5723
|
+
if (this.claudeSubscriptionGuardActive()) {
|
|
5724
|
+
if (!initializationResult.account) {
|
|
5725
|
+
warnClaudeSubscriptionGuardDegraded({
|
|
5726
|
+
sessionId,
|
|
5727
|
+
guardState: claudeSubscriptionGuard,
|
|
5728
|
+
logger: this.logger,
|
|
5729
|
+
cause: "the CLI reported no account at initialize",
|
|
5730
|
+
});
|
|
5731
|
+
}
|
|
5732
|
+
else if (billsClaudeSubscription(initializationResult.account)) {
|
|
5733
|
+
throw claudeSubscriptionNotSupportedError();
|
|
5734
|
+
}
|
|
5735
|
+
else if (!holdsNonSubscriptionCredential(initializationResult.account)) {
|
|
5736
|
+
// Fail closed: the account holds nothing this integration can bill.
|
|
5737
|
+
// A logged-out session must never exist, because the CLI can pick up
|
|
5738
|
+
// a claude.ai login by itself and AIR resumes a parked prompt on the
|
|
5739
|
+
// SAME session after a sign-in. Refusing before the session exists
|
|
5740
|
+
// makes every sign-in lead to a new session and a fresh `initialize`.
|
|
5741
|
+
throw claudeLoginRequiredError();
|
|
5742
|
+
}
|
|
5743
|
+
}
|
|
5744
|
+
// Apply user's `availableModels` allowlist from settings.json before any
|
|
5745
|
+
// downstream model handling. The SDK only enforces this allowlist in its
|
|
5746
|
+
// own UI, not in `initializationResult.models`, so we filter here to keep
|
|
5747
|
+
// configOptions, the current-model resolver, and the stored modelInfos
|
|
5748
|
+
// consistent with what the user configured.
|
|
5749
|
+
const settingsAvailableModels = settingsManager.getSettings().availableModels;
|
|
5750
|
+
const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
|
|
5751
|
+
const allowedModels = Array.isArray(settingsAvailableModels)
|
|
5752
|
+
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
|
|
5753
|
+
: initializationResult.models;
|
|
5754
|
+
const { modelState: models, resumedContextWindow } = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
|
|
5755
|
+
// Resolve the current model's capabilities separately from the stable
|
|
5756
|
+
// permission-mode catalog advertised to ACP clients.
|
|
5757
|
+
// A resumed session can be running a model outside the `availableModels`
|
|
5758
|
+
// allowlist (currentModelId is then the verbatim live id, see
|
|
5759
|
+
// `matchResumedModel`); its capabilities are still known to the SDK's
|
|
5760
|
+
// unfiltered list, so fall back to that before treating the model as
|
|
5761
|
+
// unknown — otherwise auto mode would be spuriously clamped and the
|
|
5762
|
+
// Fast-mode/Effort options hidden for a model that supports them.
|
|
5763
|
+
const allowlistedModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
|
|
5764
|
+
const fallbackModelInfo = allowlistedModelInfo
|
|
5765
|
+
? undefined
|
|
5766
|
+
: (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined);
|
|
5767
|
+
const currentModelInfo = allowlistedModelInfo ?? fallbackModelInfo;
|
|
5768
|
+
// Register the fallback-resolved capabilities under the verbatim live id
|
|
5769
|
+
// so every modelInfos consumer (buildConfigOptions' effort lookup, later
|
|
5770
|
+
// rebuilds via session.modelInfos) agrees with the gating below. The
|
|
5771
|
+
// picker options themselves come from `models.availableModels`, so this
|
|
5772
|
+
// adds no selectable entry. The spread keeps every capability flag
|
|
5773
|
+
// (current and future); the identity fields are overridden because the
|
|
5774
|
+
// fuzzy-matched sibling's resolvedModel/displayName/description can
|
|
5775
|
+
// describe a different context lane and would poison later resolvedModel
|
|
5776
|
+
// matching (syncModelAfterExternalSwitch) and context-window inference
|
|
5777
|
+
// (applyConfigOptionValue) if they traveled under this id.
|
|
5778
|
+
const modelInfos = fallbackModelInfo
|
|
5779
|
+
? [
|
|
5780
|
+
...allowedModels,
|
|
5781
|
+
{
|
|
5782
|
+
...fallbackModelInfo,
|
|
5783
|
+
value: models.currentModelId,
|
|
5784
|
+
displayName: models.currentModelId,
|
|
5785
|
+
description: "",
|
|
5786
|
+
resolvedModel: undefined,
|
|
5787
|
+
},
|
|
5788
|
+
]
|
|
5789
|
+
: allowedModels;
|
|
5790
|
+
const { modes, autoModeFallbackWarningPending } = await this.sessionModes.initialize({
|
|
5791
|
+
query: q,
|
|
5792
|
+
requestedMode: initialPermissionMode,
|
|
5793
|
+
currentModelInfo,
|
|
5794
|
+
currentModelId: models.currentModelId,
|
|
5795
|
+
});
|
|
5796
|
+
const agents = await discoverCustomAgents(q);
|
|
5797
|
+
// Only adopt the requested agent as the selected value if it's one we
|
|
5798
|
+
// actually surface in the picker. A built-in (filtered out above) or
|
|
5799
|
+
// otherwise-unknown name would leave the config option's `currentValue`
|
|
5800
|
+
// pointing at an entry not in its own `options` list, which clients render
|
|
5801
|
+
// as a blank/invalid selection.
|
|
5802
|
+
const requestedAgent = userProvidedOptions?.agent;
|
|
5803
|
+
const currentAgent = requestedAgent && agents.some((a) => a.name === requestedAgent)
|
|
5804
|
+
? requestedAgent
|
|
5805
|
+
: DEFAULT_AGENT_ID;
|
|
5806
|
+
// Seed Fast mode from the SDK's reported state so the UI reflects reality
|
|
5807
|
+
// (the CLI may start a session with fast mode already on, or force it off
|
|
5808
|
+
// when `fastModePerSessionOptIn` is set). The toggle is only surfaced while
|
|
5809
|
+
// the resolved model advertises `supportsFastMode`.
|
|
5810
|
+
const fastModeEnabled = initializationResult.fast_mode_state !== undefined &&
|
|
5811
|
+
fastModeStateEnabled(initializationResult.fast_mode_state);
|
|
5812
|
+
// `fast_mode_disabled_reason` reflects the post-switch model since SDK
|
|
5813
|
+
// 0.3.219 (the initialize response used to answer from the spawn-time
|
|
5814
|
+
// model). A fresh SDK session reports `sdk_opt_in_required` — the toggle IS
|
|
5815
|
+
// the opt-in — which normalizes away, so only real blockers are retained.
|
|
5816
|
+
const fastModeDisabledReason = fastModeEnabled
|
|
5817
|
+
? undefined
|
|
5818
|
+
: normalizeFastModeDisabledReason(initializationResult.fast_mode_disabled_reason);
|
|
5819
|
+
const fastMode = {
|
|
5820
|
+
supported: currentModelInfo?.supportsFastMode ?? false,
|
|
5821
|
+
enabled: fastModeEnabled,
|
|
5822
|
+
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
|
5823
|
+
disabledReason: fastModeDisabledReason,
|
|
5824
|
+
};
|
|
5825
|
+
// The Effort picker seed mirrors what the CLI itself resolves from the
|
|
5826
|
+
// persisted settings — the current model's `modelSettings` entry first
|
|
5827
|
+
// (the CLI persists /effort per model), then the legacy top-level value.
|
|
5828
|
+
// Display-only: no applyFlagSettings here. The CLI reads the same
|
|
5829
|
+
// settings, so pinning the seed at the flag layer was always redundant —
|
|
5830
|
+
// and it would override the CLI's own per-model restore on every later
|
|
5831
|
+
// model switch. Only a user's explicit ACP picker choice pins the flag
|
|
5832
|
+
// (see applyConfigOptionValue / Session.effortPinnedByUser).
|
|
5833
|
+
const configOptions = buildConfigOptions(modes, models, modelInfos, settingsEffortForModel(settingsManager.getSettings(), currentModelInfo), agents, currentAgent, fastMode);
|
|
5834
|
+
// Seed the context window WITHOUT any extra IPC on the session/new path.
|
|
5835
|
+
// On session/load, the resumed session's own `getContextUsage` report — a
|
|
5836
|
+
// response `getAvailableModels` already awaited to learn the live model
|
|
5837
|
+
// (resumed sessions ARE serviced pre-turn, unlike fresh ones) — is
|
|
5838
|
+
// authoritative and wins. Otherwise: the cached authoritative window if a
|
|
5839
|
+
// prior turn has learned it for this model (`result.modelUsage`,
|
|
5840
|
+
// cross-session), else the text heuristic, else the default. We
|
|
5841
|
+
// deliberately do NOT issue a getContextUsage call here: on a fresh
|
|
5842
|
+
// session that control request is not serviced until the first prompt
|
|
5843
|
+
// turn runs, so awaiting it — as 0.59.0 did — made session/new take ~15s
|
|
5844
|
+
// (issues #886/#880). The authoritative window arrives on the first
|
|
5845
|
+
// `result.modelUsage` and is cached from there.
|
|
5846
|
+
//
|
|
5847
|
+
// Text inference alone misses aliases that resolve to extended-context
|
|
5848
|
+
// models with no "1m" token anywhere in their id or description (e.g.
|
|
5849
|
+
// `sonnet` → claude-sonnet-5, natively ~1M): those stream
|
|
5850
|
+
// `usage_update.size: 200000` until the first result's modelUsage corrects
|
|
5851
|
+
// it — but the cache means only the FIRST session to ever run a turn on such
|
|
5852
|
+
// a model eats that window, not every fresh session after a process
|
|
5853
|
+
// restart (issue #596; a post-restart session/load is covered by the
|
|
5854
|
+
// resumed report above).
|
|
5855
|
+
//
|
|
5856
|
+
// The inference fallback is deliberately keyed to the allowlisted entry: a
|
|
5857
|
+
// fallback-resolved sibling's resolvedModel/displayName/description can
|
|
5858
|
+
// describe a different context lane than the verbatim live id (e.g. an
|
|
5859
|
+
// "opus[1m]" row matched for a bare 200k id), so on the fallback path only
|
|
5860
|
+
// the id itself is a trustworthy window signal.
|
|
5861
|
+
const seededWindow = resumedContextWindow !== null
|
|
5862
|
+
? { size: resumedContextWindow, authoritative: true }
|
|
5863
|
+
: immediateContextWindow(providerCacheKey, models.currentModelId, allowlistedModelInfo);
|
|
5864
|
+
this.sessions[sessionId] = {
|
|
5865
|
+
query: q,
|
|
5866
|
+
input: input,
|
|
5867
|
+
cancelled: false,
|
|
5868
|
+
cwd: params.cwd,
|
|
5869
|
+
sessionFingerprint: computeSessionFingerprint(params),
|
|
5870
|
+
creationParams: params,
|
|
5871
|
+
settingsManager,
|
|
5872
|
+
titles: new SessionTitles(this, sessionId),
|
|
5873
|
+
accumulatedUsage: {
|
|
5874
|
+
inputTokens: 0,
|
|
5875
|
+
outputTokens: 0,
|
|
5876
|
+
cachedReadTokens: 0,
|
|
5877
|
+
cachedWriteTokens: 0,
|
|
5878
|
+
},
|
|
5879
|
+
accumulatedModelUsage: {},
|
|
5880
|
+
lastModelUsageReading: {},
|
|
5881
|
+
modes,
|
|
5882
|
+
models,
|
|
5883
|
+
modelInfos,
|
|
5884
|
+
autoModeFallbackWarningShown: false,
|
|
5885
|
+
autoModeFallbackWarningPending,
|
|
5886
|
+
configOptions,
|
|
5887
|
+
agents,
|
|
5888
|
+
currentAgent,
|
|
5889
|
+
fastModeEnabled,
|
|
5890
|
+
fastModeDisabledReason,
|
|
5891
|
+
abortController,
|
|
5892
|
+
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
5893
|
+
forwardSubagentText,
|
|
5894
|
+
contextWindowSize: seededWindow.size,
|
|
5895
|
+
contextWindowAuthoritative: seededWindow.authoritative,
|
|
5896
|
+
providerCacheKey,
|
|
5897
|
+
taskState,
|
|
5898
|
+
toolUseCache: {},
|
|
5899
|
+
emittedToolCalls: new Set(),
|
|
5900
|
+
eagerToolCallSessions: new Map(),
|
|
5901
|
+
liveBackgroundTasks: new Map(),
|
|
5902
|
+
nativeSubagentsByTaskId: new Map(),
|
|
5903
|
+
nativeSubagentTaskIdByToolUseId: new Map(),
|
|
5904
|
+
nativeSubagentParentByToolUseId: new Map(),
|
|
5905
|
+
emittedAssistantText: false,
|
|
5906
|
+
owedTrailingIdles: 0,
|
|
5907
|
+
messageIdToUuid: new Map(),
|
|
5908
|
+
sessionFailureState: createSessionFailureState(),
|
|
5909
|
+
claudeSubscriptionGuard,
|
|
5910
|
+
fileChangeReportRequestIds: new Set(),
|
|
5911
|
+
fileChangeAuditSupport,
|
|
5912
|
+
};
|
|
5913
|
+
return {
|
|
5914
|
+
sessionId,
|
|
5915
|
+
modes,
|
|
5916
|
+
configOptions,
|
|
5917
|
+
};
|
|
5473
5918
|
}
|
|
5474
5919
|
catch (error) {
|
|
5475
|
-
|
|
5476
|
-
error instanceof Error &&
|
|
5477
|
-
(error.message === "Query closed before response received" ||
|
|
5478
|
-
error.message.includes("No conversation found with session ID"))) {
|
|
5479
|
-
throw RequestError.resourceNotFound(sessionId);
|
|
5480
|
-
}
|
|
5920
|
+
this.discardUnregisteredQuery(q, input, settingsManager);
|
|
5481
5921
|
throw error;
|
|
5482
5922
|
}
|
|
5483
|
-
if (shouldHideClaudeAuth() &&
|
|
5484
|
-
initializationResult.account.subscriptionType &&
|
|
5485
|
-
!this.gatewayAuthRequest) {
|
|
5486
|
-
throw RequestError.authRequired(undefined, "This integration does not support using claude.ai subscriptions.");
|
|
5487
|
-
}
|
|
5488
|
-
// Apply user's `availableModels` allowlist from settings.json before any
|
|
5489
|
-
// downstream model handling. The SDK only enforces this allowlist in its
|
|
5490
|
-
// own UI, not in `initializationResult.models`, so we filter here to keep
|
|
5491
|
-
// configOptions, the current-model resolver, and the stored modelInfos
|
|
5492
|
-
// consistent with what the user configured.
|
|
5493
|
-
const settingsAvailableModels = settingsManager.getSettings().availableModels;
|
|
5494
|
-
const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
|
|
5495
|
-
const allowedModels = Array.isArray(settingsAvailableModels)
|
|
5496
|
-
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
|
|
5497
|
-
: initializationResult.models;
|
|
5498
|
-
const { modelState: models, resumedContextWindow } = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
|
|
5499
|
-
// Resolve the current model's capabilities separately from the stable
|
|
5500
|
-
// permission-mode catalog advertised to ACP clients.
|
|
5501
|
-
// A resumed session can be running a model outside the `availableModels`
|
|
5502
|
-
// allowlist (currentModelId is then the verbatim live id, see
|
|
5503
|
-
// `matchResumedModel`); its capabilities are still known to the SDK's
|
|
5504
|
-
// unfiltered list, so fall back to that before treating the model as
|
|
5505
|
-
// unknown — otherwise auto mode would be spuriously clamped and the
|
|
5506
|
-
// Fast-mode/Effort options hidden for a model that supports them.
|
|
5507
|
-
const allowlistedModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
|
|
5508
|
-
const fallbackModelInfo = allowlistedModelInfo
|
|
5509
|
-
? undefined
|
|
5510
|
-
: (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined);
|
|
5511
|
-
const currentModelInfo = allowlistedModelInfo ?? fallbackModelInfo;
|
|
5512
|
-
// Register the fallback-resolved capabilities under the verbatim live id
|
|
5513
|
-
// so every modelInfos consumer (buildConfigOptions' effort lookup, later
|
|
5514
|
-
// rebuilds via session.modelInfos) agrees with the gating below. The
|
|
5515
|
-
// picker options themselves come from `models.availableModels`, so this
|
|
5516
|
-
// adds no selectable entry. The spread keeps every capability flag
|
|
5517
|
-
// (current and future); the identity fields are overridden because the
|
|
5518
|
-
// fuzzy-matched sibling's resolvedModel/displayName/description can
|
|
5519
|
-
// describe a different context lane and would poison later resolvedModel
|
|
5520
|
-
// matching (syncModelAfterExternalSwitch) and context-window inference
|
|
5521
|
-
// (applyConfigOptionValue) if they traveled under this id.
|
|
5522
|
-
const modelInfos = fallbackModelInfo
|
|
5523
|
-
? [
|
|
5524
|
-
...allowedModels,
|
|
5525
|
-
{
|
|
5526
|
-
...fallbackModelInfo,
|
|
5527
|
-
value: models.currentModelId,
|
|
5528
|
-
displayName: models.currentModelId,
|
|
5529
|
-
description: "",
|
|
5530
|
-
resolvedModel: undefined,
|
|
5531
|
-
},
|
|
5532
|
-
]
|
|
5533
|
-
: allowedModels;
|
|
5534
|
-
const { modes, autoModeFallbackWarningPending } = await this.sessionModes.initialize({
|
|
5535
|
-
query: q,
|
|
5536
|
-
requestedMode: initialPermissionMode,
|
|
5537
|
-
currentModelInfo,
|
|
5538
|
-
currentModelId: models.currentModelId,
|
|
5539
|
-
});
|
|
5540
|
-
const agents = await discoverCustomAgents(q);
|
|
5541
|
-
// Only adopt the requested agent as the selected value if it's one we
|
|
5542
|
-
// actually surface in the picker. A built-in (filtered out above) or
|
|
5543
|
-
// otherwise-unknown name would leave the config option's `currentValue`
|
|
5544
|
-
// pointing at an entry not in its own `options` list, which clients render
|
|
5545
|
-
// as a blank/invalid selection.
|
|
5546
|
-
const requestedAgent = userProvidedOptions?.agent;
|
|
5547
|
-
const currentAgent = requestedAgent && agents.some((a) => a.name === requestedAgent)
|
|
5548
|
-
? requestedAgent
|
|
5549
|
-
: DEFAULT_AGENT_ID;
|
|
5550
|
-
// Seed Fast mode from the SDK's reported state so the UI reflects reality
|
|
5551
|
-
// (the CLI may start a session with fast mode already on, or force it off
|
|
5552
|
-
// when `fastModePerSessionOptIn` is set). The toggle is only surfaced while
|
|
5553
|
-
// the resolved model advertises `supportsFastMode`.
|
|
5554
|
-
const fastModeEnabled = initializationResult.fast_mode_state !== undefined &&
|
|
5555
|
-
fastModeStateEnabled(initializationResult.fast_mode_state);
|
|
5556
|
-
// `fast_mode_disabled_reason` reflects the post-switch model since SDK
|
|
5557
|
-
// 0.3.219 (the initialize response used to answer from the spawn-time
|
|
5558
|
-
// model). A fresh SDK session reports `sdk_opt_in_required` — the toggle IS
|
|
5559
|
-
// the opt-in — which normalizes away, so only real blockers are retained.
|
|
5560
|
-
const fastModeDisabledReason = fastModeEnabled
|
|
5561
|
-
? undefined
|
|
5562
|
-
: normalizeFastModeDisabledReason(initializationResult.fast_mode_disabled_reason);
|
|
5563
|
-
const fastMode = {
|
|
5564
|
-
supported: currentModelInfo?.supportsFastMode ?? false,
|
|
5565
|
-
enabled: fastModeEnabled,
|
|
5566
|
-
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
|
5567
|
-
disabledReason: fastModeDisabledReason,
|
|
5568
|
-
};
|
|
5569
|
-
// The Effort picker seed mirrors what the CLI itself resolves from the
|
|
5570
|
-
// persisted settings — the current model's `modelSettings` entry first
|
|
5571
|
-
// (the CLI persists /effort per model), then the legacy top-level value.
|
|
5572
|
-
// Display-only: no applyFlagSettings here. The CLI reads the same
|
|
5573
|
-
// settings, so pinning the seed at the flag layer was always redundant —
|
|
5574
|
-
// and it would override the CLI's own per-model restore on every later
|
|
5575
|
-
// model switch. Only a user's explicit ACP picker choice pins the flag
|
|
5576
|
-
// (see applyConfigOptionValue / Session.effortPinnedByUser).
|
|
5577
|
-
const configOptions = buildConfigOptions(modes, models, modelInfos, settingsEffortForModel(settingsManager.getSettings(), currentModelInfo), agents, currentAgent, fastMode);
|
|
5578
|
-
// Seed the context window WITHOUT any extra IPC on the session/new path.
|
|
5579
|
-
// On session/load, the resumed session's own `getContextUsage` report — a
|
|
5580
|
-
// response `getAvailableModels` already awaited to learn the live model
|
|
5581
|
-
// (resumed sessions ARE serviced pre-turn, unlike fresh ones) — is
|
|
5582
|
-
// authoritative and wins. Otherwise: the cached authoritative window if a
|
|
5583
|
-
// prior turn has learned it for this model (`result.modelUsage`,
|
|
5584
|
-
// cross-session), else the text heuristic, else the default. We
|
|
5585
|
-
// deliberately do NOT issue a getContextUsage call here: on a fresh
|
|
5586
|
-
// session that control request is not serviced until the first prompt
|
|
5587
|
-
// turn runs, so awaiting it — as 0.59.0 did — made session/new take ~15s
|
|
5588
|
-
// (issues #886/#880). The authoritative window arrives on the first
|
|
5589
|
-
// `result.modelUsage` and is cached from there.
|
|
5590
|
-
//
|
|
5591
|
-
// Text inference alone misses aliases that resolve to extended-context
|
|
5592
|
-
// models with no "1m" token anywhere in their id or description (e.g.
|
|
5593
|
-
// `sonnet` → claude-sonnet-5, natively ~1M): those stream
|
|
5594
|
-
// `usage_update.size: 200000` until the first result's modelUsage corrects
|
|
5595
|
-
// it — but the cache means only the FIRST session to ever run a turn on such
|
|
5596
|
-
// a model eats that window, not every fresh session after a process
|
|
5597
|
-
// restart (issue #596; a post-restart session/load is covered by the
|
|
5598
|
-
// resumed report above).
|
|
5599
|
-
//
|
|
5600
|
-
// The inference fallback is deliberately keyed to the allowlisted entry: a
|
|
5601
|
-
// fallback-resolved sibling's resolvedModel/displayName/description can
|
|
5602
|
-
// describe a different context lane than the verbatim live id (e.g. an
|
|
5603
|
-
// "opus[1m]" row matched for a bare 200k id), so on the fallback path only
|
|
5604
|
-
// the id itself is a trustworthy window signal.
|
|
5605
|
-
const seededWindow = resumedContextWindow !== null
|
|
5606
|
-
? { size: resumedContextWindow, authoritative: true }
|
|
5607
|
-
: immediateContextWindow(providerCacheKey, models.currentModelId, allowlistedModelInfo);
|
|
5608
|
-
this.sessions[sessionId] = {
|
|
5609
|
-
query: q,
|
|
5610
|
-
input: input,
|
|
5611
|
-
cancelled: false,
|
|
5612
|
-
cwd: params.cwd,
|
|
5613
|
-
sessionFingerprint: computeSessionFingerprint(params),
|
|
5614
|
-
creationParams: params,
|
|
5615
|
-
settingsManager,
|
|
5616
|
-
titles: new SessionTitles(this, sessionId),
|
|
5617
|
-
accumulatedUsage: {
|
|
5618
|
-
inputTokens: 0,
|
|
5619
|
-
outputTokens: 0,
|
|
5620
|
-
cachedReadTokens: 0,
|
|
5621
|
-
cachedWriteTokens: 0,
|
|
5622
|
-
},
|
|
5623
|
-
accumulatedModelUsage: {},
|
|
5624
|
-
lastModelUsageReading: {},
|
|
5625
|
-
modes,
|
|
5626
|
-
models,
|
|
5627
|
-
modelInfos,
|
|
5628
|
-
autoModeFallbackWarningShown: false,
|
|
5629
|
-
autoModeFallbackWarningPending,
|
|
5630
|
-
configOptions,
|
|
5631
|
-
agents,
|
|
5632
|
-
currentAgent,
|
|
5633
|
-
fastModeEnabled,
|
|
5634
|
-
fastModeDisabledReason,
|
|
5635
|
-
abortController,
|
|
5636
|
-
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
5637
|
-
forwardSubagentText,
|
|
5638
|
-
contextWindowSize: seededWindow.size,
|
|
5639
|
-
contextWindowAuthoritative: seededWindow.authoritative,
|
|
5640
|
-
providerCacheKey,
|
|
5641
|
-
taskState,
|
|
5642
|
-
toolUseCache: {},
|
|
5643
|
-
emittedToolCalls: new Set(),
|
|
5644
|
-
eagerToolCallSessions: new Map(),
|
|
5645
|
-
liveBackgroundTasks: new Map(),
|
|
5646
|
-
nativeSubagentsByTaskId: new Map(),
|
|
5647
|
-
nativeSubagentTaskIdByToolUseId: new Map(),
|
|
5648
|
-
nativeSubagentParentByToolUseId: new Map(),
|
|
5649
|
-
emittedAssistantText: false,
|
|
5650
|
-
owedTrailingIdles: 0,
|
|
5651
|
-
messageIdToUuid: new Map(),
|
|
5652
|
-
sessionFailureState: createSessionFailureState(),
|
|
5653
|
-
fileChangeReportRequestIds: new Set(),
|
|
5654
|
-
fileChangeAuditSupport,
|
|
5655
|
-
};
|
|
5656
|
-
return {
|
|
5657
|
-
sessionId,
|
|
5658
|
-
modes,
|
|
5659
|
-
configOptions,
|
|
5660
|
-
};
|
|
5661
5923
|
}
|
|
5662
5924
|
/**
|
|
5663
5925
|
* Provider routing is baked into the environment of each SDK Query. Wait for
|
|
@@ -5681,19 +5943,74 @@ export class ClaudeAcpAgent {
|
|
|
5681
5943
|
this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
|
|
5682
5944
|
this.closeQueryStream(session);
|
|
5683
5945
|
delete this.sessions[sessionId];
|
|
5684
|
-
|
|
5946
|
+
try {
|
|
5947
|
+
await this.createSession(session.creationParams, { resume: sessionId });
|
|
5948
|
+
}
|
|
5949
|
+
catch (error) {
|
|
5950
|
+
// One session that cannot come back must not abort the switch. The
|
|
5951
|
+
// `--hide-claude-auth` guard makes this a normal outcome of
|
|
5952
|
+
// `providers/disable`: the override kept a subscription account
|
|
5953
|
+
// usable, and creation refuses without it. The session is already
|
|
5954
|
+
// gone, so tell the client why and go on to the next one.
|
|
5955
|
+
this.reportSessionLostOnProviderUpdate(sessionId, session, error);
|
|
5956
|
+
}
|
|
5685
5957
|
}
|
|
5686
5958
|
});
|
|
5687
|
-
|
|
5959
|
+
// Sessions and prompts await `providerUpdate` before they run. A rejected
|
|
5960
|
+
// promise stored here would reject every one of them, and would surface as
|
|
5961
|
+
// an unhandled rejection once this call returned. Keep the failure for the
|
|
5962
|
+
// caller of `providers/set` and `providers/disable` only.
|
|
5963
|
+
const waitable = update.catch((error) => {
|
|
5964
|
+
this.logger.error(`Provider update failed: ${error}`);
|
|
5965
|
+
});
|
|
5966
|
+
this.providerUpdate = waitable;
|
|
5688
5967
|
try {
|
|
5689
5968
|
await update;
|
|
5690
5969
|
}
|
|
5691
5970
|
finally {
|
|
5692
|
-
if (this.providerUpdate ===
|
|
5971
|
+
if (this.providerUpdate === waitable) {
|
|
5693
5972
|
this.providerUpdate = null;
|
|
5694
5973
|
}
|
|
5695
5974
|
}
|
|
5696
5975
|
}
|
|
5976
|
+
/** Tell a capable client that a session died during a provider switch. The
|
|
5977
|
+
* session is already removed, so the failure is published on the state it
|
|
5978
|
+
* left behind, with the refusal reason when the error carries one. */
|
|
5979
|
+
reportSessionLostOnProviderUpdate(sessionId, session, error) {
|
|
5980
|
+
this.logger.error(`Session ${sessionId}: could not be recreated for the provider update: ${error}`);
|
|
5981
|
+
const isAuthRequired = error instanceof RequestError && error.code === AUTH_REQUIRED_CODE;
|
|
5982
|
+
const reason = error instanceof RequestError &&
|
|
5983
|
+
typeof error.data === "object" &&
|
|
5984
|
+
error.data !== null &&
|
|
5985
|
+
"reason" in error.data &&
|
|
5986
|
+
typeof error.data.reason === "string"
|
|
5987
|
+
? error.data.reason
|
|
5988
|
+
: undefined;
|
|
5989
|
+
const details = reason === CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON
|
|
5990
|
+
? CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE
|
|
5991
|
+
: error instanceof Error
|
|
5992
|
+
? error.message
|
|
5993
|
+
: String(error);
|
|
5994
|
+
const controller = new SessionFailureController({
|
|
5995
|
+
sessionId,
|
|
5996
|
+
state: session.sessionFailureState,
|
|
5997
|
+
capabilities: this.clientCapabilities,
|
|
5998
|
+
// The session is gone from `this.sessions` by now, so the usual
|
|
5999
|
+
// identity check would call this publisher stale and drop the row.
|
|
6000
|
+
isCurrent: () => true,
|
|
6001
|
+
sendUpdate: (notification) => this.client.sessionUpdate(notification),
|
|
6002
|
+
logger: this.logger,
|
|
6003
|
+
});
|
|
6004
|
+
void controller
|
|
6005
|
+
.publish(isAuthRequired ? "auth_required" : "internal_error", {
|
|
6006
|
+
sessionScoped: true,
|
|
6007
|
+
details,
|
|
6008
|
+
...(reason ? { reason } : {}),
|
|
6009
|
+
})
|
|
6010
|
+
.catch((publishError) => {
|
|
6011
|
+
this.logger.error(`Session ${sessionId}: could not publish the provider-update failure: ${publishError}`);
|
|
6012
|
+
});
|
|
6013
|
+
}
|
|
5697
6014
|
}
|
|
5698
6015
|
function shouldEmitRawMessage(config, message) {
|
|
5699
6016
|
if (config === true)
|
|
@@ -5875,13 +6192,18 @@ function snapshotFromUsage(usage) {
|
|
|
5875
6192
|
* otherwise anthropic.
|
|
5876
6193
|
*/
|
|
5877
6194
|
function gatewayRequestToProviderConfig(request) {
|
|
5878
|
-
|
|
6195
|
+
// `authenticate` validates the payload before it stores one, so a stored
|
|
6196
|
+
// request always carries a usable gateway. Re-check the shape here anyway:
|
|
6197
|
+
// this function decides whether a provider override is active, and the
|
|
6198
|
+
// `--hide-claude-auth` guard is off while one is.
|
|
6199
|
+
const gateway = request?._meta?.gateway;
|
|
6200
|
+
if (!gateway || !isValidBaseUrl(gateway.baseUrl)) {
|
|
5879
6201
|
return null;
|
|
5880
6202
|
}
|
|
5881
6203
|
return {
|
|
5882
|
-
apiType: request
|
|
5883
|
-
baseUrl:
|
|
5884
|
-
headers:
|
|
6204
|
+
apiType: request?.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
|
|
6205
|
+
baseUrl: gateway.baseUrl,
|
|
6206
|
+
headers: gateway.headers ?? {},
|
|
5885
6207
|
};
|
|
5886
6208
|
}
|
|
5887
6209
|
/**
|