@agentclientprotocol/claude-agent-acp 0.72.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.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
- const session = this.sessions[params.sessionId];
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
- const session = this.sessions[sessionId];
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(message.error_status === null
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
- let initializationResult;
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
- initializationResult = await q.initializationResult();
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
- if (creationOpts.resume &&
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
- await this.createSession(session.creationParams, { resume: sessionId });
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
- this.providerUpdate = update;
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 === update) {
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
- if (!request?._meta) {
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.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
5883
- baseUrl: request._meta.gateway.baseUrl,
5884
- headers: request._meta.gateway.headers,
6204
+ apiType: request?.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
6205
+ baseUrl: gateway.baseUrl,
6206
+ headers: gateway.headers ?? {},
5885
6207
  };
5886
6208
  }
5887
6209
  /**