@agentclientprotocol/claude-agent-acp 0.74.0 → 0.75.1

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
@@ -6,6 +6,7 @@ import { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, asSdkSessionNotification, clie
6
6
  import { isNativeSubagentControlTool, isNativeSubagentControlUpdate, NativeSubagentRuntime, } from "./native-subagents.js";
7
7
  import { AIR_ASYNC_TASKS_CAPABILITY, withAirMeta } from "./air-extension.js";
8
8
  import { AsyncTaskRuntime, backgroundBashTaskFromToolResult, clientSupportsAsyncTasks, } from "./async-tasks.js";
9
+ import { AUTH_STATUS_PROBE_TIMEOUT_MS, AUTH_STATUS_UPDATE_METHOD, authStatusCapability, fromAccountInfo, fromCliStatus, gatewayAuthStatus, mergeAuthStatus, notLoggedInAuthStatus, sameAuthStatus, } from "./auth-status.js";
9
10
  import { execFile } from "node:child_process";
10
11
  import { randomUUID } from "node:crypto";
11
12
  import { existsSync } from "node:fs";
@@ -16,6 +17,8 @@ import { promisify } from "node:util";
16
17
  import packageJson from "../package.json" with { type: "json" };
17
18
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
18
19
  import { forkSession } from "./fork-session.js";
20
+ import { readResumedSession } from "./resumed-session.js";
21
+ import { SessionTiming } from "./session-timing.js";
19
22
  import { ALLOW_BYPASS, resolvePermissionMode } from "./permissions/modes.js";
20
23
  import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js";
21
24
  import { buildClaudePermissionOptions } from "./permissions/options.js";
@@ -25,11 +28,13 @@ import { SettingsManager } from "./settings.js";
25
28
  import { activeUsageLimitMessage, airSessionFailureCapabilityMeta, assistantMessageText, createSessionFailureState, isSyntheticUsageLimitMessage, providerFailureCategory, SessionFailureController, sessionFailureMeta, supportsAirSessionFailures, } from "./session-failure-extension.js";
26
29
  import { billsClaudeSubscription, CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE, CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON, claudeLoginRequiredError, claudeSubscriptionNotSupportedError, holdsNonSubscriptionCredential, refuseClaudeSubscriptionTurn, shouldHideClaudeAuth, warnClaudeSubscriptionGuardDegraded, } from "./hide-claude-auth.js";
27
30
  import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, agentFileChangeReportRequestId, containsFileChangeAuditMarker, createFileChangeAuditSupport, createFileChangeAuditTurnState, FILE_CHANGE_AUDIT_SERVER_NAME, isFileChangeAuditReportPhase, isFileChangeAuditTool, supportsAgentFileChangeReport, } from "./file-change-audit.js";
31
+ import { ContextCompactionLifecycle, contextCompactionMetadataFromBoundary, } from "./context-compaction.js";
28
32
  import { applyTaskCreate, applyTaskList, applyTaskUpdate, clearHookCallbacks, completeHookCallback, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, parseTaskListOutput, parseTaskUpdateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, unregisterHookCallback, } from "./tools.js";
29
33
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
30
34
  import { acceptedPlanToolResult, ExitPlanCoordinator, executionDiagnostic, exitPlanModeRawOutput, observeExitPlanToolResults, } from "./exit-plan.js";
31
35
  import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
32
36
  import { parseToolResultMeta } from "./tool-result-meta.js";
37
+ import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js";
33
38
  export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
34
39
  import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
35
40
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
@@ -65,6 +70,57 @@ const DEFAULT_CONTEXT_WINDOW = 200000;
65
70
  * "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
66
71
  * pre-empt a slow-but-healthy interrupt. */
67
72
  const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
73
+ const STRUCTURED_USAGE_TIMEOUT_MS = 5_000;
74
+ /** Best-effort structured presentation for a local `/usage` turn. The command
75
+ * itself always runs through Claude Code; null tells the consumer to forward
76
+ * its original output unchanged. The timeout prevents an unstable control
77
+ * request from holding an otherwise-completed local command indefinitely. */
78
+ async function structuredUsageMarkdown(query, signal, logger) {
79
+ if (signal.aborted)
80
+ return null;
81
+ let timeout;
82
+ let onAbort;
83
+ try {
84
+ const response = await Promise.race([
85
+ // Keeping the deliberately unstable method name visible makes an SDK
86
+ // upgrade fail at compile time if Anthropic removes or renames it.
87
+ query.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
88
+ new Promise((resolve) => {
89
+ timeout = setTimeout(() => resolve(null), STRUCTURED_USAGE_TIMEOUT_MS);
90
+ timeout.unref?.();
91
+ }),
92
+ new Promise((resolve) => {
93
+ onAbort = () => resolve(null);
94
+ if (signal.aborted)
95
+ onAbort();
96
+ else
97
+ signal.addEventListener("abort", onAbort, { once: true });
98
+ }),
99
+ ]);
100
+ if (response === null) {
101
+ if (!signal.aborted) {
102
+ logger.error("Structured /usage timed out; preserving Claude Code output");
103
+ }
104
+ return null;
105
+ }
106
+ const usage = parseUsageResponse(response);
107
+ if (!usage) {
108
+ logger.error("Structured /usage returned an incompatible response; preserving Claude Code output");
109
+ return null;
110
+ }
111
+ return formatUsageResponse(usage);
112
+ }
113
+ catch (error) {
114
+ logger.error(`Structured /usage failed; preserving Claude Code output: ${error}`);
115
+ return null;
116
+ }
117
+ finally {
118
+ if (timeout)
119
+ clearTimeout(timeout);
120
+ if (onAbort)
121
+ signal.removeEventListener("abort", onAbort);
122
+ }
123
+ }
68
124
  /** Claude Code keeps the OAuth callback listener open in the background after
69
125
  * `mcpAuthenticate` returns the authorization URL. The SDK does not expose
70
126
  * that listener's completion promise, so watch the server status while the
@@ -654,6 +710,25 @@ export class ClaudeAcpAgent {
654
710
  /** Serializes provider changes while every open query is recreated between turns. */
655
711
  providerUpdate = null;
656
712
  exitPlan;
713
+ /** Last auth identity reported to the client, connection-scoped like
714
+ * `authenticate`/`logout`. Undefined means "not determined yet". */
715
+ currentAuthStatus;
716
+ /** In-flight `claude auth status --json` probe, shared by every caller so
717
+ * a concurrent `initialize` and start-of-prompt read never spawn two CLI
718
+ * processes. */
719
+ cliAuthProbe = null;
720
+ /** Counts auth-affecting events (`authenticate`, `logout`) on this
721
+ * connection. A probe records it at start and publishes only if it has not
722
+ * moved, so a slow read can never overwrite a newer login or logout. */
723
+ authEpoch = 0;
724
+ /** Keeps the "session account told us nothing" note to one line per process
725
+ * instead of one per session. */
726
+ loggedUninformativeAccount = false;
727
+ /** Same, for the "client provider override active" note. */
728
+ loggedOverriddenAccount = false;
729
+ /** Same, for the "the CLI probe timed out" warning: a wedged CLI stays wedged
730
+ * and would otherwise warn once per probe, i.e. once per user prompt. */
731
+ loggedProbeTimeout = false;
657
732
  /** Grace period before a `session/cancel` forces a wedged prompt loop to
658
733
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
659
734
  * tests can shrink it. */
@@ -711,6 +786,12 @@ export class ClaudeAcpAgent {
711
786
  }
