@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.d.ts +134 -14
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +540 -174
- package/dist/auth-status.d.ts +140 -0
- package/dist/auth-status.d.ts.map +1 -0
- package/dist/auth-status.js +271 -0
- package/dist/context-compaction-meta.d.ts +18 -0
- package/dist/context-compaction-meta.d.ts.map +1 -0
- package/dist/context-compaction-meta.js +15 -0
- package/dist/context-compaction.d.ts +43 -0
- package/dist/context-compaction.d.ts.map +1 -0
- package/dist/context-compaction.js +152 -0
- package/dist/fork-session.d.ts +3 -0
- package/dist/fork-session.d.ts.map +1 -1
- package/dist/fork-session.js +113 -11
- package/dist/hide-claude-auth.d.ts +14 -7
- package/dist/hide-claude-auth.d.ts.map +1 -1
- package/dist/hide-claude-auth.js +16 -7
- package/dist/lib.d.ts +1 -0
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +3 -0
- package/dist/resumed-session.d.ts +19 -0
- package/dist/resumed-session.d.ts.map +1 -0
- package/dist/resumed-session.js +42 -0
- package/dist/session-timing.d.ts +15 -0
- package/dist/session-timing.d.ts.map +1 -0
- package/dist/session-timing.js +20 -0
- package/dist/usage-markdown.d.ts +7 -0
- package/dist/usage-markdown.d.ts.map +1 -0
- package/dist/usage-markdown.js +154 -0
- package/package.json +1 -1
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
|
|
906
|
-
await this.
|
|
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
|
|
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`.
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
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}:
|
|
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}:
|
|
1178
|
-
|
|
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
|
-
|
|
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"
|
|
2361
|
-
|
|
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"
|
|
2374
|
-
|
|
2375
|
-
|
|
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
|
-
//
|
|
2393
|
-
//
|
|
2394
|
-
//
|
|
2395
|
-
//
|
|
2396
|
-
//
|
|
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
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
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
|
|
2411
|
-
session.contextUsedTokens = usedTokens
|
|
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
|
-
|
|
3333
|
-
(!deliveredAssistantText &&
|
|
3334
|
-
|
|
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
|
-
//
|
|
3665
|
-
//
|
|
3666
|
-
//
|
|
3667
|
-
//
|
|
3668
|
-
|
|
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
|
-
|
|
3671
|
-
|
|
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
|
|
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
|
|
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
|
|
5835
|
-
//
|
|
5836
|
-
//
|
|
5837
|
-
//
|
|
5838
|
-
//
|
|
5839
|
-
//
|
|
5840
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
6850
|
-
//
|
|
6851
|
-
//
|
|
6852
|
-
//
|
|
6853
|
-
//
|
|
6854
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
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:
|
|
7854
|
-
*
|
|
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();
|