@lucascouts/claude-agent-acp-plus 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp-agent.d.ts +131 -16
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +657 -164
- package/dist/elicitation.d.ts.map +1 -1
- package/dist/elicitation.js +13 -0
- package/dist/model-deprecation.d.ts +1 -1
- package/dist/model-deprecation.d.ts.map +1 -1
- package/dist/model-deprecation.js +9 -4
- package/dist/rewind-command.d.ts +15 -3
- package/dist/rewind-command.d.ts.map +1 -1
- package/dist/rewind-command.js +37 -6
- package/dist/thinking-option.d.ts +12 -8
- package/dist/thinking-option.d.ts.map +1 -1
- package/dist/thinking-option.js +12 -8
- package/dist/tools.d.ts +1 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +51 -55
- package/package.json +9 -6
- package/dist/ask-user-question-fallback.d.ts +0 -78
- package/dist/ask-user-question-fallback.d.ts.map +0 -1
- package/dist/ask-user-question-fallback.js +0 -104
package/dist/acp-agent.js
CHANGED
|
@@ -8,7 +8,6 @@ import * as path from "node:path";
|
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import packageJson from "../package.json" with { type: "json" };
|
|
10
10
|
import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
|
|
11
|
-
import { askUserQuestionFallbackEnabled, handleAskUserQuestionViaPermission, } from "./ask-user-question-fallback.js";
|
|
12
11
|
import { agentName } from "./agent-name.js";
|
|
13
12
|
import { filterDeprecatedModels } from "./model-deprecation.js";
|
|
14
13
|
import { SettingsManager } from "./settings.js";
|
|
@@ -63,6 +62,46 @@ const QUERY_RECREATE_INIT_TIMEOUT_MS = 30_000;
|
|
|
63
62
|
* completed/stalled without the host turn resolving (issue #825). */
|
|
64
63
|
const TURN_NO_RESULT_MESSAGE = "The turn ended without a result: the agent went idle while this prompt was still in flight " +
|
|
65
64
|
"(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
|
|
65
|
+
/** Custom (extension) request method a client uses to steer the turn that is
|
|
66
|
+
* currently running: the message is injected into the in-flight turn rather
|
|
67
|
+
* than queued as a separate `session/prompt`. Named `_session/steering` per the
|
|
68
|
+
* agreed ACP steering wire protocol; advertised to clients via the top-level
|
|
69
|
+
* `InitializeResponse._meta.steering.supported`. */
|
|
70
|
+
const STEER_METHOD = "_session/steering";
|
|
71
|
+
/** How urgently the SDK delivers a steered message relative to the running
|
|
72
|
+
* turn — an internal Claude implementation detail, not part of the wire
|
|
73
|
+
* contract. `now` pre-empts the current generation and handles the message
|
|
74
|
+
* immediately (interrupting a single-shot response, or slotting in between a
|
|
75
|
+
* multi-step turn's tool calls). Maps to `SDKUserMessage.priority`; injected
|
|
76
|
+
* steering always uses `now` so the running turn adapts as soon as possible. */
|
|
77
|
+
const STEER_PRIORITY = "now";
|
|
78
|
+
/** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
|
|
79
|
+
* content blocks are handed to `promptToClaude`, which tolerates unknown block
|
|
80
|
+
* types — but `sessionId` and a non-empty `prompt` array are required. */
|
|
81
|
+
function parseSteerRequest(params) {
|
|
82
|
+
if (!params || typeof params !== "object") {
|
|
83
|
+
throw RequestError.invalidParams(undefined, "steer params must be an object");
|
|
84
|
+
}
|
|
85
|
+
const { sessionId, prompt, _meta } = params;
|
|
86
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
87
|
+
throw RequestError.invalidParams(undefined, "steer params require a non-empty sessionId");
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(prompt) || prompt.length === 0) {
|
|
90
|
+
throw RequestError.invalidParams(undefined, "steer params require a non-empty prompt array");
|
|
91
|
+
}
|
|
92
|
+
const steering = _meta && typeof _meta === "object" ? _meta.steering : undefined;
|
|
93
|
+
const idleBehavior = steering && typeof steering === "object"
|
|
94
|
+
? steering.idleBehavior
|
|
95
|
+
: undefined;
|
|
96
|
+
if (idleBehavior !== undefined && idleBehavior !== "promptRequired") {
|
|
97
|
+
throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior");
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
sessionId,
|
|
101
|
+
prompt: prompt,
|
|
102
|
+
_meta: _meta,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
66
105
|
/** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
|
|
67
106
|
* model did on its own (a task-notification followup, a peer/coordinator/
|
|
68
107
|
* observer message it handled) rather than the user's prompt. Absent,
|
|
@@ -123,6 +162,23 @@ const PROVIDER_ID = "main";
|
|
|
123
162
|
* env-var mappings understood by {@link createEnvForProvider}.
|
|
124
163
|
*/
|
|
125
164
|
const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
|
|
165
|
+
const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
|
|
166
|
+
function supportsSubagentTranscript(capabilities) {
|
|
167
|
+
return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
|
|
168
|
+
}
|
|
169
|
+
function parentToolUseIdOf(message) {
|
|
170
|
+
if (!("parent_tool_use_id" in message))
|
|
171
|
+
return null;
|
|
172
|
+
return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : null;
|
|
173
|
+
}
|
|
174
|
+
function stripSubagentTextAndThinking(content) {
|
|
175
|
+
if (!Array.isArray(content))
|
|
176
|
+
return content;
|
|
177
|
+
return content.filter((item) => !item ||
|
|
178
|
+
typeof item !== "object" ||
|
|
179
|
+
!("type" in item) ||
|
|
180
|
+
(item.type !== "text" && item.type !== "thinking"));
|
|
181
|
+
}
|
|
126
182
|
/**
|
|
127
183
|
* Advance the lexer state across the fragment appended since the last delta:
|
|
128
184
|
* just enough JSON awareness (string/escape, nesting depth) to spot commas
|
|
@@ -611,6 +667,14 @@ export class ClaudeAcpAgent {
|
|
|
611
667
|
...terminalAuthMethods,
|
|
612
668
|
...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []),
|
|
613
669
|
],
|
|
670
|
+
// Top-level `_meta` (sibling of `agentCapabilities`), per the existing ACP
|
|
671
|
+
// steering extension contract: advertises the `_session/steering` request
|
|
672
|
+
// so clients know they may inject a follow-up into a running turn.
|
|
673
|
+
_meta: {
|
|
674
|
+
steering: {
|
|
675
|
+
supported: true,
|
|
676
|
+
},
|
|
677
|
+
},
|
|
614
678
|
};
|
|
615
679
|
}
|
|
616
680
|
async newSession(params) {
|
|
@@ -803,6 +867,12 @@ export class ClaudeAcpAgent {
|
|
|
803
867
|
// those paths.
|
|
804
868
|
this.gatewayAuthRequest = undefined;
|
|
805
869
|
this.providerConfig = undefined;
|
|
870
|
+
// Learned context windows are per-account state too: 1M-context
|
|
871
|
+
// entitlement is gated per org/tier, and an OAuth re-login is invisible to
|
|
872
|
+
// the env-derived provider cache key, so windows learned under the old
|
|
873
|
+
// login must not seed sessions under the next. Worst case of clearing is
|
|
874
|
+
// re-learning on each model's next turn.
|
|
875
|
+
contextWindowCache.clear();
|
|
806
876
|
// For the Claude/Console login methods the credentials live in the native
|
|
807
877
|
// CLI's store (keychain or config dir), which only the binary can clear.
|
|
808
878
|
// `claude auth logout` is non-interactive and idempotent.
|
|
@@ -953,6 +1023,71 @@ export class ClaudeAcpAgent {
|
|
|
953
1023
|
this.ensureConsumer(session, params.sessionId);
|
|
954
1024
|
return response;
|
|
955
1025
|
}
|
|
1026
|
+
/** Steer the session per the ACP steering wire protocol: inject a follow-up
|
|
1027
|
+
* message into the turn that is currently running. If that turn already
|
|
1028
|
+
* settled, the established default starts a new detached turn; Hosts may opt
|
|
1029
|
+
* into the host-owned `promptRequired` fallback through request `_meta`.
|
|
1030
|
+
*
|
|
1031
|
+
* When a turn is in flight this injects (returns `injected`): unlike
|
|
1032
|
+
* `prompt()`, it does NOT create a Turn or enqueue on `turnQueue`; it pushes
|
|
1033
|
+
* an `SDKUserMessage` onto the same streaming input, which the SDK routes
|
|
1034
|
+
* into the in-flight turn. The injected message's echo carries a uuid that
|
|
1035
|
+
* matches no queued turn, so the consumer drops it as an unrelated replay
|
|
1036
|
+
* without promoting/settling anything. It is delivered at {@link
|
|
1037
|
+
* STEER_PRIORITY} (`now`) so it pre-empts the current generation (interrupting
|
|
1038
|
+
* a single-shot response, or slotting in between a multi-step turn's tool
|
|
1039
|
+
* calls). The steered message's own output streams via `session/update`, not
|
|
1040
|
+
* this response.
|
|
1041
|
+
*
|
|
1042
|
+
* When the session is idle, the opt-in path returns `promptRequired` WITHOUT
|
|
1043
|
+
* calling `prompt()`, pushing SDK input, or mutating `turnQueue`: the content
|
|
1044
|
+
* stays Host-owned so the Host can submit it through a standard
|
|
1045
|
+
* `session/prompt`. Without the opt-in, the existing detached `prompt()` and
|
|
1046
|
+
* `startedNewTurn` result are preserved for compatibility. */
|
|
1047
|
+
async steer(params) {
|
|
1048
|
+
const sessionId = params.sessionId;
|
|
1049
|
+
const session = this.sessions[sessionId];
|
|
1050
|
+
if (!session) {
|
|
1051
|
+
throw new Error("Session not found");
|
|
1052
|
+
}
|
|
1053
|
+
if (session.queryClosed) {
|
|
1054
|
+
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1055
|
+
}
|
|
1056
|
+
// "A turn is running" = the queue holds an unsettled turn. This covers both
|
|
1057
|
+
// the activated turn and one just submitted but not yet echoed/activated,
|
|
1058
|
+
// which is exactly the window in which steering is meaningful. This check
|
|
1059
|
+
// and the active-path push below stay in one synchronous section so the
|
|
1060
|
+
// turn cannot settle in the gap between deciding to inject and enqueueing.
|
|
1061
|
+
const turnInFlight = (session.turnQueue ?? []).some((turn) => !turn.settled);
|
|
1062
|
+
if (!turnInFlight) {
|
|
1063
|
+
const promptRequest = {
|
|
1064
|
+
sessionId,
|
|
1065
|
+
prompt: params.prompt,
|
|
1066
|
+
};
|
|
1067
|
+
if (params._meta?.steering?.idleBehavior === "promptRequired") {
|
|
1068
|
+
// The opt-in path leaves the content untouched so the Host can retry via
|
|
1069
|
+
// a normal session/prompt whose lifecycle owns the continuation result.
|
|
1070
|
+
return { outcome: "promptRequired", reason: "noRunningTurn" };
|
|
1071
|
+
}
|
|
1072
|
+
// Preserve the established default for Hosts that do not opt in. This is
|
|
1073
|
+
// intentionally detached for compatibility with the existing contract.
|
|
1074
|
+
this.prompt(promptRequest).catch((error) => {
|
|
1075
|
+
this.logger.error(`Session ${sessionId}: steered new turn failed: ${error}`);
|
|
1076
|
+
});
|
|
1077
|
+
return { outcome: "startedNewTurn" };
|
|
1078
|
+
}
|
|
1079
|
+
const promptRequest = {
|
|
1080
|
+
sessionId,
|
|
1081
|
+
prompt: params.prompt,
|
|
1082
|
+
};
|
|
1083
|
+
const userMessage = promptToClaude(promptRequest);
|
|
1084
|
+
userMessage.uuid = randomUUID();
|
|
1085
|
+
// Deliver into the running turn rather than queuing behind it as a fresh
|
|
1086
|
+
// prompt would.
|
|
1087
|
+
userMessage.priority = STEER_PRIORITY;
|
|
1088
|
+
session.input.push(userMessage);
|
|
1089
|
+
return { outcome: "injected" };
|
|
1090
|
+
}
|
|
956
1091
|
/** Lazily start the per-session consumer that drains the SDK query stream for
|
|
957
1092
|
* the session's whole life. Idempotent: only the first `prompt()` starts it. */
|
|
958
1093
|
ensureConsumer(session, sessionId) {
|
|
@@ -1612,7 +1747,7 @@ export class ClaudeAcpAgent {
|
|
|
1612
1747
|
// A fresh `system`/init (e.g. after reinitialize) can carry an
|
|
1613
1748
|
// updated Fast mode state; reconcile it with what we seeded at
|
|
1614
1749
|
// session creation.
|
|
1615
|
-
await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
|
|
1750
|
+
await this.syncFastModeState(message.session_id, session, message.fast_mode_state, message.fast_mode_disabled_reason);
|
|
1616
1751
|
break;
|
|
1617
1752
|
case "status": {
|
|
1618
1753
|
// These banners count as delivered text (via sendUpdate), so
|
|
@@ -1831,9 +1966,9 @@ export class ClaudeAcpAgent {
|
|
|
1831
1966
|
// Push the full slash-command list after a mid-session change
|
|
1832
1967
|
// (e.g. skills discovered dynamically as the agent works in a
|
|
1833
1968
|
// subdirectory). The client should REPLACE its cached command
|
|
1834
|
-
// list with this payload
|
|
1835
|
-
//
|
|
1836
|
-
//
|
|
1969
|
+
// list with this payload. Forward message.commands directly —
|
|
1970
|
+
// it's authoritative, and re-querying supportedCommands()
|
|
1971
|
+
// would just return the same list with an extra round-trip.
|
|
1837
1972
|
await sendUpdate({
|
|
1838
1973
|
sessionId: message.session_id,
|
|
1839
1974
|
update: {
|
|
@@ -1857,6 +1992,28 @@ export class ClaudeAcpAgent {
|
|
|
1857
1992
|
// already emitted as a `tool_call`, so mark it failed with the
|
|
1858
1993
|
// rejection reason — otherwise the client shows a tool call
|
|
1859
1994
|
// that silently never resolves.
|
|
1995
|
+
//
|
|
1996
|
+
// The id is the executing call's own, and the frame lands
|
|
1997
|
+
// between its `tool_use` and its `tool_result` (the SDK enqueues
|
|
1998
|
+
// it from inside canUseTool), so the call is normally in flight
|
|
1999
|
+
// here. Not always: the assistant message carrying the tool_use
|
|
2000
|
+
// is dropped by the cancelled-turn guard below, and a denial for
|
|
2001
|
+
// it can still arrive afterwards — the case the `tool_result`
|
|
2002
|
+
// fallback in `toAcpNotifications` gates on `wasEmitted` for.
|
|
2003
|
+
// Drop the update rather than reference a tool call the client
|
|
2004
|
+
// was never given (see `ensureToolCallEmitted`, issue #851).
|
|
2005
|
+
if (!session.emittedToolCalls.has(message.tool_use_id)) {
|
|
2006
|
+
break;
|
|
2007
|
+
}
|
|
2008
|
+
// A denial inside a subagent identifies the subagent by
|
|
2009
|
+
// `agent_id` (as canUseTool does with `agentID`), never by the
|
|
2010
|
+
// Agent/Task call that spawned it. Resolve it the same way so
|
|
2011
|
+
// the update lands in the subagent's transcript alongside the
|
|
2012
|
+
// `tool_call` it resolves, which carries the parent stamped from
|
|
2013
|
+
// `parent_tool_use_id` (see `liveBackgroundTasks`).
|
|
2014
|
+
const parentToolUseId = message.agent_id
|
|
2015
|
+
? session.liveBackgroundTasks.get(message.agent_id)?.parentToolUseId
|
|
2016
|
+
: undefined;
|
|
1860
2017
|
const reason = message.decision_reason ?? message.message;
|
|
1861
2018
|
await sendUpdate({
|
|
1862
2019
|
sessionId: message.session_id,
|
|
@@ -1873,6 +2030,7 @@ export class ClaudeAcpAgent {
|
|
|
1873
2030
|
_meta: {
|
|
1874
2031
|
claudeCode: {
|
|
1875
2032
|
toolName: message.tool_name,
|
|
2033
|
+
...(parentToolUseId ? { parentToolUseId } : {}),
|
|
1876
2034
|
toolResponse: {
|
|
1877
2035
|
decisionReasonType: message.decision_reason_type,
|
|
1878
2036
|
decisionReason: message.decision_reason,
|
|
@@ -2106,7 +2264,7 @@ export class ClaudeAcpAgent {
|
|
|
2106
2264
|
// an autonomous cycle's state lands on the next user turn's
|
|
2107
2265
|
// result. Runs even when the turn errors or was cancelled.
|
|
2108
2266
|
if (!isAutonomousResult) {
|
|
2109
|
-
await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
|
|
2267
|
+
await this.syncFastModeState(params.sessionId, session, message.fast_mode_state, message.fast_mode_disabled_reason);
|
|
2110
2268
|
}
|
|
2111
2269
|
// A user-turn result needs an active turn so its stop reason is
|
|
2112
2270
|
// attributed and the turn settles at idle. Local-only commands carry
|
|
@@ -2182,12 +2340,35 @@ export class ClaudeAcpAgent {
|
|
|
2182
2340
|
const matchingModelUsage = lastAssistantModel
|
|
2183
2341
|
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
|
2184
2342
|
: null;
|
|
2185
|
-
// Only overwrite when we have an authoritative value
|
|
2186
|
-
// (e.g. a turn with no top-level assistant message)
|
|
2187
|
-
//
|
|
2188
|
-
//
|
|
2189
|
-
|
|
2190
|
-
|
|
2343
|
+
// Only overwrite when we have an authoritative, sane value. A miss
|
|
2344
|
+
// (e.g. a turn with no top-level assistant message), or a
|
|
2345
|
+
// nonsensical non-positive/NaN window (observed from third-party
|
|
2346
|
+
// backends), would otherwise discard the window learned on a prior
|
|
2347
|
+
// turn and leave the next prompt's mid-stream updates reporting a
|
|
2348
|
+
// wrong size. `cacheContextWindow` applies the same `> 0` guard, so
|
|
2349
|
+
// a bad value never reaches the cross-session cache either.
|
|
2350
|
+
if (matchingModelUsage &&
|
|
2351
|
+
typeof matchingModelUsage.usage.contextWindow === "number" &&
|
|
2352
|
+
matchingModelUsage.usage.contextWindow > 0) {
|
|
2353
|
+
session.contextWindowSize = matchingModelUsage.usage.contextWindow;
|
|
2354
|
+
session.contextWindowAuthoritative = true;
|
|
2355
|
+
// Authoritative: fold it into the cross-session cache keyed on
|
|
2356
|
+
// (this session's provider, the resolved model id —
|
|
2357
|
+
// matchingModelUsage.key, e.g. "claude-sonnet-5[1m]") so a later
|
|
2358
|
+
// session/new or switch on the same provider that resolves to
|
|
2359
|
+
// this model seeds the correct window synchronously, with no
|
|
2360
|
+
// getContextUsage IPC.
|
|
2361
|
+
cacheContextWindow(contextWindowCacheKey(session.providerCacheKey, matchingModelUsage.key), matchingModelUsage.usage.contextWindow);
|
|
2362
|
+
// Also cache under the assistant message's own (bare) spelling.
|
|
2363
|
+
// Seed-time reads fall back to a picker value / verbatim live id
|
|
2364
|
+
// when a row carries no resolvedModel (the synthesized
|
|
2365
|
+
// out-of-allowlist resume row sets it undefined on purpose), and
|
|
2366
|
+
// those spellings match `.model` from the assistant message, not
|
|
2367
|
+
// the decorated modelUsage key — without this entry such rows
|
|
2368
|
+
// could never hit the cache.
|
|
2369
|
+
if (lastAssistantModel && lastAssistantModel !== matchingModelUsage.key) {
|
|
2370
|
+
cacheContextWindow(contextWindowCacheKey(session.providerCacheKey, lastAssistantModel), matchingModelUsage.usage.contextWindow);
|
|
2371
|
+
}
|
|
2191
2372
|
}
|
|
2192
2373
|
// Send usage_update notification
|
|
2193
2374
|
if (lastAssistantTotalUsage !== null) {
|
|
@@ -2430,11 +2611,16 @@ export class ClaudeAcpAgent {
|
|
|
2430
2611
|
const model = message.event.message.model;
|
|
2431
2612
|
if (model && model !== "<synthetic>") {
|
|
2432
2613
|
lastAssistantModel = model;
|
|
2433
|
-
// Only upgrade from the default — once
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
// confirmed by each `result`), trust it over
|
|
2437
|
-
|
|
2614
|
+
// Only upgrade from the heuristic default — once we have an
|
|
2615
|
+
// authoritative window (cache-seeded at session creation or
|
|
2616
|
+
// on a model switch, read from the resumed session on
|
|
2617
|
+
// session/load, confirmed by each `result`), trust it over
|
|
2618
|
+
// the heuristic. The flag, not the value, is the sentinel: an
|
|
2619
|
+
// authoritative window can legitimately equal
|
|
2620
|
+
// DEFAULT_CONTEXT_WINDOW (e.g. a backend serving a 200k lane
|
|
2621
|
+
// under a "[1m]"-spelled id) and must not be clobbered.
|
|
2622
|
+
if (!session.contextWindowAuthoritative &&
|
|
2623
|
+
session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
|
|
2438
2624
|
const inferred = inferContextWindowFromModel(model);
|
|
2439
2625
|
if (inferred !== null) {
|
|
2440
2626
|
session.contextWindowSize = inferred;
|
|
@@ -2692,11 +2878,13 @@ export class ClaudeAcpAgent {
|
|
|
2692
2878
|
// the record stays bounded to the in-flight message.
|
|
2693
2879
|
streamedBlocks.length = 0;
|
|
2694
2880
|
}
|
|
2695
|
-
else if (message.type === "assistant"
|
|
2696
|
-
|
|
2697
|
-
//
|
|
2698
|
-
//
|
|
2699
|
-
// top-level
|
|
2881
|
+
else if (message.type === "assistant" &&
|
|
2882
|
+
!(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities))) {
|
|
2883
|
+
// Legacy clients don't understand nested transcripts. Keep the
|
|
2884
|
+
// historical behavior for them: subagent text/thinking remains
|
|
2885
|
+
// internal to the tool call instead of leaking into the top-level
|
|
2886
|
+
// feed. Capable clients opt into the branch above unchanged, with
|
|
2887
|
+
// `parentToolUseId` stamped by toAcpNotifications.
|
|
2700
2888
|
content = message.message.content.filter((item) => item.type !== "text" && item.type !== "thinking");
|
|
2701
2889
|
}
|
|
2702
2890
|
else {
|
|
@@ -2710,6 +2898,11 @@ export class ClaudeAcpAgent {
|
|
|
2710
2898
|
emittedToolCalls: session.emittedToolCalls,
|
|
2711
2899
|
messageId: messageIdForGrouping(message),
|
|
2712
2900
|
toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
|
|
2901
|
+
// On the wire since CLI 2.1.216 but not in SDKUserMessage's
|
|
2902
|
+
// type, hence the cast. Validated by parseToolResultMeta.
|
|
2903
|
+
toolResultMeta: message.type === "user"
|
|
2904
|
+
? message.tool_result_meta
|
|
2905
|
+
: undefined,
|
|
2713
2906
|
})) {
|
|
2714
2907
|
// sendUpdate records delivery. Subagent text/thinking is
|
|
2715
2908
|
// filtered out of `content` above; blocks that do pass through
|
|
@@ -2720,16 +2913,48 @@ export class ClaudeAcpAgent {
|
|
|
2720
2913
|
break;
|
|
2721
2914
|
}
|
|
2722
2915
|
case "tool_progress": {
|
|
2916
|
+
// Not every beat reports under the id of a tool call the client has
|
|
2917
|
+
// seen: heartbeats derive `<tool_use_id>-heartbeat-<n>`, and the
|
|
2918
|
+
// `agent_api_retry` beats behind `subagentRetry` report under
|
|
2919
|
+
// `agent_<assistant_message_id>`. Forwarding those verbatim leaves the
|
|
2920
|
+
// client resolving an id it has never been told about (the same trap
|
|
2921
|
+
// `ensureToolCallEmitted` documents for #851). The SDK stamps
|
|
2922
|
+
// `parent_tool_use_id` with the executing tool's real id whenever the
|
|
2923
|
+
// beat doesn't carry one of its own, so fall back to it rather than
|
|
2924
|
+
// pattern-matching each synthetic id shape. Beats that do report a real
|
|
2925
|
+
// id (a subagent's `bash_progress`, whose parent is the spawning Agent
|
|
2926
|
+
// call) keep resolving to that id.
|
|
2927
|
+
const toolCallId = session.emittedToolCalls.has(message.tool_use_id)
|
|
2928
|
+
? message.tool_use_id
|
|
2929
|
+
: message.parent_tool_use_id;
|
|
2930
|
+
// Ids leave `emittedToolCalls` at `tool_result`, so this also stops a
|
|
2931
|
+
// beat that races past completion from reopening a finished call.
|
|
2932
|
+
if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
|
|
2933
|
+
break;
|
|
2934
|
+
}
|
|
2723
2935
|
await sendUpdate({
|
|
2724
2936
|
sessionId: message.session_id,
|
|
2725
2937
|
update: {
|
|
2726
2938
|
sessionUpdate: "tool_call_update",
|
|
2727
|
-
toolCallId
|
|
2939
|
+
toolCallId,
|
|
2728
2940
|
status: "in_progress",
|
|
2729
2941
|
_meta: {
|
|
2730
2942
|
claudeCode: {
|
|
2731
2943
|
toolName: message.tool_name,
|
|
2732
|
-
toolResponse: {
|
|
2944
|
+
toolResponse: {
|
|
2945
|
+
elapsedTimeSeconds: message.elapsed_time_seconds,
|
|
2946
|
+
// For Agent/Task calls: the subagent's type, and — when
|
|
2947
|
+
// the subagent is waiting out an API rate-limit retry —
|
|
2948
|
+
// the SDK's retry counters (attempt, max_retries,
|
|
2949
|
+
// retry_delay_ms, …), forwarded verbatim so clients can
|
|
2950
|
+
// show why a spawn looks stalled.
|
|
2951
|
+
...(message.subagent_type !== undefined && {
|
|
2952
|
+
subagentType: message.subagent_type,
|
|
2953
|
+
}),
|
|
2954
|
+
...(message.subagent_retry !== undefined && {
|
|
2955
|
+
subagentRetry: message.subagent_retry,
|
|
2956
|
+
}),
|
|
2957
|
+
},
|
|
2733
2958
|
},
|
|
2734
2959
|
},
|
|
2735
2960
|
},
|
|
@@ -3276,6 +3501,8 @@ export class ClaudeAcpAgent {
|
|
|
3276
3501
|
async replaySessionHistory(sessionId) {
|
|
3277
3502
|
const toolUseCache = {};
|
|
3278
3503
|
const messages = await getSessionMessages(sessionId);
|
|
3504
|
+
const forwardSubagentText = this.sessions[sessionId]?.forwardSubagentText ??
|
|
3505
|
+
supportsSubagentTranscript(this.clientCapabilities);
|
|
3279
3506
|
for (const message of messages) {
|
|
3280
3507
|
// Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
|
|
3281
3508
|
// observe live (resumed/loaded sessions), so rewind/resume can translate
|
|
@@ -3294,6 +3521,10 @@ export class ClaudeAcpAgent {
|
|
|
3294
3521
|
}
|
|
3295
3522
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3296
3523
|
let content = message.message.content;
|
|
3524
|
+
const parentToolUseId = parentToolUseIdOf(message);
|
|
3525
|
+
if (message.type === "assistant" && parentToolUseId && !forwardSubagentText) {
|
|
3526
|
+
content = stripSubagentTextAndThinking(content);
|
|
3527
|
+
}
|
|
3297
3528
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
3298
3529
|
if (message.message.role === "user") {
|
|
3299
3530
|
content = stripLocalCommandMetadata(content);
|
|
@@ -3310,6 +3541,7 @@ export class ClaudeAcpAgent {
|
|
|
3310
3541
|
cwd: this.sessions[sessionId]?.cwd,
|
|
3311
3542
|
taskState: this.sessions[sessionId]?.taskState,
|
|
3312
3543
|
messageId: replayMessageId,
|
|
3544
|
+
parentToolUseId,
|
|
3313
3545
|
})) {
|
|
3314
3546
|
await this.client.sessionUpdate(notification);
|
|
3315
3547
|
}
|
|
@@ -3383,7 +3615,7 @@ export class ClaudeAcpAgent {
|
|
|
3383
3615
|
await this.client.sessionUpdate({ sessionId, update });
|
|
3384
3616
|
}
|
|
3385
3617
|
canUseTool(sessionId) {
|
|
3386
|
-
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID }) => {
|
|
3618
|
+
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID, matchedAskRule }) => {
|
|
3387
3619
|
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
|
|
3388
3620
|
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
3389
3621
|
const session = this.sessions[sessionId];
|
|
@@ -3420,41 +3652,6 @@ export class ClaudeAcpAgent {
|
|
|
3420
3652
|
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
|
|
3421
3653
|
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
|
|
3422
3654
|
}
|
|
3423
|
-
// Fallback for clients WITHOUT `elicitation.form`: route each question
|
|
3424
|
-
// through ACP `session/request_permission` dialogs (gated by
|
|
3425
|
-
// ACP_ASKUSERQUESTION_FALLBACK). Placed before ExitPlanMode and the
|
|
3426
|
-
// bypassPermissions early-allow so a question is always asked, even in
|
|
3427
|
-
// bypass mode.
|
|
3428
|
-
if (toolName === "AskUserQuestion" && askUserQuestionFallbackEnabled(process.env)) {
|
|
3429
|
-
// Emit the tool_call before the first permission request (R3.1).
|
|
3430
|
-
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
|
|
3431
|
-
// Logger has no `debug`; `.error` reaches stderr in all contexts (the CLI
|
|
3432
|
-
// entrypoint also remaps console.log/debug -> stderr), so it never
|
|
3433
|
-
// corrupts the stdout ndJSON protocol. Used by story 002 validation.
|
|
3434
|
-
this.logger.error("AskUserQuestion: routing via permission fallback (client lacks elicitation.form).");
|
|
3435
|
-
return handleAskUserQuestionViaPermission(toolInput, async ({ question, options }) => {
|
|
3436
|
-
const response = await this.requestPermissionFromClient({
|
|
3437
|
-
options,
|
|
3438
|
-
sessionId,
|
|
3439
|
-
toolCall: {
|
|
3440
|
-
toolCallId: toolUseID,
|
|
3441
|
-
rawInput: toolInput,
|
|
3442
|
-
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
3443
|
-
// Per-question title so the user sees which question they are
|
|
3444
|
-
// answering; placed AFTER the spread so it wins over the
|
|
3445
|
-
// tool-derived title.
|
|
3446
|
-
title: question.question,
|
|
3447
|
-
},
|
|
3448
|
-
}, toolName, signal);
|
|
3449
|
-
// RequestPermissionResponse has a DOUBLE-nested outcome (see the
|
|
3450
|
-
// ExitPlanMode usage below): response.outcome?.outcome is
|
|
3451
|
-
// "selected" | "cancelled".
|
|
3452
|
-
if (response.outcome?.outcome === "selected") {
|
|
3453
|
-
return { outcome: "selected", optionId: response.outcome.optionId };
|
|
3454
|
-
}
|
|
3455
|
-
return { outcome: "cancelled" };
|
|
3456
|
-
}, signal);
|
|
3457
|
-
}
|
|
3458
3655
|
if (toolName === "ExitPlanMode") {
|
|
3459
3656
|
const optionsAll = [
|
|
3460
3657
|
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
|
|
@@ -3526,7 +3723,14 @@ export class ClaudeAcpAgent {
|
|
|
3526
3723
|
};
|
|
3527
3724
|
}
|
|
3528
3725
|
}
|
|
3529
|
-
|
|
3726
|
+
// In bypass mode the CLI skips permission checks itself; the asks that
|
|
3727
|
+
// still reach canUseTool are the ones it insists on prompting for even
|
|
3728
|
+
// under --dangerously-skip-permissions. Keep auto-allowing those —
|
|
3729
|
+
// bypass means bypass — EXCEPT rule-forced asks (`matchedAskRule`): the
|
|
3730
|
+
// user explicitly configured a permissions.ask rule for this tool, and
|
|
3731
|
+
// the SDK's guidance is that hosts running auto-approval must treat such
|
|
3732
|
+
// asks as a human prompt. Fall through to the normal request below.
|
|
3733
|
+
if (session.modes.currentModeId === "bypassPermissions" && !matchedAskRule) {
|
|
3530
3734
|
return {
|
|
3531
3735
|
behavior: "allow",
|
|
3532
3736
|
updatedInput: toolInput,
|
|
@@ -3732,17 +3936,21 @@ export class ClaudeAcpAgent {
|
|
|
3732
3936
|
// carries no "1m" token.
|
|
3733
3937
|
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
3734
3938
|
if (session.models.currentModelId !== value) {
|
|
3735
|
-
//
|
|
3736
|
-
//
|
|
3737
|
-
// `
|
|
3738
|
-
//
|
|
3739
|
-
//
|
|
3740
|
-
//
|
|
3741
|
-
//
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3939
|
+
// Seed the new model's context window WITHOUT any IPC on the switch
|
|
3940
|
+
// path: cached authoritative value if we've already learned it (from a
|
|
3941
|
+
// prior turn's `result.modelUsage`), else the text heuristic, else the
|
|
3942
|
+
// default. We deliberately do NOT call `getContextUsage` here — before
|
|
3943
|
+
// a fresh session's first prompt turn that control request is not
|
|
3944
|
+
// serviced (~15s stall, issues #886/#880), and (because SDK control
|
|
3945
|
+
// requests are serialized over one channel) it would drag the awaited
|
|
3946
|
+
// `setModel` down with it. The authoritative window arrives on the
|
|
3947
|
+
// first `result.modelUsage` for the model and is cached from there;
|
|
3948
|
+
// until then a switched-to alias that has never run a turn shows the
|
|
3949
|
+
// heuristic/default window, which self-corrects on its first response
|
|
3950
|
+
// (matches pre-0.59.0 behavior).
|
|
3951
|
+
const seeded = immediateContextWindow(session.providerCacheKey, value, newModelInfo);
|
|
3952
|
+
session.contextWindowSize = seeded.size;
|
|
3953
|
+
session.contextWindowAuthoritative = seeded.authoritative;
|
|
3746
3954
|
}
|
|
3747
3955
|
session.models = { ...session.models, currentModelId: value };
|
|
3748
3956
|
// Recompute availableModes for the new model and clamp the current
|
|
@@ -3780,6 +3988,13 @@ export class ClaudeAcpAgent {
|
|
|
3780
3988
|
else {
|
|
3781
3989
|
session.modes = { ...session.modes, availableModes: newAvailableModes };
|
|
3782
3990
|
}
|
|
3991
|
+
// `model_not_allowed` described the model we just left, so it must not
|
|
3992
|
+
// follow us onto the new one; the remaining reasons are account- or
|
|
3993
|
+
// environment-scoped and stay true across a switch. Either way the next
|
|
3994
|
+
// init/result report refreshes this.
|
|
3995
|
+
if (session.fastModeDisabledReason === "model_not_allowed") {
|
|
3996
|
+
session.fastModeDisabledReason = undefined;
|
|
3997
|
+
}
|
|
3783
3998
|
// Rebuild config options since effort levels depend on the selected model
|
|
3784
3999
|
const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
|
|
3785
4000
|
const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
|
@@ -3789,6 +4004,7 @@ export class ClaudeAcpAgent {
|
|
|
3789
4004
|
// intent) when a supporting model is selected again.
|
|
3790
4005
|
supported: newModelInfo?.supportsFastMode ?? false,
|
|
3791
4006
|
enabled: session.fastModeEnabled,
|
|
4007
|
+
disabledReason: session.fastModeDisabledReason,
|
|
3792
4008
|
},
|
|
3793
4009
|
// Thinking is model-independent: re-render the retained tri-state
|
|
3794
4010
|
// intent so the row survives this rebuild (an option not threaded
|
|
@@ -3874,12 +4090,13 @@ export class ClaudeAcpAgent {
|
|
|
3874
4090
|
}
|
|
3875
4091
|
}
|
|
3876
4092
|
/** Replace the Fast mode option in `session.configOptions` so it reflects
|
|
3877
|
-
* `enabled
|
|
3878
|
-
*
|
|
3879
|
-
* {@link createFastModeConfigOption} — the one
|
|
3880
|
-
* so the shape can't drift from what
|
|
4093
|
+
* `enabled` (and the session's current disabled reason). A no-op when the
|
|
4094
|
+
* option isn't present, so callers must confirm the current model surfaces
|
|
4095
|
+
* it first. Rebuilds through {@link createFastModeConfigOption} — the one
|
|
4096
|
+
* source of the option's shape — so the shape can't drift from what
|
|
4097
|
+
* `buildConfigOptions` first emitted. */
|
|
3881
4098
|
refreshFastModeOption(session, enabled) {
|
|
3882
|
-
const refreshed = createFastModeConfigOption(enabled);
|
|
4099
|
+
const refreshed = createFastModeConfigOption(enabled, session.fastModeDisabledReason);
|
|
3883
4100
|
session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
|
|
3884
4101
|
}
|
|
3885
4102
|
/** Toggle Fast mode for a session: push the SDK flag, record the user's
|
|
@@ -4163,8 +4380,15 @@ export class ClaudeAcpAgent {
|
|
|
4163
4380
|
* here).
|
|
4164
4381
|
* - `cooldown`: a transient suspension of an already-enabled fast mode.
|
|
4165
4382
|
* Leave the toggle as-is rather than flapping it — and never let a stray
|
|
4166
|
-
* cooldown spuriously enable a toggle the user has off.
|
|
4167
|
-
|
|
4383
|
+
* cooldown spuriously enable a toggle the user has off.
|
|
4384
|
+
*
|
|
4385
|
+
* `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
|
|
4386
|
+
* state. Only explainable reasons are retained (see
|
|
4387
|
+
* {@link normalizeFastModeDisabledReason}), so the comparison below tracks
|
|
4388
|
+
* exactly what the user can see: a routine `sdk_opt_in_required` report on
|
|
4389
|
+
* every turn's result can't churn the option, while a real blocker updates
|
|
4390
|
+
* the description even when the toggle's own value is unchanged. */
|
|
4391
|
+
async syncFastModeState(sessionId, session, state, reason) {
|
|
4168
4392
|
if (state === undefined) {
|
|
4169
4393
|
return;
|
|
4170
4394
|
}
|
|
@@ -4175,11 +4399,31 @@ export class ClaudeAcpAgent {
|
|
|
4175
4399
|
return;
|
|
4176
4400
|
}
|
|
4177
4401
|
const enabled = state === "on";
|
|
4178
|
-
|
|
4402
|
+
// A reason only describes an off state; drop any that rides an `on` report
|
|
4403
|
+
// so it can't decorate the option the next time fast mode goes off.
|
|
4404
|
+
const nextReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
|
|
4405
|
+
if (enabled === session.fastModeEnabled && nextReason === session.fastModeDisabledReason) {
|
|
4179
4406
|
return;
|
|
4180
4407
|
}
|
|
4408
|
+
// The user asked for Fast mode and the SDK is telling us it can't serve it.
|
|
4409
|
+
// The description carries the same explanation, but a toggle silently
|
|
4410
|
+
// snapping back is the case worth saying out loud once, at the flip.
|
|
4411
|
+
const explain = session.fastModeEnabled && !enabled && nextReason !== undefined;
|
|
4181
4412
|
session.fastModeEnabled = enabled;
|
|
4413
|
+
session.fastModeDisabledReason = nextReason;
|
|
4182
4414
|
this.refreshFastModeOption(session, enabled);
|
|
4415
|
+
if (explain) {
|
|
4416
|
+
await this.client.sessionUpdate({
|
|
4417
|
+
sessionId,
|
|
4418
|
+
update: {
|
|
4419
|
+
sessionUpdate: "agent_message_chunk",
|
|
4420
|
+
content: {
|
|
4421
|
+
type: "text",
|
|
4422
|
+
text: `**Fast mode turned off:** ${FAST_MODE_UNAVAILABLE_EXPLANATIONS[nextReason]}.`,
|
|
4423
|
+
},
|
|
4424
|
+
},
|
|
4425
|
+
});
|
|
4426
|
+
}
|
|
4183
4427
|
await this.client.sessionUpdate({
|
|
4184
4428
|
sessionId,
|
|
4185
4429
|
update: {
|
|
@@ -4311,6 +4555,8 @@ export class ClaudeAcpAgent {
|
|
|
4311
4555
|
// Extract options from _meta if provided
|
|
4312
4556
|
const sessionMeta = params._meta;
|
|
4313
4557
|
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
|
4558
|
+
const forwardSubagentText = supportsSubagentTranscript(this.clientCapabilities) ||
|
|
4559
|
+
userProvidedOptions?.forwardSubagentText === true;
|
|
4314
4560
|
// Configure thinking behavior through the same single code path query
|
|
4315
4561
|
// recreation uses (`recreateSessionQuery`). A fresh session's Thinking
|
|
4316
4562
|
// intent is untouched (`undefined`), so this resolves to exactly the
|
|
@@ -4326,13 +4572,9 @@ export class ClaudeAcpAgent {
|
|
|
4326
4572
|
url: !!this.clientCapabilities?.elicitation?.url,
|
|
4327
4573
|
};
|
|
4328
4574
|
// AskUserQuestion surfaces as a `permission_ask_user_question` dialog that
|
|
4329
|
-
// we render as a form elicitation. Without form-elicitation support
|
|
4330
|
-
//
|
|
4331
|
-
|
|
4332
|
-
// keep it disabled, exactly as upstream does.
|
|
4333
|
-
const disallowedTools = elicitationSupport.form || askUserQuestionFallbackEnabled(process.env)
|
|
4334
|
-
? []
|
|
4335
|
-
: ["AskUserQuestion"];
|
|
4575
|
+
// we render as a form elicitation. Without form-elicitation support there
|
|
4576
|
+
// is no way to present it over ACP, so keep it disabled in that case.
|
|
4577
|
+
const disallowedTools = elicitationSupport.form ? [] : ["AskUserQuestion"];
|
|
4336
4578
|
// Resolve which built-in tools to expose.
|
|
4337
4579
|
// Explicit tools array from _meta.claudeCode.options takes precedence.
|
|
4338
4580
|
// disableBuiltInTools is a legacy shorthand for tools: [] — kept for
|
|
@@ -4344,6 +4586,28 @@ export class ClaudeAcpAgent {
|
|
|
4344
4586
|
// below) so the TaskCreated/TaskCompleted hook callbacks can close over
|
|
4345
4587
|
// the same Map that the streaming message handler will read from.
|
|
4346
4588
|
const taskState = new Map();
|
|
4589
|
+
// The exact env the query will be created with. Built (and the provider
|
|
4590
|
+
// cache key derived from it, below) in one place so the key always
|
|
4591
|
+
// describes the backend this query actually talks to: `providers/set`,
|
|
4592
|
+
// `providers/disable`, and `logout` mutate the process-wide provider
|
|
4593
|
+
// config concurrently, so re-resolving it after any of the awaits between
|
|
4594
|
+
// here and the session registration could disagree with the env baked
|
|
4595
|
+
// into the query.
|
|
4596
|
+
const env = {
|
|
4597
|
+
...process.env,
|
|
4598
|
+
...userProvidedOptions?.env,
|
|
4599
|
+
// Client-managed LLM routing: `providers/set` config wins, else the
|
|
4600
|
+
// legacy gateway auth request. Baked into the query at creation, so it
|
|
4601
|
+
// only affects sessions started after the change (matching the RFD).
|
|
4602
|
+
...createEnvForProvider(this.resolveProviderConfig()),
|
|
4603
|
+
// Opt-in to session state events like when the agent is idle
|
|
4604
|
+
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
|
4605
|
+
};
|
|
4606
|
+
// Scopes the context-window cache to this query's backend (see
|
|
4607
|
+
// `contextWindowCache`). Derived from the same `env` object handed to the
|
|
4608
|
+
// SDK, so per-session `_meta` env routing and ambient process-env routing
|
|
4609
|
+
// are distinguished exactly as the CLI will see them.
|
|
4610
|
+
const providerCacheKey = providerCacheKeyFor(env);
|
|
4347
4611
|
const options = {
|
|
4348
4612
|
systemPrompt,
|
|
4349
4613
|
settingSources: ["user", "project", "local"],
|
|
@@ -4364,19 +4628,11 @@ export class ClaudeAcpAgent {
|
|
|
4364
4628
|
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
|
4365
4629
|
},
|
|
4366
4630
|
}),
|
|
4367
|
-
env
|
|
4368
|
-
...process.env,
|
|
4369
|
-
...userProvidedOptions?.env,
|
|
4370
|
-
// Client-managed LLM routing: `providers/set` config wins, else the
|
|
4371
|
-
// legacy gateway auth request. Baked into the query at creation, so it
|
|
4372
|
-
// only affects sessions started after the change (matching the RFD).
|
|
4373
|
-
...createEnvForProvider(this.resolveProviderConfig()),
|
|
4374
|
-
// Opt-in to session state events like when the agent is idle
|
|
4375
|
-
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
|
4376
|
-
},
|
|
4631
|
+
env,
|
|
4377
4632
|
// Override certain fields that must be controlled by ACP
|
|
4378
4633
|
cwd: params.cwd,
|
|
4379
4634
|
includePartialMessages: true,
|
|
4635
|
+
forwardSubagentText,
|
|
4380
4636
|
mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
|
|
4381
4637
|
// If we want bypassPermissions to be an option, we have to allow it here.
|
|
4382
4638
|
// But it doesn't work in root mode, so we only activate it if it will work.
|
|
@@ -4415,7 +4671,7 @@ export class ClaudeAcpAgent {
|
|
|
4415
4671
|
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
|
4416
4672
|
{
|
|
4417
4673
|
hooks: [
|
|
4418
|
-
createPostToolUseHook(
|
|
4674
|
+
createPostToolUseHook({
|
|
4419
4675
|
onEnterPlanMode: async () => {
|
|
4420
4676
|
await this.client.sessionUpdate({
|
|
4421
4677
|
sessionId,
|
|
@@ -4537,7 +4793,7 @@ export class ClaudeAcpAgent {
|
|
|
4537
4793
|
const allowedModels = Array.isArray(settingsAvailableModels)
|
|
4538
4794
|
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, this.logger)
|
|
4539
4795
|
: hideDeprecatedModels(initializationResult.models, this.logger);
|
|
4540
|
-
const models = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
|
|
4796
|
+
const { modelState: models, resumedContextWindow } = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
|
|
4541
4797
|
// Gate `auto` (and future model-specific modes) on the resolved model's
|
|
4542
4798
|
// `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
|
|
4543
4799
|
// Looked up in the UNfiltered catalog: a session honoring a persisted
|
|
@@ -4628,9 +4884,17 @@ export class ClaudeAcpAgent {
|
|
|
4628
4884
|
// the resolved model advertises `supportsFastMode`.
|
|
4629
4885
|
const fastModeEnabled = initializationResult.fast_mode_state !== undefined &&
|
|
4630
4886
|
fastModeStateEnabled(initializationResult.fast_mode_state);
|
|
4887
|
+
// `fast_mode_disabled_reason` reflects the post-switch model since SDK
|
|
4888
|
+
// 0.3.219 (the initialize response used to answer from the spawn-time
|
|
4889
|
+
// model). A fresh SDK session reports `sdk_opt_in_required` — the toggle IS
|
|
4890
|
+
// the opt-in — which normalizes away, so only real blockers are retained.
|
|
4891
|
+
const fastModeDisabledReason = fastModeEnabled
|
|
4892
|
+
? undefined
|
|
4893
|
+
: normalizeFastModeDisabledReason(initializationResult.fast_mode_disabled_reason);
|
|
4631
4894
|
const fastMode = {
|
|
4632
4895
|
supported: currentModelInfo?.supportsFastMode ?? false,
|
|
4633
4896
|
enabled: fastModeEnabled,
|
|
4897
|
+
disabledReason: fastModeDisabledReason,
|
|
4634
4898
|
};
|
|
4635
4899
|
const configOptions = buildConfigOptions(modes, models,
|
|
4636
4900
|
// Catalog-based (see `modelInfos` above), matching the model-switch
|
|
@@ -4652,25 +4916,40 @@ export class ClaudeAcpAgent {
|
|
|
4652
4916
|
typeof initialEffort.currentValue === "string" &&
|
|
4653
4917
|
initialEffort.currentValue !== "default") {
|
|
4654
4918
|
await q.applyFlagSettings({
|
|
4655
|
-
effortLevel: initialEffort.currentValue,
|
|
4919
|
+
effortLevel: toSdkEffortLevel(initialEffort.currentValue),
|
|
4656
4920
|
});
|
|
4657
4921
|
}
|
|
4658
|
-
// Seed the context window
|
|
4659
|
-
//
|
|
4660
|
-
//
|
|
4661
|
-
//
|
|
4662
|
-
//
|
|
4663
|
-
//
|
|
4664
|
-
//
|
|
4922
|
+
// Seed the context window WITHOUT any extra IPC on the session/new path.
|
|
4923
|
+
// On session/load, the resumed session's own `getContextUsage` report — a
|
|
4924
|
+
// response `getAvailableModels` already awaited to learn the live model
|
|
4925
|
+
// (resumed sessions ARE serviced pre-turn, unlike fresh ones) — is
|
|
4926
|
+
// authoritative and wins. Otherwise: the cached authoritative window if a
|
|
4927
|
+
// prior turn has learned it for this model (`result.modelUsage`,
|
|
4928
|
+
// cross-session), else the text heuristic, else the default. We
|
|
4929
|
+
// deliberately do NOT issue a getContextUsage call here: on a fresh
|
|
4930
|
+
// session that control request is not serviced until the first prompt
|
|
4931
|
+
// turn runs, so awaiting it — as 0.59.0 did — made session/new take ~15s
|
|
4932
|
+
// (issues #886/#880). The authoritative window arrives on the first
|
|
4933
|
+
// `result.modelUsage` and is cached from there.
|
|
4934
|
+
//
|
|
4935
|
+
// Text inference alone misses aliases that resolve to extended-context
|
|
4936
|
+
// models with no "1m" token anywhere in their id or description (e.g.
|
|
4937
|
+
// `sonnet` → claude-sonnet-5, natively ~1M): those stream
|
|
4938
|
+
// `usage_update.size: 200000` until the first result's modelUsage corrects
|
|
4939
|
+
// it — but the cache means only the FIRST session to ever run a turn on such
|
|
4940
|
+
// a model eats that window, not every fresh session after a process
|
|
4941
|
+
// restart (issue #596; a post-restart session/load is covered by the
|
|
4942
|
+
// resumed report above).
|
|
4665
4943
|
//
|
|
4666
|
-
// The inference fallback is deliberately keyed to the
|
|
4944
|
+
// The inference fallback is deliberately keyed to the catalog entry (the
|
|
4945
|
+
// fork's unfiltered lookup, see `catalogModelInfo` above): a
|
|
4667
4946
|
// fallback-resolved sibling's resolvedModel/displayName/description can
|
|
4668
4947
|
// describe a different context lane than the verbatim live id (e.g. an
|
|
4669
4948
|
// "opus[1m]" row matched for a bare 200k id), so on the fallback path only
|
|
4670
4949
|
// the id itself is a trustworthy window signal.
|
|
4671
|
-
const
|
|
4672
|
-
|
|
4673
|
-
|
|
4950
|
+
const seededWindow = resumedContextWindow !== null
|
|
4951
|
+
? { size: resumedContextWindow, authoritative: true }
|
|
4952
|
+
: immediateContextWindow(providerCacheKey, models.currentModelId, catalogModelInfo);
|
|
4674
4953
|
this.sessions[sessionId] = {
|
|
4675
4954
|
query: q,
|
|
4676
4955
|
input: input,
|
|
@@ -4698,9 +4977,13 @@ export class ClaudeAcpAgent {
|
|
|
4698
4977
|
agents,
|
|
4699
4978
|
currentAgent,
|
|
4700
4979
|
fastModeEnabled,
|
|
4980
|
+
fastModeDisabledReason,
|
|
4701
4981
|
abortController,
|
|
4702
4982
|
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
4703
|
-
|
|
4983
|
+
forwardSubagentText,
|
|
4984
|
+
contextWindowSize: seededWindow.size,
|
|
4985
|
+
contextWindowAuthoritative: seededWindow.authoritative,
|
|
4986
|
+
providerCacheKey,
|
|
4704
4987
|
taskState,
|
|
4705
4988
|
toolUseCache: {},
|
|
4706
4989
|
emittedToolCalls: new Set(),
|
|
@@ -4895,7 +5178,10 @@ function buildAvailableModes(modelInfo) {
|
|
|
4895
5178
|
// and only clears a key when an explicit `null` is sent — see
|
|
4896
5179
|
// `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
|
|
4897
5180
|
// `"default"` sentinel and `undefined` (effort option absent for the model) to
|
|
4898
|
-
// `null` ensures any previously-applied flag is actually cleared.
|
|
5181
|
+
// `null` ensures any previously-applied flag is actually cleared. Typed as
|
|
5182
|
+
// `EffortLevel` (not `Settings["effortLevel"]`): the picker offers whatever
|
|
5183
|
+
// `supportedEffortLevels` reports, which includes the session-scoped `"max"`
|
|
5184
|
+
// that the persisted Settings shape deliberately excludes.
|
|
4899
5185
|
function toSdkEffortLevel(value) {
|
|
4900
5186
|
return value === undefined || value === "default" ? null : value;
|
|
4901
5187
|
}
|
|
@@ -4953,17 +5239,58 @@ const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
|
|
|
4953
5239
|
export function fastModeStateEnabled(state) {
|
|
4954
5240
|
return state !== "off";
|
|
4955
5241
|
}
|
|
5242
|
+
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
|
|
5243
|
+
* that a user can act on (or at least wants to know about). Deliberately
|
|
5244
|
+
* partial — the omitted reasons are not worth surfacing:
|
|
5245
|
+
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
|
|
5246
|
+
* opt-in), so it describes the default, not a problem.
|
|
5247
|
+
* - `preference`: the user turned Fast mode off themselves.
|
|
5248
|
+
* - `pending`: eligibility is still resolving; the next report supersedes it.
|
|
5249
|
+
* - `unknown`: nothing meaningful to say.
|
|
5250
|
+
* Unknown future reasons fall through the same way (open set — the SDK's docs
|
|
5251
|
+
* say to ignore values you don't handle). */
|
|
5252
|
+
const FAST_MODE_UNAVAILABLE_EXPLANATIONS = {
|
|
5253
|
+
free: "not available on the free plan",
|
|
5254
|
+
extra_usage_disabled: "requires extra usage to be enabled for this account",
|
|
5255
|
+
model_not_allowed: "not available for the selected model",
|
|
5256
|
+
not_first_party: "not available on this API provider",
|
|
5257
|
+
disabled_by_env: "disabled by environment configuration",
|
|
5258
|
+
network_error: "eligibility could not be verified (network error)",
|
|
5259
|
+
};
|
|
5260
|
+
/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
|
|
5261
|
+
* a reason we have an explanation for, else `undefined`. Keeping only
|
|
5262
|
+
* explainable reasons means state comparisons (see `syncFastModeState`) track
|
|
5263
|
+
* exactly what the user can see, so routine reports like
|
|
5264
|
+
* `sdk_opt_in_required` never churn the config option. */
|
|
5265
|
+
export function normalizeFastModeDisabledReason(reason) {
|
|
5266
|
+
return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
|
|
5267
|
+
}
|
|
4956
5268
|
/** Build the Fast mode config option as a two-value on/off `select`. Emitted
|
|
4957
|
-
* for EVERY Client — the boolean option shape is gone (story 006, R2.1
|
|
4958
|
-
*
|
|
4959
|
-
*
|
|
4960
|
-
*
|
|
4961
|
-
*
|
|
4962
|
-
|
|
5269
|
+
* for EVERY Client — the boolean option shape is gone (story 006, R2.1;
|
|
5270
|
+
* retained through the v0.64.0 sync by story 008 R3.4). Only the emitted SHAPE
|
|
5271
|
+
* is fixed to a select; boolean VALUES are still honored on set (see
|
|
5272
|
+
* {@link resolveFastModeEnabled}). This factory is the single source of the
|
|
5273
|
+
* option's shape, re-rendered by `refreshFastModeOption` / `syncFastModeState`
|
|
5274
|
+
* so the shape can never desync.
|
|
5275
|
+
*
|
|
5276
|
+
* `disabledReason` (the SDK's `fast_mode_disabled_reason`, upstream v0.64.0) is
|
|
5277
|
+
* folded into the description while the toggle reads off, so a user whose
|
|
5278
|
+
* account or provider can't serve Fast mode sees why instead of a switch that
|
|
5279
|
+
* silently refuses to stay on. Ignored while enabled: a reason reported
|
|
5280
|
+
* alongside an `on`/`cooldown` state isn't blocking anything right now.
|
|
5281
|
+
*
|
|
5282
|
+
* Upstream's second parameter (`useBooleanOption`) is deliberately absent: the
|
|
5283
|
+
* shape is unconditionally a select, so there is no branch to select. What
|
|
5284
|
+
* guards that is behavioural, not structural — `tests/fast-mode-select-only.
|
|
5285
|
+
* test.ts` proves no argument combination can yield the boolean shape. */
|
|
5286
|
+
export function createFastModeConfigOption(enabled, disabledReason) {
|
|
5287
|
+
const explanation = enabled
|
|
5288
|
+
? undefined
|
|
5289
|
+
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
|
|
4963
5290
|
return {
|
|
4964
5291
|
id: FAST_MODE_CONFIG_ID,
|
|
4965
5292
|
name: "Fast mode",
|
|
4966
|
-
description: FAST_MODE_DESCRIPTION,
|
|
5293
|
+
description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
|
|
4967
5294
|
category: "model_config",
|
|
4968
5295
|
type: "select",
|
|
4969
5296
|
currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
|
|
@@ -5056,7 +5383,7 @@ thinkingEnabled) {
|
|
|
5056
5383
|
// option is always emitted as a two-value on/off select for every Client
|
|
5057
5384
|
// (R2.1); boolean values remain accepted on set for boolean-era clients.
|
|
5058
5385
|
if (fastMode?.supported) {
|
|
5059
|
-
options.push(createFastModeConfigOption(fastMode.enabled));
|
|
5386
|
+
options.push(createFastModeConfigOption(fastMode.enabled, fastMode.disabledReason));
|
|
5060
5387
|
}
|
|
5061
5388
|
// Surface the Thinking toggle whenever the caller supplies its display
|
|
5062
5389
|
// state. Unlike Fast mode it is model-independent — no `supported` gate —
|
|
@@ -5397,17 +5724,26 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
|
|
|
5397
5724
|
}
|
|
5398
5725
|
/** Read the model a resumed session is actually running (via the
|
|
5399
5726
|
* `getContextUsage` control request — the same source `/context` prints) and
|
|
5400
|
-
* map it onto the picker
|
|
5401
|
-
*
|
|
5402
|
-
*
|
|
5727
|
+
* map it onto the picker, along with the report's authoritative context
|
|
5728
|
+
* window (`rawMaxTokens`). Resumed sessions get this request serviced before
|
|
5729
|
+
* any turn runs in the new process — unlike fresh sessions, where it stalls
|
|
5730
|
+
* until the first prompt turn (issues #886/#880) — so the same response that
|
|
5731
|
+
* restores the live model (issue #845) also seeds the window for free,
|
|
5732
|
+
* covering post-restart reloads of models the text heuristic misses (issue
|
|
5733
|
+
* #596). Best-effort: a control-request failure is logged and returns nulls
|
|
5734
|
+
* so callers keep their current choice; failing the whole session/load over
|
|
5735
|
+
* an unreadable report would be worse. */
|
|
5403
5736
|
async function readResumedLiveModel(query, models, logger) {
|
|
5404
5737
|
try {
|
|
5405
|
-
const
|
|
5406
|
-
return
|
|
5738
|
+
const usage = await query.getContextUsage();
|
|
5739
|
+
return {
|
|
5740
|
+
model: usage.model ? matchResumedModel(models, usage.model) : null,
|
|
5741
|
+
contextWindow: usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null,
|
|
5742
|
+
};
|
|
5407
5743
|
}
|
|
5408
5744
|
catch (error) {
|
|
5409
5745
|
logger.error("Failed to read the resumed session's live model:", error);
|
|
5410
|
-
return null;
|
|
5746
|
+
return { model: null, contextWindow: null };
|
|
5411
5747
|
}
|
|
5412
5748
|
}
|
|
5413
5749
|
async function getAvailableModels(query,
|
|
@@ -5423,6 +5759,11 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
|
|
|
5423
5759
|
const settings = settingsManager.getSettings();
|
|
5424
5760
|
let currentModel = models[0];
|
|
5425
5761
|
let resolvedFromInput;
|
|
5762
|
+
// The context window reported alongside a resumed session's live model.
|
|
5763
|
+
// Only ever non-null on the paths where `currentModel` IS the live model
|
|
5764
|
+
// (no override, or a failed override re-assert), so the window always
|
|
5765
|
+
// describes the model the session actually runs.
|
|
5766
|
+
let resumedContextWindow = null;
|
|
5426
5767
|
// Model priority (highest to lowest):
|
|
5427
5768
|
// 1. ANTHROPIC_MODEL environment variable
|
|
5428
5769
|
// 2. settings.model (user configuration)
|
|
@@ -5450,7 +5791,9 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
|
|
|
5450
5791
|
// the SDK is already running this model, and pushing a picker alias back
|
|
5451
5792
|
// (e.g. "opus[1m]") could change the live model rather than describe it.
|
|
5452
5793
|
if (resolvedFromInput === undefined && isResumedSession) {
|
|
5453
|
-
|
|
5794
|
+
const live = await readResumedLiveModel(query, models, logger);
|
|
5795
|
+
currentModel = live.model ?? currentModel;
|
|
5796
|
+
resumedContextWindow = live.contextWindow;
|
|
5454
5797
|
}
|
|
5455
5798
|
// Skip the setModel round-trip when we can prove the SDK has already landed
|
|
5456
5799
|
// on the same model. Two cases qualify:
|
|
@@ -5483,16 +5826,25 @@ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
|
|
|
5483
5826
|
if (!isResumedSession)
|
|
5484
5827
|
throw error;
|
|
5485
5828
|
logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
|
|
5486
|
-
|
|
5829
|
+
const live = await readResumedLiveModel(query, models, logger);
|
|
5830
|
+
currentModel = live.model ?? currentModel;
|
|
5831
|
+
resumedContextWindow = live.contextWindow;
|
|
5487
5832
|
}
|
|
5488
5833
|
}
|
|
5489
5834
|
return {
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5835
|
+
modelState: {
|
|
5836
|
+
// Picker rows come from the DEPRECATION-FILTERED list (`pickerModels`),
|
|
5837
|
+
// not the raw catalog upstream maps here: the filter is visibility-only,
|
|
5838
|
+
// so preference resolution and capability lookups elsewhere keep reading
|
|
5839
|
+
// the unfiltered catalog (R4.3).
|
|
5840
|
+
availableModels: pickerModels.map((model) => ({
|
|
5841
|
+
modelId: model.value,
|
|
5842
|
+
name: model.displayName,
|
|
5843
|
+
description: model.description,
|
|
5844
|
+
})),
|
|
5845
|
+
currentModelId: currentModel.value,
|
|
5846
|
+
},
|
|
5847
|
+
resumedContextWindow,
|
|
5496
5848
|
};
|
|
5497
5849
|
}
|
|
5498
5850
|
function getAvailableSlashCommands(commands) {
|
|
@@ -5630,6 +5982,12 @@ export function promptToClaude(prompt) {
|
|
|
5630
5982
|
},
|
|
5631
5983
|
session_id: prompt.sessionId,
|
|
5632
5984
|
parent_tool_use_id: null,
|
|
5985
|
+
// ACP prompts are the user's own input relayed by the client. Stamp the
|
|
5986
|
+
// provenance explicitly: per the SDK, a host wrapping keyboard input must
|
|
5987
|
+
// send `{kind: "human"}` — an absent `origin` is treated as unattributed
|
|
5988
|
+
// and fails closed at the CLI's strict isHuman() trust gates (e.g. the
|
|
5989
|
+
// ultracode keyword opt-in honors only human-originated turns).
|
|
5990
|
+
origin: { kind: "human" },
|
|
5633
5991
|
};
|
|
5634
5992
|
}
|
|
5635
5993
|
/**
|
|
@@ -5686,6 +6044,24 @@ function isTaskTool(toolName) {
|
|
|
5686
6044
|
function shouldEmitToolCall(toolName) {
|
|
5687
6045
|
return toolName !== "TodoWrite" && !isTaskTool(toolName);
|
|
5688
6046
|
}
|
|
6047
|
+
/** Build the Claude Code-specific metadata for a tool call. Bash descriptions
|
|
6048
|
+
* are kept out of ACP's standard `title`, which clients may use as the shell
|
|
6049
|
+
* command preview, while still giving clients access to Claude's concise
|
|
6050
|
+
* human-readable title. */
|
|
6051
|
+
function claudeCodeMetaFromToolUse(toolUse) {
|
|
6052
|
+
const description = toolUse.name === "Bash" &&
|
|
6053
|
+
toolUse.input !== null &&
|
|
6054
|
+
typeof toolUse.input === "object" &&
|
|
6055
|
+
"description" in toolUse.input &&
|
|
6056
|
+
typeof toolUse.input.description === "string"
|
|
6057
|
+
? toolUse.input.description
|
|
6058
|
+
: undefined;
|
|
6059
|
+
return {
|
|
6060
|
+
toolName: toolUse.name,
|
|
6061
|
+
...(description ? { title: description } : {}),
|
|
6062
|
+
...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true }),
|
|
6063
|
+
};
|
|
6064
|
+
}
|
|
5689
6065
|
/** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
|
|
5690
6066
|
* notification for a tool_use. Shared by every site that surfaces a tool call:
|
|
5691
6067
|
* the streamed tool_use path (first encounter → tool_call, later encounter →
|
|
@@ -5696,7 +6072,7 @@ function shouldEmitToolCall(toolName) {
|
|
|
5696
6072
|
function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
|
|
5697
6073
|
if (refine) {
|
|
5698
6074
|
return {
|
|
5699
|
-
_meta: { claudeCode:
|
|
6075
|
+
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) },
|
|
5700
6076
|
toolCallId: toolUse.id,
|
|
5701
6077
|
sessionUpdate: "tool_call_update",
|
|
5702
6078
|
rawInput,
|
|
@@ -5705,7 +6081,7 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
|
|
|
5705
6081
|
}
|
|
5706
6082
|
return {
|
|
5707
6083
|
_meta: {
|
|
5708
|
-
claudeCode:
|
|
6084
|
+
claudeCode: claudeCodeMetaFromToolUse(toolUse),
|
|
5709
6085
|
...(toolUse.name === "Bash" && supportsTerminalOutput
|
|
5710
6086
|
? { terminal_info: { terminal_id: toolUse.id } }
|
|
5711
6087
|
: {}),
|
|
@@ -5730,7 +6106,9 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
|
5730
6106
|
}
|
|
5731
6107
|
const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
|
|
5732
6108
|
return {
|
|
5733
|
-
_meta: {
|
|
6109
|
+
_meta: {
|
|
6110
|
+
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
|
|
6111
|
+
},
|
|
5734
6112
|
toolCallId: toolUse.id,
|
|
5735
6113
|
sessionUpdate: "tool_call_update",
|
|
5736
6114
|
rawInput: input,
|
|
@@ -5739,6 +6117,33 @@ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
|
5739
6117
|
...(locations ? { locations } : {}),
|
|
5740
6118
|
};
|
|
5741
6119
|
}
|
|
6120
|
+
/** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the
|
|
6121
|
+
* wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a
|
|
6122
|
+
* by-tool_use_id lookup. Each entry explains why an is_error tool_result
|
|
6123
|
+
* carries harness prose instead of the tool's own output — "user-rejected",
|
|
6124
|
+
* "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship
|
|
6125
|
+
* on the wire ahead of schema updates, so no enum check). Malformed entries
|
|
6126
|
+
* are skipped rather than failing the message. */
|
|
6127
|
+
function parseToolResultMeta(raw) {
|
|
6128
|
+
if (!Array.isArray(raw)) {
|
|
6129
|
+
return undefined;
|
|
6130
|
+
}
|
|
6131
|
+
let byToolUseId;
|
|
6132
|
+
for (const entry of raw) {
|
|
6133
|
+
if (typeof entry !== "object" || entry === null) {
|
|
6134
|
+
continue;
|
|
6135
|
+
}
|
|
6136
|
+
const { id, non_execution_kind, user_feedback } = entry;
|
|
6137
|
+
if (typeof id !== "string" || typeof non_execution_kind !== "string") {
|
|
6138
|
+
continue;
|
|
6139
|
+
}
|
|
6140
|
+
(byToolUseId ??= new Map()).set(id, {
|
|
6141
|
+
nonExecutionKind: non_execution_kind,
|
|
6142
|
+
...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
|
|
6143
|
+
});
|
|
6144
|
+
}
|
|
6145
|
+
return byToolUseId;
|
|
6146
|
+
}
|
|
5742
6147
|
/**
|
|
5743
6148
|
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
5744
6149
|
* Only handles text, image, and thinking chunks for now.
|
|
@@ -5779,6 +6184,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
5779
6184
|
.length === 1
|
|
5780
6185
|
? options.toolUseResult
|
|
5781
6186
|
: undefined;
|
|
6187
|
+
// Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
|
|
6188
|
+
// messages need no single-block guard.
|
|
6189
|
+
const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
|
|
5782
6190
|
const output = [];
|
|
5783
6191
|
// Only handle the first chunk for streaming; extend as needed for batching
|
|
5784
6192
|
for (const chunk of content) {
|
|
@@ -5922,6 +6330,12 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
5922
6330
|
case "mcp_tool_result": {
|
|
5923
6331
|
const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
|
|
5924
6332
|
options?.emittedToolCalls?.delete(chunk.tool_use_id);
|
|
6333
|
+
// Why this is_error result carries harness prose instead of tool
|
|
6334
|
+
// output (user-rejected / interrupted / …), when the SDK said so.
|
|
6335
|
+
// Spread into the claudeCode meta of every update emitted below; the
|
|
6336
|
+
// untracked-tool fallback can't carry it (claudeCode metas always
|
|
6337
|
+
// carry `toolName`, which is unknown there).
|
|
6338
|
+
const nonExecution = toolResultMeta?.get(chunk.tool_use_id);
|
|
5925
6339
|
const toolUse = toolUseCache[chunk.tool_use_id];
|
|
5926
6340
|
if (!toolUse) {
|
|
5927
6341
|
// The permission flow may have surfaced this tool_call even though
|
|
@@ -5961,6 +6375,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
5961
6375
|
_meta: {
|
|
5962
6376
|
claudeCode: {
|
|
5963
6377
|
toolName: toolUse.name,
|
|
6378
|
+
...(nonExecution ?? {}),
|
|
5964
6379
|
...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
|
|
5965
6380
|
},
|
|
5966
6381
|
},
|
|
@@ -6021,6 +6436,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
6021
6436
|
_meta: {
|
|
6022
6437
|
claudeCode: {
|
|
6023
6438
|
toolName: toolUse.name,
|
|
6439
|
+
...(nonExecution ?? {}),
|
|
6024
6440
|
},
|
|
6025
6441
|
...(toolMeta?.terminal_exit ? { terminal_exit: toolMeta.terminal_exit } : {}),
|
|
6026
6442
|
},
|
|
@@ -6235,6 +6651,7 @@ export function runAcp() {
|
|
|
6235
6651
|
.onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
|
|
6236
6652
|
.onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
|
|
6237
6653
|
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
|
|
6654
|
+
.onRequest(STEER_METHOD, { parse: parseSteerRequest }, (ctx) => agent.steer(ctx.params))
|
|
6238
6655
|
.connect(stream);
|
|
6239
6656
|
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
|
6240
6657
|
return { connection, agent };
|
|
@@ -6246,9 +6663,9 @@ function commonPrefixLength(a, b) {
|
|
|
6246
6663
|
}
|
|
6247
6664
|
return i;
|
|
6248
6665
|
}
|
|
6249
|
-
/** Best-effort first guess of a model's context window, used
|
|
6250
|
-
*
|
|
6251
|
-
*
|
|
6666
|
+
/** Best-effort first guess of a model's context window, used to seed the
|
|
6667
|
+
* window synchronously (via `immediateContextWindow`) until a `result` message
|
|
6668
|
+
* arrives with the authoritative `modelUsage` value.
|
|
6252
6669
|
*
|
|
6253
6670
|
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
|
6254
6671
|
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
|
@@ -6258,9 +6675,12 @@ function commonPrefixLength(a, b) {
|
|
|
6258
6675
|
* "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
|
|
6259
6676
|
* This text scan can't catch every model — some resolve to extended-context
|
|
6260
6677
|
* models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
|
|
6261
|
-
* ~1M)
|
|
6262
|
-
*
|
|
6263
|
-
*
|
|
6678
|
+
* ~1M). Such a miss falls back to the default window and is corrected by
|
|
6679
|
+
* `result.modelUsage` (and cached) within one turn. We do NOT consult the
|
|
6680
|
+
* SDK's `getContextUsage` to close that gap: on a fresh session it is not
|
|
6681
|
+
* serviced before the first prompt turn (issues #886/#880, see
|
|
6682
|
+
* `contextWindowCache`; resumed sessions do get it, via
|
|
6683
|
+
* `readResumedLiveModel`). */
|
|
6264
6684
|
function inferContextWindowFromModel(...texts) {
|
|
6265
6685
|
if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
|
|
6266
6686
|
return 1_000_000;
|
|
@@ -6281,28 +6701,95 @@ async function fetchContextUsedTokens(query, logger) {
|
|
|
6281
6701
|
return null;
|
|
6282
6702
|
}
|
|
6283
6703
|
}
|
|
6284
|
-
/**
|
|
6285
|
-
* `
|
|
6286
|
-
*
|
|
6287
|
-
*
|
|
6288
|
-
*
|
|
6289
|
-
*
|
|
6290
|
-
*
|
|
6704
|
+
/** Cross-session cache of authoritative context windows, keyed by
|
|
6705
|
+
* `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}).
|
|
6706
|
+
* The window is a property of (model id, backend): the same resolved model id
|
|
6707
|
+
* (e.g. "claude-sonnet-5[1m]", the spelling of the `result.modelUsage` keys)
|
|
6708
|
+
* can name different context lanes behind different base URLs, routing
|
|
6709
|
+
* headers, or credentials, so the key carries both. Caching it module-level
|
|
6710
|
+
* lets a later session/new or switch that resolves to the same (backend,
|
|
6711
|
+
* model) — in this session or any other, within the adapter's lifetime — seed
|
|
6712
|
+
* the correct window synchronously with no IPC. Keying on the resolved id
|
|
6713
|
+
* (rather than the picker value) means aliases that resolve to the same
|
|
6714
|
+
* concrete model share one entry; the result handler additionally writes the
|
|
6715
|
+
* bare assistant-message spelling so seed-time reads that fall back to a
|
|
6716
|
+
* verbatim live id (rows without `resolvedModel`) can hit too.
|
|
6291
6717
|
*
|
|
6292
|
-
*
|
|
6293
|
-
*
|
|
6294
|
-
*
|
|
6295
|
-
*
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6718
|
+
* Populated authoritatively by each `result.modelUsage` a turn confirms (see
|
|
6719
|
+
* the consumer's result handler). We deliberately never populate it from a
|
|
6720
|
+
* fresh session's `getContextUsage`: before that session's first prompt turn
|
|
6721
|
+
* has run the control request is not serviced (it stalls ~15s, and serializes
|
|
6722
|
+
* ahead of an awaited `setModel` — issues #886/#880, regressed in 0.59.0), so
|
|
6723
|
+
* it can neither beat the first `result` nor be issued cheaply before one.
|
|
6724
|
+
* Resumed sessions are the exception — their report IS serviced pre-turn, and
|
|
6725
|
+
* the session/load path seeds (but does not cache) the window from the same
|
|
6726
|
+
* response that restores the live model, see `readResumedLiveModel`.
|
|
6727
|
+
* Cleared on `logout`: 1M-context entitlement can differ per account/tier, so
|
|
6728
|
+
* windows learned under one login must not seed sessions under the next. */
|
|
6729
|
+
const contextWindowCache = new Map();
|
|
6730
|
+
/** The env vars that determine which LLM backend — and which context lane on
|
|
6731
|
+
* it — a query's API traffic reaches: endpoint selection (base URLs and the
|
|
6732
|
+
* Bedrock/Vertex switches with their project/region), routing/beta headers
|
|
6733
|
+
* (an `anthropic-beta: context-1m-…` header flips the same model id at the
|
|
6734
|
+
* same endpoint between context lanes), and credential identity (extended
|
|
6735
|
+
* context is entitlement-gated per account). Used to derive the
|
|
6736
|
+
* provider-cache key from the exact env a query is created with, so
|
|
6737
|
+
* `providers/set` config, per-session `_meta` env overrides, and ambient
|
|
6738
|
+
* process env are all distinguished exactly as the CLI will see them. */
|
|
6739
|
+
const PROVIDER_ROUTING_ENV_VARS = [
|
|
6740
|
+
"ANTHROPIC_BASE_URL",
|
|
6741
|
+
"ANTHROPIC_BEDROCK_BASE_URL",
|
|
6742
|
+
"ANTHROPIC_VERTEX_BASE_URL",
|
|
6743
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
6744
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
6745
|
+
"ANTHROPIC_VERTEX_PROJECT_ID",
|
|
6746
|
+
"CLOUD_ML_REGION",
|
|
6747
|
+
"AWS_REGION",
|
|
6748
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
6749
|
+
"ANTHROPIC_API_KEY",
|
|
6750
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
6751
|
+
];
|
|
6752
|
+
/** Stable identifier for the LLM backend a session's query is created against,
|
|
6753
|
+
* used to scope {@link contextWindowCache} per backend. Positional `\0`-join
|
|
6754
|
+
* of {@link PROVIDER_ROUTING_ENV_VARS} values, so no segment can masquerade
|
|
6755
|
+
* as another and unset vars everywhere yield one stable "default" bucket.
|
|
6756
|
+
* Header/credential values can be secrets; the key only ever lives as an
|
|
6757
|
+
* in-memory Map key and is never logged or surfaced. Over-keying is the safe
|
|
6758
|
+
* side: a var change that didn't really change the backend costs one cache
|
|
6759
|
+
* miss (heuristic seed until the next result), while under-keying would serve
|
|
6760
|
+
* one backend's window for another's. */
|
|
6761
|
+
function providerCacheKeyFor(env) {
|
|
6762
|
+
return PROVIDER_ROUTING_ENV_VARS.map((name) => env[name] ?? "").join("\0");
|
|
6763
|
+
}
|
|
6764
|
+
/** Compose the `contextWindowCache` key from a session's provider key and a
|
|
6765
|
+
* model id. `\0`-joined so the model segment can't collide with a provider
|
|
6766
|
+
* segment. */
|
|
6767
|
+
function contextWindowCacheKey(providerCacheKey, modelId) {
|
|
6768
|
+
return `${providerCacheKey}\0${modelId}`;
|
|
6769
|
+
}
|
|
6770
|
+
function cacheContextWindow(modelKey, window) {
|
|
6771
|
+
if (window > 0) {
|
|
6772
|
+
contextWindowCache.set(modelKey, window);
|
|
6304
6773
|
}
|
|
6305
6774
|
}
|
|
6775
|
+
/** The context window to report *right now* for a model, with NO IPC on the
|
|
6776
|
+
* critical path: the cached authoritative value if we've learned it (from a
|
|
6777
|
+
* prior turn's `result.modelUsage`, this or any session on the same backend),
|
|
6778
|
+
* else the text heuristic over the model row's identity strings, else the
|
|
6779
|
+
* default. Derives the cache key itself — `modelInfo?.resolvedModel ?? modelId`,
|
|
6780
|
+
* the same rule at every seed site — so read keys can't drift from the write
|
|
6781
|
+
* site's spelling. `authoritative` reports whether the value came from the
|
|
6782
|
+
* cache: an authoritative window can legitimately equal
|
|
6783
|
+
* DEFAULT_CONTEXT_WINDOW, so the value alone can't tell the caller. */
|
|
6784
|
+
function immediateContextWindow(providerCacheKey, modelId, modelInfo) {
|
|
6785
|
+
const cached = contextWindowCache.get(contextWindowCacheKey(providerCacheKey, modelInfo?.resolvedModel ?? modelId));
|
|
6786
|
+
if (cached !== undefined)
|
|
6787
|
+
return { size: cached, authoritative: true };
|
|
6788
|
+
return {
|
|
6789
|
+
size: inferContextWindowFromModel(modelId, modelInfo?.resolvedModel, modelInfo?.displayName, modelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
|
|
6790
|
+
authoritative: false,
|
|
6791
|
+
};
|
|
6792
|
+
}
|
|
6306
6793
|
function parseModelConfig(raw) {
|
|
6307
6794
|
if (!raw)
|
|
6308
6795
|
return undefined;
|
|
@@ -6328,6 +6815,12 @@ function getMatchingModelUsage(modelUsage, currentModel) {
|
|
|
6328
6815
|
}
|
|
6329
6816
|
}
|
|
6330
6817
|
if (bestKey) {
|
|
6331
|
-
|
|
6818
|
+
// `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"),
|
|
6819
|
+
// the same spelling as ModelInfo.resolvedModel — the primary key the
|
|
6820
|
+
// window is cached under. `currentModel` (the assistant message's
|
|
6821
|
+
// `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result
|
|
6822
|
+
// handler caches under that spelling too, for seed-time reads that fall
|
|
6823
|
+
// back to a bare id (rows without `resolvedModel`).
|
|
6824
|
+
return { key: bestKey, usage: modelUsage[bestKey] };
|
|
6332
6825
|
}
|
|
6333
6826
|
}
|