712
787
  async initialize(request) {
713
788
  this.clientCapabilities = request.clientCapabilities;
789
+ // Learn the auth identity in the background: `initialize` never waits on
790
+ // the CLI probe, and no snapshot rides in its response. When the probe
791
+ // lands it calls `setAuthStatus`, which pushes `_auth/status_update` — the
792
+ // connection's first push, and unconditional, because nothing was reported
793
+ // before it. It is therefore sent after this response, never before it.
794
+ void this.probeCliAuthStatus();
714
795
  // Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
715
796
  // Only offered when the client advertises `auth._meta.gateway` capability.
716
797
  const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
@@ -822,6 +903,11 @@ export class ClaudeAcpAgent {
822
903
  claudeCode: {
823
904
  promptQueueing: true,
824
905
  },
906
+ // Capability marker for the `authStatus` extension: presence means
907
+ // "this agent pushes its identity" and the object stays empty — it is
908
+ // never a status payload. The state itself travels on
909
+ // `_auth/status_update`; there is nothing for a client to ask for.
910
+ authStatus: authStatusCapability(),
825
911
  },
826
912
  promptCapabilities: {
827
913
  image: true,
@@ -885,6 +971,7 @@ export class ClaudeAcpAgent {
885
971
  await this.providerUpdate;
886
972
  return forkSession(params, {
887
973
  liveMessageIdToUuid: this.sessions[params.sessionId]?.messageIdToUuid,
974
+ logger: this.logger,
888
975
  messageIdForGrouping,
889
976
  });
890
977
  }
@@ -900,10 +987,14 @@ export class ClaudeAcpAgent {
900
987
  return result;
901
988
  }
902
989
  async loadSession(params) {
990
+ const timing = new SessionTiming(this.logger, "load", params.sessionId);
903
991
  if (this.providerUpdate)
904
992
  await this.providerUpdate;
905
- const result = await this.getOrCreateSession(params);
906
- await this.replaySessionHistory(params.sessionId);
993
+ const resumedSession = await readResumedSession(params.sessionId, this.logger);
994
+ const result = await this.getOrCreateSession(params, resumedSession);
995
+ timing.phase("session-ready");
996
+ await this.replaySessionHistory(params.sessionId, resumedSession.messages);
997
+ timing.phase("replay");
907
998
  // Send available commands after replay so it doesn't interleave with history
908
999
  setTimeout(() => {
909
1000
  this.sendAvailableCommandsUpdate(params.sessionId);
@@ -952,10 +1043,201 @@ export class ClaudeAcpAgent {
952
1043
  }
953
1044
  }
954
1045
  this.gatewayAuthRequest = _params;
1046
+ // The gateway holds the credentials from here on, so it replaces
1047
+ // whatever the CLI store reported. Bumping the epoch discards any probe
1048
+ // that started before this login: its answer is now stale.
1049
+ this.authEpoch += 1;
1050
+ this.setAuthStatus(gatewayAuthStatus(gatewayRequestToProviderConfig(this.gatewayAuthRequest)?.baseUrl));
955
1051
  return;
956
1052
  }
957
1053
  throw new Error("Method not implemented.");
958
1054
  }
1055
+ /**
1056
+ * Stores `next` and pushes it to the client.
1057
+ *
1058
+ * A push goes out only when the payload changed. The identity is read on many
1059
+ * occasions — each session create, each guarded turn, the start of each user
1060
+ * prompt — and almost all of them see the same login. Clients replace their
1061
+ * whole state on each update and tolerate duplicates, so a repeat is
1062
+ * harmless, but it is also pure noise; {@link sameAuthStatus} drops it.
1063
+ *
1064
+ * What the agent owes is truth — a stale or uninformative source must not
1065
+ * reach here at all (see the epoch check in the probe and the guards at
1066
+ * session create).
1067
+ */
1068
+ setAuthStatus(next) {
1069
+ if (!next) {
1070
+ return;
1071
+ }
1072
+ if (sameAuthStatus(this.currentAuthStatus, next)) {
1073
+ return;
1074
+ }
1075
+ this.currentAuthStatus = next;
1076
+ // ACP notifications get no reply, and clients that do not know the method
1077
+ // drop it silently — send unconditionally and never fail a caller on it.
1078
+ void Promise.resolve()
1079
+ .then(() => this.client.extNotification(AUTH_STATUS_UPDATE_METHOD, { authStatus: next }))
1080
+ .catch((error) => {
1081
+ this.logger.error("Failed to send _auth/status_update:", error);
1082
+ });
1083
+ }
1084
+ /**
1085
+ * Report the identity behind a session's `AccountInfo`.
1086
+ *
1087
+ * `AccountInfo` is richer than the CLI probe (it is what the live query
1088
+ * actually authenticates with). Called before the `--hide-claude-auth` guard
1089
+ * may refuse the session or the turn: that flag blocks the *use* of a
1090
+ * subscription, it does not make the state secret, and a refusal is when the
1091
+ * client most needs to know the account it was refused for.
1092
+ *
1093
+ * Two cases skip it:
1094
+ *
1095
+ * - ACP gateway *authentication* (`gatewayAuthRequest`): the gateway, not
1096
+ * this account, is the agent-owned identity — already reported as
1097
+ * `kind: "gateway"` by `authenticate`.
1098
+ * - A client-driven provider override (`providers/set`): the session then
1099
+ * routes through the client's endpoint and `AccountInfo` describes that
1100
+ * route (e.g. `apiProvider: "gateway"`), which is NOT agent-owned state.
1101
+ * `authStatus` reports the agent's own login only, so the CLI probe —
1102
+ * which reads the credential store the override never touches — stays
1103
+ * authoritative.
1104
+ *
1105
+ * An account with no identity signal (e.g. `{apiProvider: "firstParty"}`
1106
+ * under an apiKeyHelper) means "nothing to add", not "logged out" — keep
1107
+ * what the CLI probe already established instead of overwriting it.
1108
+ */
1109
+ publishSessionAccountIdentity(account) {
1110
+ if (this.providerConfig) {
1111
+ if (!this.loggedOverriddenAccount) {
1112
+ this.loggedOverriddenAccount = true;
1113
+ this.logger.log("[authStatus] client provider override active; keeping agent-owned probe state");
1114
+ }
1115
+ return;
1116
+ }
1117
+ if (this.gatewayAuthRequest) {
1118
+ return;
1119
+ }
1120
+ const fromSession = fromAccountInfo(account);
1121
+ if (fromSession) {
1122
+ this.setAuthStatus(fromSession);
1123
+ }
1124
+ else if (!this.loggedUninformativeAccount) {
1125
+ this.loggedUninformativeAccount = true;
1126
+ this.logger.log("[authStatus] session account carries no identity signal; keeping probe");
1127
+ }
1128
+ }
1129
+ /** Shares one in-flight `claude auth status --json` run between callers. The
1130
+ * promise is released once settled so a later call re-probes instead of
1131
+ * replaying a stale verdict. `fresh` forces a new run even when one is in
1132
+ * flight — `logout` needs a read that started after the credentials were
1133
+ * cleared. */
1134
+ probeCliAuthStatus(options) {
1135
+ if (!options?.fresh && this.cliAuthProbe) {
1136
+ return this.cliAuthProbe;
1137
+ }
1138
+ const probe = this.runCliAuthProbe();
1139
+ this.cliAuthProbe = probe;
1140
+ void probe.finally(() => {
1141
+ if (this.cliAuthProbe === probe) {
1142
+ this.cliAuthProbe = null;
1143
+ }
1144
+ });
1145
+ return probe;
1146
+ }
1147
+ /** Never rejects: an unavailable CLI means "not reported", not an error. */
1148
+ async runCliAuthProbe() {
1149
+ // Monotonicity: a read that started before the connection's latest
1150
+ // auth-affecting event describes a world that no longer exists. Remember
1151
+ // which one this read belongs to and drop the answer if it moved on.
1152
+ const epoch = this.authEpoch;
1153
+ // ACP gateway auth bypasses the CLI credential store entirely, so the
1154
+ // probe would report an identity that is not the one being used. A mere
1155
+ // client provider override (`providers/set`) does NOT skip the probe: it
1156
+ // reroutes traffic without touching the credential store, and the store is
1157
+ // exactly the agent-owned login `authStatus` reports.
1158
+ if (this.gatewayAuthRequest) {
1159
+ return this.currentAuthStatus;
1160
+ }
1161
+ let stdout;
1162
+ try {
1163
+ const cliPath = await claudeCliPath();
1164
+ ({ stdout } = await execFileAsync(cliPath, ["auth", "status", "--json"], {
1165
+ timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
1166
+ }));
1167
+ }
1168
+ catch (error) {
1169
+ const failed = error;
1170
+ // Node killed the child on the timeout. Whatever it printed so far is a
1171
+ // truncated fragment, never valid JSON: report nothing and keep the last
1172
+ // known state, so nothing regresses and no push goes out.
1173
+ if (failed?.killed === true && failed.signal) {
1174
+ if (!this.loggedProbeTimeout) {
1175
+ this.loggedProbeTimeout = true;
1176
+ this.logger.error("claude auth status did not answer within 5 s; the identity is not refreshed");
1177
+ }
1178
+ return this.currentAuthStatus;
1179
+ }
1180
+ // The logged-out case exits 1 while still printing valid JSON, so the
1181
+ // stdout of a failed exec is parsed just like a successful one.
1182
+ if (typeof failed?.stdout !== "string" || failed.stdout.trim().length === 0) {
1183
+ this.logger.error("claude auth status failed:", error instanceof Error ? error.message : String(error));
1184
+ return undefined;
1185
+ }
1186
+ stdout = failed.stdout;
1187
+ }
1188
+ const status = fromCliStatus(stdout);
1189
+ if (!status) {
1190
+ this.logger.error("claude auth status returned unparseable output");
1191
+ return undefined;
1192
+ }
1193
+ if (epoch !== this.authEpoch) {
1194
+ // An `authenticate` or `logout` landed while this probe was running; the
1195
+ // newer state wins and this answer is discarded, never published.
1196
+ this.logger.log("[authStatus] discarding a probe that predates the latest auth change");
1197
+ return this.currentAuthStatus;
1198
+ }
1199
+ // The CLI probe can be poorer than the session `AccountInfo` for the very
1200
+ // same login (no organization, say). Keep those extra fields rather than
1201
+ // regressing the payload; a different identity replaces it wholesale.
1202
+ const merged = mergeAuthStatus(this.currentAuthStatus, status);
1203
+ this.setAuthStatus(merged);
1204
+ this.markSessionsWhoseAccountKindChanged(status.kind);
1205
+ return merged;
1206
+ }
1207
+ /**
1208
+ * Feed the completed probe to the `--hide-claude-auth` guard.
1209
+ *
1210
+ * The account cached at `initialize` is the guard's fact, and the CLI can
1211
+ * swap the credential behind it between two turns (a Console key removed and
1212
+ * a claude.ai login put in its place) without any turn failing. A read that
1213
+ * reports a different kind of identity than the session was created on
1214
+ * proves that fact stale.
1215
+ *
1216
+ * The probe decides nothing, and it interrupts nothing. Since the read is
1217
+ * fired at the start of every user prompt, it usually lands in the middle of
1218
+ * the turn it belongs to; all it may do there is set a flag. The turn runs to
1219
+ * its end on the query it started on, the NEXT prompt consumes the flag and
1220
+ * recreates the query, the new `initialize` reports the real account, and the
1221
+ * creation guard judges it. So a probe can never refuse or abort a turn, and
1222
+ * a wrong read costs one query recreation, not a false refusal.
1223
+ */
1224
+ markSessionsWhoseAccountKindChanged(kind) {
1225
+ if (!this.claudeSubscriptionGuardActive()) {
1226
+ return;
1227
+ }
1228
+ for (const [sessionId, session] of Object.entries(this.sessions)) {
1229
+ // No kind: the account said nothing about the identity, so there is
1230
+ // nothing this read can contradict.
1231
+ if (!session.accountKind || session.queryClosed || session.needsSignOutRespawn) {
1232
+ continue;
1233
+ }
1234
+ if (kind !== session.accountKind) {
1235
+ // `endQuery: false` is the whole turn-boundary contract: the flag is
1236
+ // set now, the query dies at the recreation the next prompt runs.
1237
+ this.markSessionForSignOutRespawn(sessionId, session, { endQuery: false });
1238
+ }
1239
+ }
1240
+ }
959
1241
  async unstable_listProviders(_params) {
960
1242
  const config = this.providerConfig ?? this.defaultProviderConfig();
961
1243
  this.logger.log(`[providers/list] apiType=${config.apiType} baseUrl=${config.baseUrl} overridden=${this.providerConfig !== undefined}`);
@@ -1052,6 +1334,9 @@ export class ClaudeAcpAgent {
1052
1334
  // those paths.
1053
1335
  this.gatewayAuthRequest = undefined;
1054
1336
  this.providerConfig = undefined;
1337
+ // Any probe already running read the pre-logout world; the bump makes its
1338
+ // answer unpublishable so it cannot resurrect the identity being cleared.
1339
+ this.authEpoch += 1;
1055
1340
  // Learned context windows are per-account state too: 1M-context
1056
1341
  // entitlement is gated per org/tier, and an OAuth re-login is invisible to
1057
1342
  // the env-derived provider cache key, so windows learned under the old
@@ -1071,6 +1356,14 @@ export class ClaudeAcpAgent {
1071
1356
  : undefined;
1072
1357
  throw RequestError.internalError({ stderr: stderr || undefined }, `claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`);
1073
1358
  }
1359
+ // Re-read the store rather than assuming "none": an API key from the env
1360
+ // or a key helper survives `auth logout`. The read must start after the
1361
+ // credentials were cleared, so it never joins a probe that is already in
1362
+ // flight. If it can't be read, the logout still happened — drop the
1363
+ // now-stale identity instead of reporting it further.
1364
+ if (!(await this.probeCliAuthStatus({ fresh: true }))) {
1365
+ this.setAuthStatus(notLoggedInAuthStatus());
1366
+ }
1074
1367
  }
1075
1368
  async prompt(params) {
1076
1369
  if (this.providerUpdate)
@@ -1079,6 +1372,15 @@ export class ClaudeAcpAgent {
1079
1372
  if (!session) {
1080
1373
  throw new Error("Session not found");
1081
1374
  }
1375
+ // The one re-read per user prompt, fired here and never awaited: a prompt
1376
+ // must not wait on a CLI process, and its result is pushed on change
1377
+ // whenever it lands, mid-turn included. It runs BEFORE the guard on
1378
+ // purpose — a refused prompt is exactly the one whose retry follows a
1379
+ // sign-in in the terminal, and that retry is a new prompt, so the read
1380
+ // that finds the new credential must not be the one the refusal skipped.
1381
+ // The guard itself consumes the result only at the next turn boundary (see
1382
+ // `markSessionsWhoseAccountKindChanged`).
1383
+ void this.probeCliAuthStatus();
1082
1384
  const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session);
1083
1385
  if (signOutRespawn)
1084
1386
  session = await signOutRespawn;
@@ -1113,6 +1415,10 @@ export class ClaudeAcpAgent {
1113
1415
  session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
1114
1416
  fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
1115
1417
  }
1418
+ const isUsageCommand = params.prompt.length === 1 &&
1419
+ params.prompt[0]?.type === "text" &&
1420
+ isUsageCommandText(params.prompt[0].text);
1421
+ const usageMarkdownAbort = isUsageCommand ? new AbortController() : undefined;
1116
1422
  session.titles.onPrompt(params.prompt);
1117
1423
  // Each prompt is a Turn whose deferred the persistent consumer settles once
1118
1424
  // the turn's outcome is known. `prompt()` owns no loop: it enqueues the
@@ -1121,6 +1427,8 @@ export class ClaudeAcpAgent {
1121
1427
  const turn = {
1122
1428
  promptUuid,
1123
1429
  isLocalOnlyCommand,
1430
+ ...(isUsageCommand ? { isUsageCommand: true } : {}),
1431
+ ...(usageMarkdownAbort ? { usageMarkdownAbort } : {}),
1124
1432
  ...(fileChangeAudit ? { fileChangeAudit } : {}),
1125
1433
  settled: false,
1126
1434
  resolve: () => { },
@@ -1153,29 +1461,41 @@ export class ClaudeAcpAgent {
1153
1461
  claudeSubscriptionGuardActive() {
1154
1462
  return shouldHideClaudeAuth() && this.resolveProviderConfig() === null;
1155
1463
  }
1156
- /** Record that the CLI signed out during this session, and end the query.
1464
+ /** Record that the account cached at `initialize` is wrong, and — unless the
1465
+ * caller defers it — end the query.
1157
1466
  *
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.
1467
+ * Only under `--hide-claude-auth`. That cached account is this
1468
+ * integration's source of truth, and two things prove it wrong: a sign-out
1469
+ * during the session, and a CLI probe that finds a different identity. In
1470
+ * both cases the credential behind the cached account is gone, and whatever
1471
+ * replaces it is invisible until a new `initialize` runs. The flag makes
1472
+ * the next prompt recreate the query, so the creation guard decides on the
1473
+ * real account.
1474
+ *
1475
+ * `endQuery` says whether the query dies now. A sign-out has already killed
1476
+ * the turn, so its stream is closed at once. A probe has killed nothing: it
1477
+ * runs beside a turn that is still producing output, and closing there would
1478
+ * abort a turn the user is watching. It therefore leaves the stream alone
1479
+ * and lets the recreation at the next prompt close it (`recreateSignedOutQuery`
1480
+ * closes it too, and both are idempotent).
1163
1481
  *
1164
1482
  * Idempotent: one sign-out reaches this twice (the synthetic login message
1165
1483
  * and the turn's error-shaped result), and the second call must not close
1166
1484
  * a stream the first one already closed. Closing settles the queued turns
1167
1485
  * through the consumer's end-of-stream path, which rejects each one. */
1168
- markSessionForSignOutRespawn(sessionId, session) {
1486
+ markSessionForSignOutRespawn(sessionId, session, options) {
1169
1487
  if (!shouldHideClaudeAuth() || session.needsSignOutRespawn) {
1170
1488
  return;
1171
1489
  }
1172
1490
  if (!session.creationParams) {
1173
- this.logger.error(`Session ${sessionId}: signed out, but the creation params are missing; cannot recreate the query`);
1491
+ this.logger.error(`Session ${sessionId}: the identity behind the cached account changed, but the creation params are missing; cannot recreate the query`);
1174
1492
  return;
1175
1493
  }
1176
1494
  session.needsSignOutRespawn = true;
1177
- this.logger.log(`Session ${sessionId}: signed out; the query is recreated on the next turn`);
1178
- this.closeQueryStream(session);
1495
+ this.logger.log(`Session ${sessionId}: the identity behind the cached account changed (sign-out, or a probe that found a different login); the query is recreated on the next turn`);
1496
+ if (options?.endQuery !== false) {
1497
+ this.closeQueryStream(session);
1498
+ }
1179
1499
  }
1180
1500
  /** Recreate the query of a signed-out session, keeping the ACP session id and
1181
1501
  * resuming the Claude session so the history survives. Returns the session
@@ -1194,11 +1514,21 @@ export class ClaudeAcpAgent {
1194
1514
  *
1195
1515
  * Returns `undefined`, not a resolved promise, when no recreation is due:
1196
1516
  * the callers enqueue their turn in one synchronous section, and an extra
1197
- * microtask there would let the caller observe a half-built turn. */
1517
+ * microtask there would let the caller observe a half-built turn.
1518
+ *
1519
+ * A live turn also postpones it. A probe marks the session without ending
1520
+ * the query, so a turn can still be running when the mark is read — by a
1521
+ * `steer` injecting into it, say. Recreating there would close the stream
1522
+ * under the turn and kill output the user is watching, so the flag waits for
1523
+ * the next turn boundary. A sign-out is not affected: it closed the stream
1524
+ * when it marked the session, and a closed stream recreates immediately. */
1198
1525
  respawnSignedOutSession(sessionId, session) {
1199
1526
  if (!session.needsSignOutRespawn) {
1200
1527
  return undefined;
1201
1528
  }
1529
+ if (!session.queryClosed && (session.turnQueue ?? []).some((turn) => !turn.settled)) {
1530
+ return undefined;
1531
+ }
1202
1532
  return this.awaitSignOutRespawn(sessionId, session);
1203
1533
  }
1204
1534
  async awaitSignOutRespawn(sessionId, session) {
@@ -1270,6 +1600,9 @@ export class ClaudeAcpAgent {
1270
1600
  query: session.query,
1271
1601
  guardState: (session.claudeSubscriptionGuard ??= {}),
1272
1602
  logger: this.logger,
1603
+ // The guard reads the account anyway; reuse that read to keep the
1604
+ // reported identity current, refusal or not.
1605
+ onAccount: (account) => this.publishSessionAccountIdentity(account),
1273
1606
  sessionFailures: () => new SessionFailureController({
1274
1607
  sessionId,
1275
1608
  state: session.sessionFailureState,
@@ -1505,11 +1838,6 @@ export class ClaudeAcpAgent {
1505
1838
  // stop_reason "refusal" and structured stop_details. We capture the
1506
1839
  // human-readable explanation so the terminal `result` can surface it.
1507
1840
  let lastRefusalExplanation = null;
1508
- // Tracks whether we're inside a compaction. The SDK emits the terminal
1509
- // `status` (compact_result success/failed) twice for a single failed
1510
- // compaction, and the two messages are indistinguishable — so we report the
1511
- // outcome only while a compaction is in progress, then clear this.
1512
- let compactionInProgress = false;
1513
1841
  // Anthropic API message id of the assistant message currently being
1514
1842
  // streamed, captured from `message_start` so the streamed chunks that follow
1515
1843
  // (whose delta events don't carry it) can all be tagged with the same,
@@ -1546,6 +1874,7 @@ export class ClaudeAcpAgent {
1546
1874
  * as the turn's answer. */
1547
1875
  const subagents = (session.nativeSubagentRuntime ??= new NativeSubagentRuntime(clientSupportsSubagents(this.clientCapabilities), params.sessionId, session, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)), this.logger));
1548
1876
  const asyncTasks = (session.asyncTaskRuntime ??= new AsyncTaskRuntime(clientSupportsAsyncTasks(this.clientCapabilities), params.sessionId, async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification))));
1877
+ const compaction = new ContextCompactionLifecycle((notification) => sendUpdate(notification));
1549
1878
  const sendUpdate = async (notification) => {
1550
1879
  const { update } = notification;
1551
1880
  const claudeMeta = update._meta?.claudeCode;
@@ -1574,6 +1903,11 @@ export class ClaudeAcpAgent {
1574
1903
  return;
1575
1904
  }
1576
1905
  if (update.sessionUpdate === "agent_message_chunk") {
1906
+ if (!claudeMeta?.parentToolUseId &&
1907
+ update.content.type === "text" &&
1908
+ compaction.consumeDuplicateErrorOutput(update.content.text)) {
1909
+ return;
1910
+ }
1577
1911
  if (!claudeMeta?.parentToolUseId) {
1578
1912
  session.emittedAssistantText = true;
1579
1913
  session.titles.onAssistantText(update.content);
@@ -1638,6 +1972,12 @@ export class ClaudeAcpAgent {
1638
1972
  await sessionFailures.clear((failure) => failure.recoveryPolicy === "next_attempt" && failure.turnId !== activeTurnId);
1639
1973
  };
1640
1974
  const internalErrorForClient = (data, rawDetail) => RequestError.internalError(data, supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail);
1975
+ const ensureUsageMarkdown = (turn) => {
1976
+ if (!turn.isUsageCommand || !turn.usageMarkdownAbort)
1977
+ return undefined;
1978
+ turn.usageMarkdown ??= structuredUsageMarkdown(session.query, turn.usageMarkdownAbort.signal, this.logger);
1979
+ return turn.usageMarkdown;
1980
+ };
1641
1981
  const resetTurnScratch = () => {
1642
1982
  lastAssistantTotalUsage = null;
1643
1983
  lastAssistantUsage = null;
@@ -1646,7 +1986,6 @@ export class ClaudeAcpAgent {
1646
1986
  lastAssistantWasUsageLimit = false;
1647
1987
  lastAssistantFailureTitle = undefined;
1648
1988
  lastRefusalExplanation = null;
1649
- compactionInProgress = false;
1650
1989
  // Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
1651
1990
  // activation can fire mid-message (the replayed user echo with
1652
1991
  // --replay-user-messages lands between a message's blocks); clearing the
@@ -1679,6 +2018,7 @@ export class ClaudeAcpAgent {
1679
2018
  const activateTurn = (turn) => {
1680
2019
  session.activeTurn = turn;
1681
2020
  session.cancelled = false;
2021
+ ensureUsageMarkdown(turn);
1682
2022
  session.pendingOrphanResults = 0;
1683
2023
  session.orphanCommands?.clear();
1684
2024
  // Two-phase sweep of registry entries the level signal ended (see
@@ -1864,6 +2204,30 @@ export class ClaudeAcpAgent {
1864
2204
  * spelling of "a prompt is pending" shared by the head promotion and
1865
2205
  * the autonomous stretch-close guard. */
1866
2206
  const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
2207
+ /** Claim the structured replacement for the turn currently producing a
2208
+ * local-command output. Undefined means this is not a structured usage
2209
+ * turn (or its request failed), null means another SDK message shape
2210
+ * already delivered it, and string is the one replacement to publish. */
2211
+ const takeUsageMarkdown = async (originalOutput) => {
2212
+ const turn = session.activeTurn ?? firstUnsettledQueuedTurn();
2213
+ if (!turn)
2214
+ return undefined;
2215
+ const usageMarkdown = ensureUsageMarkdown(turn);
2216
+ if (!usageMarkdown)
2217
+ return undefined;
2218
+ const markdown = await usageMarkdown;
2219
+ if (markdown === null)
2220
+ return undefined;
2221
+ if (turn.usageMarkdownDelivered) {
2222
+ // Different SDK message shapes can mirror the same local-command
2223
+ // output. Suppress an exact mirror, but let a later, distinct frame
2224
+ // (for example an interruption diagnostic) follow the normal path.
2225
+ return turn.usageOriginalOutput === originalOutput ? null : undefined;
2226
+ }
2227
+ turn.usageMarkdownDelivered = true;
2228
+ turn.usageOriginalOutput = originalOutput;
2229
+ return markdown;
2230
+ };
1867
2231
  /** Whether any background subagent this turn spawned is still live —
1868
2232
  * while true, the turn's settlement stays deferred so the subagent's
1869
2233
  * output and permission requests land inside it (see
@@ -1932,6 +2296,7 @@ export class ClaudeAcpAgent {
1932
2296
  // Captured before the settled flip below (isHeldOpen tests !settled).
1933
2297
  const wasHeld = isHeldOpen(turn);
1934
2298
  turn.settled = true;
2299
+ turn.usageMarkdownAbort?.abort();
1935
2300
  disarmForceCancel(session);
1936
2301
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
1937
2302
  session.activeTurn = null;
@@ -2343,42 +2708,15 @@ export class ClaudeAcpAgent {
2343
2708
  }
2344
2709
  break;
2345
2710
  case "status": {
2346
- // These banners count as delivered text (via sendUpdate), so
2347
- // an echo-less turn that only ever emits them (e.g. `/compact`,
2348
- // promoted at its own result) doesn't have its result text
2349
- // re-emitted by the issue-#453 fallback.
2350
2711
  if (message.status === "compacting") {
2351
- compactionInProgress = true;
2352
- await sendUpdate({
2353
- sessionId: message.session_id,
2354
- update: {
2355
- sessionUpdate: "agent_message_chunk",
2356
- content: { type: "text", text: "Compacting..." },
2357
- },
2358
- });
2712
+ await compaction.start(message.session_id, message.uuid);
2359
2713
  }
2360
- else if (message.compact_result === "success" && compactionInProgress) {
2361
- // The SDK signals manual `/compact` completion with a status
2362
- // message carrying `compact_result`, not the `compact_boundary`
2363
- // message (which only fires when there's content to compact).
2364
- compactionInProgress = false;
2365
- await sendUpdate({
2366
- sessionId: message.session_id,
2367
- update: {
2368
- sessionUpdate: "agent_message_chunk",
2369
- content: { type: "text", text: "\n\nCompacting completed." },
2370
- },
2371
- });
2714
+ else if (message.compact_result === "success") {
2715
+ await compaction.finish(message.session_id, message.uuid, "completed");
2372
2716
  }
2373
- else if (message.compact_result === "failed" && compactionInProgress) {
2374
- compactionInProgress = false;
2375
- const reason = message.compact_error ? `: ${message.compact_error}` : ".";
2376
- await sendUpdate({
2377
- sessionId: message.session_id,
2378
- update: {
2379
- sessionUpdate: "agent_message_chunk",
2380
- content: { type: "text", text: `\n\nCompacting failed${reason}` },
2381
- },
2717
+ else if (message.compact_result === "failed") {
2718
+ await compaction.finish(message.session_id, message.uuid, "failed", {
2719
+ ...(message.compact_error ? { error: message.compact_error } : {}),
2382
2720
  });
2383
2721
  }
2384
2722
  break;
@@ -2389,26 +2727,22 @@ export class ClaudeAcpAgent {
2389
2727
  // right after the user sees "Compacting completed", which is
2390
2728
  // confusing and wrong.
2391
2729
  //
2392
- // Prefer the SDK's authoritative post-compaction `used` via
2393
- // getContextUsage — it reflects the real retained context
2394
- // (system prompt + tools + surviving messages), which the
2395
- // per-message API usage numbers can't give us until the next
2396
- // turn's result. If the control request fails, fall back to the
2397
- // used:0 approximation: directionally correct (context just
2398
- // dropped dramatically) and replaced within seconds by the next
2399
- // result message.
2730
+ // The compact boundary already carries the retained token
2731
+ // count. Prefer it over a getContextUsage control request,
2732
+ // which can block the live query for tens of seconds. Older
2733
+ // SDK frames without post_tokens fall back to used:0 and are
2734
+ // corrected by the next result message.
2400
2735
  //
2401
2736
  // `size` keeps coming from session.contextWindowSize —
2402
2737
  // compaction frees occupancy, it doesn't change the model's
2403
2738
  // window.
2404
2739
  //
2405
- // The "Compacting completed." text is emitted from the `status`
2406
- // handler (keyed on `compact_result`), not here, so the failure
2407
- // path gets a message too.
2408
- const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
2740
+ const compactMetadata = message.compact_metadata;
2741
+ await compaction.finish(message.session_id, message.uuid, "completed", compactMetadata ? contextCompactionMetadataFromBoundary(compactMetadata) : {}, true);
2742
+ const usedTokens = compactMetadata?.post_tokens ?? 0;
2409
2743
  lastAssistantUsage = null;
2410
- lastAssistantTotalUsage = usedTokens ?? 0;
2411
- session.contextUsedTokens = usedTokens ?? 0;
2744
+ lastAssistantTotalUsage = usedTokens;
2745
+ session.contextUsedTokens = usedTokens;
2412
2746
  await sendUpdate({
2413
2747
  sessionId: message.session_id,
2414
2748
  update: {
@@ -2420,11 +2754,20 @@ export class ClaudeAcpAgent {
2420
2754
  break;
2421
2755
  }
2422
2756
  case "local_command_output": {
2757
+ if (compaction.consumeDuplicateErrorOutput(message.content)) {
2758
+ break;
2759
+ }
2760
+ const usageTurn = session.activeTurn ?? firstUnsettledQueuedTurn();
2761
+ const usageMarkdown = await takeUsageMarkdown(message.content);
2762
+ if (usageTurn?.isUsageCommand && session.cancelled)
2763
+ break;
2764
+ if (usageMarkdown === null)
2765
+ break;
2423
2766
  await sendUpdate({
2424
2767
  sessionId: message.session_id,
2425
2768
  update: {
2426
2769
  sessionUpdate: "agent_message_chunk",
2427
- content: { type: "text", text: message.content },
2770
+ content: { type: "text", text: usageMarkdown ?? message.content },
2428
2771
  },
2429
2772
  });
2430
2773
  break;
@@ -2462,6 +2805,7 @@ export class ClaudeAcpAgent {
2462
2805
  // the interrupted turn's tokens entirely (issue #844). Zero
2463
2806
  // when the cancel pre-empted the result (wedge/force-cancel).
2464
2807
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
2808
+ compaction.reset();
2465
2809
  settleActive(turnOutcome(session, "cancelled"));
2466
2810
  // An interrupt can pre-empt the turn's result entirely
2467
2811
  // (nothing ran the result-case `finally`), so close the
@@ -2524,6 +2868,7 @@ export class ClaudeAcpAgent {
2524
2868
  else if (!session.cancelled &&
2525
2869
  session.activeTurn &&
2526
2870
  !session.activeTurn.settled) {
2871
+ compaction.reset();
2527
2872
  // Deliberately only the ACTIVE turn: a queued turn that
2528
2873
  // was never echoed is NOT failed here, because an idle
2529
2874
  // can legitimately precede the SDK picking up freshly
@@ -3023,6 +3368,7 @@ export class ClaudeAcpAgent {
3023
3368
  // through the early break below, which the gated `finally`
3024
3369
  // leaves alone).
3025
3370
  const deliveredAssistantText = session.emittedAssistantText;
3371
+ const deliveredCompactionOutput = compaction.hasDeliveredOutput;
3026
3372
  // Every user-turn result terminates a turn (settle, reject, or
3027
3373
  // orphan skip) and the SDK follows it with a trailing
3028
3374
  // `session_state_changed: idle` — record the debt so the idle
@@ -3329,9 +3675,15 @@ export class ClaudeAcpAgent {
3329
3675
  // the fallback there. (Autonomous results never get here —
3330
3676
  // they exit at the early break above — so no background
3331
3677
  // prose can be injected into the feed.)
3332
- if (session.activeTurn?.isLocalOnlyCommand ||
3333
- (!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
3334
- for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger)) {
3678
+ const shouldForwardResult = session.activeTurn?.isLocalOnlyCommand ||
3679
+ (!deliveredAssistantText &&
3680
+ !deliveredCompactionOutput &&
3681
+ (message.usage.output_tokens ?? 0) === 0);
3682
+ if (shouldForwardResult) {
3683
+ const usageMarkdown = await takeUsageMarkdown(message.result);
3684
+ if (usageMarkdown === null)
3685
+ break;
3686
+ for (const notification of toAcpNotifications(usageMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger)) {
3335
3687
  await sendUpdate(notification);
3336
3688
  }
3337
3689
  }
@@ -3406,11 +3758,23 @@ export class ClaudeAcpAgent {
3406
3758
  finally {
3407
3759
  if (!isAutonomousResult) {
3408
3760
  session.emittedAssistantText = false;
3761
+ // A result closes this compaction lifecycle. Reset here rather
3762
+ // than at idle: an owed idle from this turn can arrive after
3763
+ // the next turn has already started and must not erase that
3764
+ // turn's compaction state.
3765
+ compaction.reset();
3409
3766
  }
3410
3767
  }
3411
3768
  break;
3412
3769
  }
3413
3770
  case "stream_event": {
3771
+ const isCompactionProgress = (message.event.type === "content_block_start" &&
3772
+ message.event.content_block.type === "compaction") ||
3773
+ (message.event.type === "content_block_delta" &&
3774
+ message.event.delta.type === "compaction_delta");
3775
+ if (isCompactionProgress) {
3776
+ await compaction.heartbeat(message.session_id, message.uuid);
3777
+ }
3414
3778
  // `message_start` carries the Anthropic API message id; capture it
3415
3779
  // so the streamed chunks that follow (whose delta events don't carry
3416
3780
  // it) can all be tagged with the same, replay-stable id.
@@ -3633,6 +3997,18 @@ export class ClaudeAcpAgent {
3633
3997
  if (session.cancelled) {
3634
3998
  break;
3635
3999
  }
4000
+ // Synthetic assistant frames carry the CLI's local-command output.
4001
+ // On resume the SDK can replay a stale frame from an earlier compact
4002
+ // attempt after a later compaction completed. Scope suppression to
4003
+ // the compaction lifecycle and the synthetic frame itself rather
4004
+ // than to the owning turn: one model turn may compact more than once,
4005
+ // and its real assistant response must still be delivered.
4006
+ if (message.type === "assistant" &&
4007
+ message.parent_tool_use_id === null &&
4008
+ message.message.model === "<synthetic>" &&
4009
+ compaction.hasDeliveredOutput) {
4010
+ break;
4011
+ }
3636
4012
  // Snapshot the latest top-level assistant usage and model so the
3637
4013
  // next `result` can emit a usage_update tied to the right context
3638
4014
  // window. Subagent messages are excluded to keep the snapshot
@@ -3661,15 +4037,29 @@ export class ClaudeAcpAgent {
3661
4037
  message.message.stop_details?.explanation ?? lastRefusalExplanation;
3662
4038
  }
3663
4039
  }
3664
- // Strip <command-*>/<local-command-stdout> markers and render any
3665
- // remaining prose. Skill bodies and built-in slash commands (e.g.
3666
- // /usage, /status, /model) arrive wrapped in these tags; pure-marker
3667
- // payloads (e.g. /compact's malformed output) strip to null and are
3668
- // skipped. Mirrors the replay path at replaySessionHistory.
4040
+ // Depending on the Claude Code build, a local command can arrive
4041
+ // as the dedicated system message above or as a synthetic
4042
+ // assistant message. Replace only the output owned by the exact
4043
+ // /usage turn; no content signatures or text parsing are involved.
4044
+ if (message.type === "assistant" &&
4045
+ message.parent_tool_use_id === null &&
4046
+ message.message.model === "<synthetic>") {
4047
+ const usageMarkdown = await takeUsageMarkdown(assistantMessageText(message.message) ?? "");
4048
+ if (session.cancelled)
4049
+ break;
4050
+ if (usageMarkdown !== undefined) {
4051
+ if (usageMarkdown !== null) {
4052
+ for (const notification of toAcpNotifications(usageMarkdown, "assistant", params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, { messageId: messageIdForGrouping(message) })) {
4053
+ await sendUpdate(notification);
4054
+ }
4055
+ }
4056
+ break;
4057
+ }
4058
+ }
4059
+ const stringContent = typeof message.message.content === "string" ? message.message.content : undefined;
3669
4060
  if (message.message.role !== "system" &&
3670
- typeof message.message.content === "string" &&
3671
- message.message.content.includes("<local-command-stdout>")) {
3672
- const stripped = stripLocalCommandMetadata(message.message.content);
4061
+ stringContent?.includes("<local-command-stdout>")) {
4062
+ const stripped = stripLocalCommandMetadata(stringContent);
3673
4063
  if (typeof stripped === "string") {
3674
4064
  for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, routedNotificationClient, this.logger, {
3675
4065
  clientCapabilities: this.clientCapabilities,
@@ -4004,6 +4394,8 @@ export class ClaudeAcpAgent {
4004
4394
  return;
4005
4395
  }
4006
4396
  session.cancelled = true;
4397
+ for (const turn of session.turnQueue ?? [])
4398
+ turn.usageMarkdownAbort?.abort();
4007
4399
  session.pendingExitPlanModeInterruption = undefined;
4008
4400
  session.pendingExitPlanContextReset = undefined;
4009
4401
  // The stream already ended (see closeQueryStream): every in-flight turn was
@@ -4432,9 +4824,12 @@ export class ClaudeAcpAgent {
4432
4824
  await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
4433
4825
  return { configOptions: session.configOptions };
4434
4826
  }
4435
- async replaySessionHistory(sessionId) {
4827
+ async replaySessionHistory(sessionId, resumedMessages) {
4828
+ const replayStartedAt = performance.now();
4436
4829
  const toolUseCache = {};
4437
- const messages = await getSessionMessages(sessionId);
4830
+ const messages = resumedMessages ?? (await getSessionMessages(sessionId));
4831
+ const historyLoadedAt = performance.now();
4832
+ this.logger.log(`[session/replay] sessionId=${sessionId} phase=read durationMs=${Math.round(historyLoadedAt - replayStartedAt)} messages=${messages.length}`);
4438
4833
  const session = this.sessions[sessionId];
4439
4834
  const forwardSubagentText = session?.forwardSubagentText ?? supportsSubagentTranscript(this.clientCapabilities);
4440
4835
  const supportsTypedFailures = supportsAirSessionFailures(this.clientCapabilities);
@@ -4703,6 +5098,8 @@ export class ClaudeAcpAgent {
4703
5098
  }));
4704
5099
  }
4705
5100
  }
5101
+ const replayFinishedAt = performance.now();
5102
+ this.logger.log(`[session/replay] sessionId=${sessionId} phase=publish durationMs=${Math.round(replayFinishedAt - historyLoadedAt)} totalMs=${Math.round(replayFinishedAt - replayStartedAt)} messages=${messages.length}`);
4706
5103
  }
4707
5104
  async readTextFile(params) {
4708
5105
  const response = await this.client.readTextFile(params);
@@ -5308,7 +5705,7 @@ export class ClaudeAcpAgent {
5308
5705
  },
5309
5706
  });
5310
5707
  }
5311
- async getOrCreateSession(params) {
5708
+ async getOrCreateSession(params, resumedSession) {
5312
5709
  const existingSession = this.sessions[params.sessionId];
5313
5710
  if (existingSession) {
5314
5711
  const fingerprint = computeSessionFingerprint(params);
@@ -5324,6 +5721,9 @@ export class ClaudeAcpAgent {
5324
5721
  // recreate it so the underlying Query process picks up the new values.
5325
5722
  await this.teardownSession(params.sessionId);
5326
5723
  }
5724
+ const resumedModelHint = resumedSession
5725
+ ? resumedSession.model
5726
+ : (await readResumedSession(params.sessionId, this.logger)).model;
5327
5727
  const response = await this.createSession({
5328
5728
  cwd: params.cwd,
5329
5729
  mcpServers: params.mcpServers ?? [],
@@ -5331,6 +5731,7 @@ export class ClaudeAcpAgent {
5331
5731
  _meta: params._meta,
5332
5732
  }, {
5333
5733
  resume: params.sessionId,
5734
+ resumedModelHint,
5334
5735
  });
5335
5736
  return {
5336
5737
  sessionId: response.sessionId,
@@ -5360,6 +5761,7 @@ export class ClaudeAcpAgent {
5360
5761
  }
5361
5762
  }
5362
5763
  async createSession(params, creationOpts = {}) {
5764
+ const createStartedAt = performance.now();
5363
5765
  // Validate `cwd` up front. The ACP spec requires an absolute path, and the
5364
5766
  // directory must actually exist on the machine running the agent. Without
5365
5767
  // this check a session is created against a missing directory and the
@@ -5387,11 +5789,26 @@ export class ClaudeAcpAgent {
5387
5789
  else {
5388
5790
  sessionId = randomUUID();
5389
5791
  }
5792
+ const timing = new SessionTiming(this.logger, "create", sessionId, createStartedAt);
5793
+ timing.phase("validate-cwd");
5794
+ // Most session/load calls already carry a transcript snapshot so history
5795
+ // replay and model restoration share one local read. A few resume paths
5796
+ // intentionally call createSession directly (legacy session/new metadata,
5797
+ // sign-out respawn, provider rerouting), so fill the same fast local hint
5798
+ // here when the caller did not provide it. Never fall back to the slow
5799
+ // getContextUsage control request.
5800
+ let resumedModelHint = creationOpts.resumedModelHint;
5801
+ if (creationOpts.resume !== undefined &&
5802
+ !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint")) {
5803
+ resumedModelHint = (await readResumedSession(creationOpts.resume, this.logger)).model;
5804
+ timing.phase("resume-transcript");
5805
+ }
5390
5806
  const input = new Pushable();
5391
5807
  const settingsManager = new SettingsManager(params.cwd, {
5392
5808
  logger: this.logger,
5393
5809
  });
5394
5810
  await settingsManager.initialize();
5811
+ timing.phase("settings");
5395
5812
  const mcpServers = {};
5396
5813
  if (Array.isArray(params.mcpServers)) {
5397
5814
  for (const server of params.mcpServers) {
@@ -5619,7 +6036,7 @@ export class ClaudeAcpAgent {
5619
6036
  // switches the session's model with no refusal-fallback frame, and
5620
6037
  // the client's picker silently drifts. `source: 'sdk'` is the
5621
6038
  // adapter's own setModel (applyConfigOptionValue already synced it),
5622
- // and 'resume' restores are read back via readResumedLiveModel, so
6039
+ // and 'resume' restores are read from the transcript before startup, so
5623
6040
  // only the remaining sources sync here (CLI 2.1.251+; older CLIs
5624
6041
  // never fire the hook and keep the pre-hook behavior).
5625
6042
  PostModelSwitch: [
@@ -5701,6 +6118,7 @@ export class ClaudeAcpAgent {
5701
6118
  prompt: input,
5702
6119
  options,
5703
6120
  });
6121
+ timing.phase("prepare-query");
5704
6122
  // `query()` spawns the CLI at once. Any throw between here and the
5705
6123
  // registration in `this.sessions` would leave that child process running
5706
6124
  // with nobody holding the query, so discard it before propagating.
@@ -5718,6 +6136,11 @@ export class ClaudeAcpAgent {
5718
6136
  }
5719
6137
  throw error;
5720
6138
  }
6139
+ timing.phase("sdk-initialize");
6140
+ // Publish the identity BEFORE the guard can refuse this session. A
6141
+ // refusal is exactly when the client most needs to know which account it
6142
+ // was refused for.
6143
+ this.publishSessionAccountIdentity(initializationResult.account);
5721
6144
  // Shared with the per-turn guard, so "warn once" spans the whole session.
5722
6145
  const claudeSubscriptionGuard = {};
5723
6146
  if (this.claudeSubscriptionGuardActive()) {
@@ -5751,7 +6174,8 @@ export class ClaudeAcpAgent {
5751
6174
  const allowedModels = Array.isArray(settingsAvailableModels)
5752
6175
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
5753
6176
  : initializationResult.models;
5754
- const { modelState: models, resumedContextWindow } = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
6177
+ const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined, sessionId, resumedModelHint);
6178
+ timing.phase("models");
5755
6179
  // Resolve the current model's capabilities separately from the stable
5756
6180
  // permission-mode catalog advertised to ACP clients.
5757
6181
  // A resumed session can be running a model outside the `availableModels`
@@ -5793,7 +6217,9 @@ export class ClaudeAcpAgent {
5793
6217
  currentModelInfo,
5794
6218
  currentModelId: models.currentModelId,
5795
6219
  });
6220
+ timing.phase("modes");
5796
6221
  const agents = await discoverCustomAgents(q);
6222
+ timing.phase("agents");
5797
6223
  // Only adopt the requested agent as the selected value if it's one we
5798
6224
  // actually surface in the picker. A built-in (filtered out above) or
5799
6225
  // otherwise-unknown name would leave the config option's `currentValue`
@@ -5831,18 +6257,13 @@ export class ClaudeAcpAgent {
5831
6257
  // model switch. Only a user's explicit ACP picker choice pins the flag
5832
6258
  // (see applyConfigOptionValue / Session.effortPinnedByUser).
5833
6259
  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.
6260
+ // Seed the context window without extra IPC. The cached authoritative
6261
+ // window from a prior turn wins (`result.modelUsage`, cross-session),
6262
+ // then the text heuristic, then the default. We deliberately do NOT issue
6263
+ // a getContextUsage call here: before the first prompt turn that control
6264
+ // request can take tens of seconds, on resumed as well as fresh sessions.
6265
+ // The authoritative window arrives on the first `result.modelUsage` and
6266
+ // is cached from there.
5846
6267
  //
5847
6268
  // Text inference alone misses aliases that resolve to extended-context
5848
6269
  // models with no "1m" token anywhere in their id or description (e.g.
@@ -5850,17 +6271,14 @@ export class ClaudeAcpAgent {
5850
6271
  // `usage_update.size: 200000` until the first result's modelUsage corrects
5851
6272
  // it — but the cache means only the FIRST session to ever run a turn on such
5852
6273
  // 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).
6274
+ // restart (issue #596).
5855
6275
  //
5856
6276
  // The inference fallback is deliberately keyed to the allowlisted entry: a
5857
6277
  // fallback-resolved sibling's resolvedModel/displayName/description can
5858
6278
  // describe a different context lane than the verbatim live id (e.g. an
5859
6279
  // "opus[1m]" row matched for a bare 200k id), so on the fallback path only
5860
6280
  // the id itself is a trustworthy window signal.
5861
- const seededWindow = resumedContextWindow !== null
5862
- ? { size: resumedContextWindow, authoritative: true }
5863
- : immediateContextWindow(providerCacheKey, models.currentModelId, allowlistedModelInfo);
6281
+ const seededWindow = immediateContextWindow(providerCacheKey, models.currentModelId, allowlistedModelInfo);
5864
6282
  this.sessions[sessionId] = {
5865
6283
  query: q,
5866
6284
  input: input,
@@ -5907,9 +6325,11 @@ export class ClaudeAcpAgent {
5907
6325
  messageIdToUuid: new Map(),
5908
6326
  sessionFailureState: createSessionFailureState(),
5909
6327
  claudeSubscriptionGuard,
6328
+ accountKind: fromAccountInfo(initializationResult.account)?.kind,
5910
6329
  fileChangeReportRequestIds: new Set(),
5911
6330
  fileChangeAuditSupport,
5912
6331
  };
6332
+ timing.phase("register");
5913
6333
  return {
5914
6334
  sessionId,
5915
6335
  modes,
@@ -6786,17 +7206,6 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
6786
7206
  }
6787
7207
  return result;
6788
7208
  }
6789
- /** Read the model a resumed session is actually running (via the
6790
- * `getContextUsage` control request — the same source `/context` prints) and
6791
- * map it onto the picker, along with the report's authoritative context
6792
- * window (`rawMaxTokens`). Resumed sessions get this request serviced before
6793
- * any turn runs in the new process — unlike fresh sessions, where it stalls
6794
- * until the first prompt turn (issues #886/#880) — so the same response that
6795
- * restores the live model (issue #845) also seeds the window for free,
6796
- * covering post-restart reloads of models the text heuristic misses (issue
6797
- * #596). Best-effort: a control-request failure is logged and returns nulls
6798
- * so callers keep their current choice; failing the whole session/load over
6799
- * an unreadable report would be worse. */
6800
7209
  /** Whether a rejected `setModel` was vetoed by a user-configured
6801
7210
  * PreModelSwitch hook. The CLI's rejection message is the stable,
6802
7211
  * distinguishable marker ("Model switch blocked by a PreModelSwitch hook:
@@ -6805,28 +7214,10 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
6805
7214
  function isPreModelSwitchHookBlock(error) {
6806
7215
  return error instanceof Error && error.message.includes("blocked by a PreModelSwitch hook");
6807
7216
  }
6808
- async function readResumedLiveModel(query, models, logger) {
6809
- try {
6810
- const usage = await query.getContextUsage();
6811
- return {
6812
- model: usage.model ? matchResumedModel(models, usage.model) : null,
6813
- contextWindow: usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null,
6814
- };
6815
- }
6816
- catch (error) {
6817
- logger.error("Failed to read the resumed session's live model:", error);
6818
- return { model: null, contextWindow: null };
6819
- }
6820
- }
6821
- async function getAvailableModels(query, models, sdkModels, settingsManager, logger, isResumedSession) {
7217
+ async function getAvailableModels(query, models, sdkModels, settingsManager, logger, isResumedSession, sessionId, resumedModelHint) {
6822
7218
  const settings = settingsManager.getSettings();
6823
7219
  let currentModel = models[0];
6824
7220
  let resolvedFromInput;
6825
- // The context window reported alongside a resumed session's live model.
6826
- // Only ever non-null on the paths where `currentModel` IS the live model
6827
- // (no override, or a failed override re-assert), so the window always
6828
- // describes the model the session actually runs.
6829
- let resumedContextWindow = null;
6830
7221
  // Model priority (highest to lowest):
6831
7222
  // 1. ANTHROPIC_MODEL environment variable
6832
7223
  // 2. settings.model (user configuration)
@@ -6846,17 +7237,14 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
6846
7237
  resolvedFromInput = settings.model;
6847
7238
  }
6848
7239
  }
6849
- // A resumed session restores the model it was previously running (the CLI
6850
- // re-reads it from the transcript), so without an env/settings override the
6851
- // freshly-computed default above can disagree with what the session actually
6852
- // runs — session/load then reports a model the session isn't using (issue
6853
- // #845). Ask the CLI for the live model and reflect it. No `setModel` here:
6854
- // the SDK is already running this model, and pushing a picker alias back
6855
- // (e.g. "opus[1m]") could change the live model rather than describe it.
7240
+ // A resumed session restores the model from its last real assistant
7241
+ // transcript record. Use that same local record instead of getContextUsage:
7242
+ // the latter is a control request to the live CLI process and can add tens
7243
+ // of seconds to session/load before its first turn. No `setModel` here: the
7244
+ // SDK is already running this model, and pushing a picker alias back (e.g.
7245
+ // "opus[1m]") could change the live model rather than describe it.
6856
7246
  if (resolvedFromInput === undefined && isResumedSession) {
6857
- const live = await readResumedLiveModel(query, models, logger);
6858
- currentModel = live.model ?? currentModel;
6859
- resumedContextWindow = live.contextWindow;
7247
+ currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : currentModel;
6860
7248
  }
6861
7249
  // Skip the setModel round-trip when we can prove the SDK has already landed
6862
7250
  // on the same model. Two cases qualify:
@@ -6875,10 +7263,13 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
6875
7263
  const skipSetModel = resolvedFromInput === undefined ||
6876
7264
  (!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
6877
7265
  if (!skipSetModel) {
7266
+ const setModelStartedAt = performance.now();
6878
7267
  try {
6879
7268
  await query.setModel(currentModel.value);
7269
+ logger.log(`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=success`);
6880
7270
  }
6881
7271
  catch (error) {
7272
+ logger.log(`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=error`);
6882
7273
  // On a fresh session the pin is a defining option — fail loudly. A
6883
7274
  // resumed session already runs fine on the transcript's model, so
6884
7275
  // failing the whole session/load over the re-assert would be worse
@@ -6905,22 +7296,17 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
6905
7296
  }
6906
7297
  else {
6907
7298
  logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
6908
- const live = await readResumedLiveModel(query, models, logger);
6909
- currentModel = live.model ?? currentModel;
6910
- resumedContextWindow = live.contextWindow;
7299
+ currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : models[0];
6911
7300
  }
6912
7301
  }
6913
7302
  }
6914
7303
  return {
6915
- modelState: {
6916
- availableModels: models.map((model) => ({
6917
- modelId: model.value,
6918
- name: model.displayName,
6919
- description: model.description,
6920
- })),
6921
- currentModelId: currentModel.value,
6922
- },
6923
- resumedContextWindow,
7304
+ availableModels: models.map((model) => ({
7305
+ modelId: model.value,
7306
+ name: model.displayName,
7307
+ description: model.description,
7308
+ })),
7309
+ currentModelId: currentModel.value,
6924
7310
  };
6925
7311
  }
6926
7312
  function getAvailableSlashCommands(commands,
@@ -7850,30 +8236,13 @@ function commonPrefixLength(a, b) {
7850
8236
  * models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
7851
8237
  * ~1M). Such a miss falls back to the default window and is corrected by
7852
8238
  * `result.modelUsage` (and cached) within one turn. We do NOT consult the
7853
- * SDK's `getContextUsage` to close that gap: on a fresh session it is not
7854
- * serviced before the first prompt turn (issues #886/#880, see
7855
- * `contextWindowCache`; resumed sessions do get it, via
7856
- * `readResumedLiveModel`). */
8239
+ * SDK's `getContextUsage` to close that gap: before the first prompt turn it
8240
+ * can take tens of seconds (issues #886/#880, see `contextWindowCache`). */
7857
8241
  function inferContextWindowFromModel(...texts) {
7858
8242
  if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
7859
8243
  return 1_000_000;
7860
8244
  return null;
7861
8245
  }
7862
- /** Fetch the SDK's authoritative context-window occupancy via the
7863
- * `getContextUsage` control request. Unlike the per-message API usage numbers
7864
- * (which only count message tokens), this `totalTokens` includes the system
7865
- * prompt, tool schemas, MCP tools, and memory-file overhead — the real
7866
- * occupancy the user sees. Returns `null` on any control-request failure. */
7867
- async function fetchContextUsedTokens(query, logger) {
7868
- try {
7869
- const usage = await query.getContextUsage();
7870
- return usage.totalTokens;
7871
- }
7872
- catch (error) {
7873
- logger.error("Failed to fetch context usage from SDK:", error);
7874
- return null;
7875
- }
7876
- }
7877
8246
  /** Cross-session cache of authoritative context windows, keyed by
7878
8247
  * `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}).
7879
8248
  * The window is a property of (model id, backend): the same resolved model id
@@ -7894,9 +8263,6 @@ async function fetchContextUsedTokens(query, logger) {
7894
8263
  * has run the control request is not serviced (it stalls ~15s, and serializes
7895
8264
  * ahead of an awaited `setModel` — issues #886/#880, regressed in 0.59.0), so
7896
8265
  * it can neither beat the first `result` nor be issued cheaply before one.
7897
- * Resumed sessions are the exception — their report IS serviced pre-turn, and
7898
- * the session/load path seeds (but does not cache) the window from the same
7899
- * response that restores the live model, see `readResumedLiveModel`.
7900
8266
  * Cleared on `logout`: 1M-context entitlement can differ per account/tier, so
7901
8267
  * windows learned under one login must not seed sessions under the next. */
7902
8268
  const contextWindowCache = new Map();