claude-code-acp-ts 0.57.0-beta.2 → 0.59.0-beta.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 +332 -8
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +1566 -306
- package/dist/tools.d.ts +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +208 -8
- package/package.json +6 -6
package/dist/acp-agent.js
CHANGED
|
@@ -48,6 +48,47 @@ const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
|
|
|
48
48
|
* completed/stalled without the host turn resolving (issue #825). */
|
|
49
49
|
const TURN_NO_RESULT_MESSAGE = "The turn ended without a result: the agent went idle while this prompt was still in flight " +
|
|
50
50
|
"(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
|
|
51
|
+
/** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
|
|
52
|
+
* model did on its own (a task-notification followup, a peer/coordinator/
|
|
53
|
+
* observer message it handled) rather than the user's prompt. Absent,
|
|
54
|
+
* `human`, and `channel` origins are the user's own turn (this adapter's
|
|
55
|
+
* prompts arrive as the ACP channel on some CLI configurations — ALL
|
|
56
|
+
* channel servers are treated as user, so a foreign channel integration's
|
|
57
|
+
* autonomously-handled result is misclassified as the user's; accepted,
|
|
58
|
+
* see below), and `auto-continuation` continues the user's turn, so its
|
|
59
|
+
* result is the turn's real terminal.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately fail-OPEN: an unknown future kind defaults to the user
|
|
62
|
+
* lane. Misrouting a USER result into the autonomous lane hangs the
|
|
63
|
+
* prompt un-detectably (the result is skipped, its trailing idle absorbed
|
|
64
|
+
* as owed, so the #825 detector can't fire); misrouting an autonomous
|
|
65
|
+
* result into the user lane is the bounded misattribution class this set
|
|
66
|
+
* exists to reduce. */
|
|
67
|
+
const AUTONOMOUS_RESULT_ORIGINS = new Set([
|
|
68
|
+
"task-notification",
|
|
69
|
+
"peer",
|
|
70
|
+
"coordinator",
|
|
71
|
+
"observer",
|
|
72
|
+
"observer-activity",
|
|
73
|
+
]);
|
|
74
|
+
/** Whether this turn's terminal result arrived but its settlement is being
|
|
75
|
+
* held for background subagents it spawned (see Turn.deferredSettle). The
|
|
76
|
+
* single spelling of the hold predicate, shared by the consumer's settle
|
|
77
|
+
* lanes and cancel(). */
|
|
78
|
+
function isHeldOpen(turn) {
|
|
79
|
+
return turn != null && turn.deferredSettle !== undefined && !turn.settled;
|
|
80
|
+
}
|
|
81
|
+
/** Disarm the force-cancel backstop (see Session.forceCancelTimer). Every
|
|
82
|
+
* path that settles the active turn must run this so a timer can never fire
|
|
83
|
+
* on an already-settled turn — and must leave the field undefined, or the
|
|
84
|
+
* arm site's !forceCancelTimer guard would refuse to arm the backstop for
|
|
85
|
+
* the NEXT turn's cancel. */
|
|
86
|
+
function disarmForceCancel(session) {
|
|
87
|
+
if (session.forceCancelTimer) {
|
|
88
|
+
clearTimeout(session.forceCancelTimer);
|
|
89
|
+
session.forceCancelTimer = undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
51
92
|
/** Compute a stable fingerprint of the session-defining params so we can
|
|
52
93
|
* detect when a loadSession/resumeSession call requires tearing down and
|
|
53
94
|
* recreating the underlying Query process. MCP servers are sorted by name
|
|
@@ -56,6 +97,78 @@ function computeSessionFingerprint(params) {
|
|
|
56
97
|
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
|
57
98
|
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
|
58
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The single provider ID this agent exposes via `providers/*`. Claude Code has
|
|
102
|
+
* one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
|
|
103
|
+
* is exactly one configurable provider.
|
|
104
|
+
*/
|
|
105
|
+
const PROVIDER_ID = "main";
|
|
106
|
+
/**
|
|
107
|
+
* Protocols the `main` provider can be configured with. These mirror the
|
|
108
|
+
* env-var mappings understood by {@link createEnvForProvider}.
|
|
109
|
+
*/
|
|
110
|
+
const SUPPORTED_PROTOCOLS = ["anthropic", "bedrock", "vertex"];
|
|
111
|
+
/**
|
|
112
|
+
* Advance the lexer state across the fragment appended since the last delta:
|
|
113
|
+
* just enough JSON awareness (string/escape, nesting depth) to spot commas
|
|
114
|
+
* that sit at the top level of the input object — everything before such a
|
|
115
|
+
* comma is a set of complete fields. Returns true once the input object's
|
|
116
|
+
* closing brace arrives.
|
|
117
|
+
*/
|
|
118
|
+
function scanStreamedToolInput(state) {
|
|
119
|
+
let complete = false;
|
|
120
|
+
for (let index = state.scannedTo; index < state.partialJson.length; index++) {
|
|
121
|
+
const character = state.partialJson[index];
|
|
122
|
+
if (state.inString) {
|
|
123
|
+
if (state.escaped) {
|
|
124
|
+
state.escaped = false;
|
|
125
|
+
}
|
|
126
|
+
else if (character === "\\") {
|
|
127
|
+
state.escaped = true;
|
|
128
|
+
}
|
|
129
|
+
else if (character === '"') {
|
|
130
|
+
state.inString = false;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (character === '"') {
|
|
135
|
+
state.inString = true;
|
|
136
|
+
}
|
|
137
|
+
else if (character === "{") {
|
|
138
|
+
state.objectDepth++;
|
|
139
|
+
}
|
|
140
|
+
else if (character === "}") {
|
|
141
|
+
state.objectDepth--;
|
|
142
|
+
if (state.objectDepth === 0) {
|
|
143
|
+
complete = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else if (character === "[") {
|
|
147
|
+
state.arrayDepth++;
|
|
148
|
+
}
|
|
149
|
+
else if (character === "]") {
|
|
150
|
+
state.arrayDepth--;
|
|
151
|
+
}
|
|
152
|
+
else if (character === "," && state.objectDepth === 1 && state.arrayDepth === 0) {
|
|
153
|
+
state.lastTopLevelComma = index;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
state.scannedTo = state.partialJson.length;
|
|
157
|
+
return complete;
|
|
158
|
+
}
|
|
159
|
+
/** Parse the complete top-level fields before a top-level comma by closing the
|
|
160
|
+
* object at that boundary. */
|
|
161
|
+
function recoveredToolInput(prefix) {
|
|
162
|
+
try {
|
|
163
|
+
const value = JSON.parse(prefix + "}");
|
|
164
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
165
|
+
? value
|
|
166
|
+
: undefined;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
59
172
|
export async function claudeCliPath() {
|
|
60
173
|
if (process.env.CLAUDE_CODE_EXECUTABLE) {
|
|
61
174
|
return process.env.CLAUDE_CODE_EXECUTABLE;
|
|
@@ -188,6 +301,36 @@ export function stripLocalCommandMetadata(content) {
|
|
|
188
301
|
export function isLocalCommandMetadata(content) {
|
|
189
302
|
return stripLocalCommandMetadata(content) === null;
|
|
190
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* True for the synthetic assistant message the CLI injects into the transcript
|
|
306
|
+
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
|
|
307
|
+
* "Session expired. Please run /login to sign in again."). The `/login`
|
|
308
|
+
* instruction is Claude Code TUI-specific and meaningless to ACP clients
|
|
309
|
+
* (issue #863). The live prompt loop suppresses the text and fails the turn
|
|
310
|
+
* with `authRequired` so the client can run its own auth flow; replay must
|
|
311
|
+
* skip it too — both for parity with what the client saw live and because the
|
|
312
|
+
* message stays in the transcript forever, so it would resurface on every
|
|
313
|
+
* session/load even after the user has logged back in.
|
|
314
|
+
*
|
|
315
|
+
* Takes the API message (`message.message`), which replay only knows as
|
|
316
|
+
* `unknown`. The persisted record's structured `error: "authentication_failed"`
|
|
317
|
+
* marker is stripped by `getSessionMessages`, so the synthetic model + text is
|
|
318
|
+
* all both paths have to match on.
|
|
319
|
+
*/
|
|
320
|
+
export function isSyntheticLoginMessage(apiMessage) {
|
|
321
|
+
if (!apiMessage || typeof apiMessage !== "object") {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
const { model, content } = apiMessage;
|
|
325
|
+
if (model !== "<synthetic>" || !Array.isArray(content) || content.length !== 1) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
const block = content[0];
|
|
329
|
+
return (!!block &&
|
|
330
|
+
block.type === "text" &&
|
|
331
|
+
typeof block.text === "string" &&
|
|
332
|
+
block.text.includes("Please run /login"));
|
|
333
|
+
}
|
|
191
334
|
const PERMISSION_MODE_ALIASES = {
|
|
192
335
|
auto: "auto",
|
|
193
336
|
default: "default",
|
|
@@ -301,6 +444,10 @@ export class ClaudeAcpAgent {
|
|
|
301
444
|
clientCapabilities;
|
|
302
445
|
logger;
|
|
303
446
|
gatewayAuthRequest;
|
|
447
|
+
/** Client-managed LLM routing set via `providers/set`. Process-scoped and
|
|
448
|
+
* never persisted to disk (see the Configurable LLM Providers RFD). When
|
|
449
|
+
* set, it takes precedence over {@link gatewayAuthRequest}. */
|
|
450
|
+
providerConfig;
|
|
304
451
|
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
|
305
452
|
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
306
453
|
* tests can shrink it. */
|
|
@@ -426,6 +573,10 @@ export class ClaudeAcpAgent {
|
|
|
426
573
|
auth: {
|
|
427
574
|
logout: {},
|
|
428
575
|
},
|
|
576
|
+
// Client-managed LLM routing via `providers/list`, `providers/set`, and
|
|
577
|
+
// `providers/disable`. Advertised unconditionally; there is no client
|
|
578
|
+
// capability prerequisite for the provider methods.
|
|
579
|
+
providers: {},
|
|
429
580
|
loadSession: true,
|
|
430
581
|
sessionCapabilities: {
|
|
431
582
|
additionalDirectories: {},
|
|
@@ -549,11 +700,94 @@ export class ClaudeAcpAgent {
|
|
|
549
700
|
}
|
|
550
701
|
throw new Error("Method not implemented.");
|
|
551
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* `providers/list` — returns the single client-configurable custom gateway
|
|
705
|
+
* provider (`main`). `current` carries only non-secret routing (never headers,
|
|
706
|
+
* which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
|
|
707
|
+
* display, and is `null` when the provider is not configured/disabled. The
|
|
708
|
+
* provider is optional (`required: false`): while disabled/unconfigured the
|
|
709
|
+
* agent falls back to its own default routing (normal Claude login).
|
|
710
|
+
*/
|
|
711
|
+
async unstable_listProviders(_params) {
|
|
712
|
+
const config = this.resolveProviderConfig();
|
|
713
|
+
const provider = {
|
|
714
|
+
providerId: PROVIDER_ID,
|
|
715
|
+
supported: SUPPORTED_PROTOCOLS,
|
|
716
|
+
required: false,
|
|
717
|
+
current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null,
|
|
718
|
+
};
|
|
719
|
+
return { providers: [provider] };
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* `providers/set` — replace the full configuration for the `main` provider.
|
|
723
|
+
* Rejects unknown IDs, unsupported protocols, and empty/invalid base URLs with
|
|
724
|
+
* `invalid_params`. Config is process-scoped and applies to sessions created or
|
|
725
|
+
* loaded after this call.
|
|
726
|
+
*/
|
|
727
|
+
async unstable_setProvider(params) {
|
|
728
|
+
if (params.providerId !== PROVIDER_ID) {
|
|
729
|
+
throw RequestError.invalidParams({ providerId: params.providerId }, `Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`);
|
|
730
|
+
}
|
|
731
|
+
if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) {
|
|
732
|
+
throw RequestError.invalidParams({ apiType: params.apiType, supported: SUPPORTED_PROTOCOLS }, `Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`);
|
|
733
|
+
}
|
|
734
|
+
if (!isValidBaseUrl(params.baseUrl)) {
|
|
735
|
+
throw RequestError.invalidParams({ baseUrl: params.baseUrl }, "baseUrl must be a non-empty absolute http(s) URL.");
|
|
736
|
+
}
|
|
737
|
+
const config = {
|
|
738
|
+
apiType: params.apiType,
|
|
739
|
+
baseUrl: params.baseUrl,
|
|
740
|
+
headers: params.headers ?? {},
|
|
741
|
+
};
|
|
742
|
+
// Vertex requires project + region, which the standard payload cannot
|
|
743
|
+
// carry, so they arrive via `_meta.claudeCode.vertex`.
|
|
744
|
+
if (params.apiType === "vertex") {
|
|
745
|
+
const vertex = params._meta?.claudeCode?.vertex;
|
|
746
|
+
if (!vertex ||
|
|
747
|
+
typeof vertex.projectId !== "string" ||
|
|
748
|
+
vertex.projectId.trim() === "" ||
|
|
749
|
+
typeof vertex.region !== "string" ||
|
|
750
|
+
vertex.region.trim() === "") {
|
|
751
|
+
throw RequestError.invalidParams(undefined, "vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.");
|
|
752
|
+
}
|
|
753
|
+
config.vertex = { projectId: vertex.projectId, region: vertex.region };
|
|
754
|
+
}
|
|
755
|
+
this.providerConfig = config;
|
|
756
|
+
return {};
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* `providers/disable` — disabling the `main` provider clears any client-managed
|
|
760
|
+
* routing (both a `providers/set` config and the legacy gateway auth request),
|
|
761
|
+
* so the agent reverts to its own default routing and `providers/list` reports
|
|
762
|
+
* `current: null`. Disabling any other (unknown) ID is treated as a successful
|
|
763
|
+
* no-op per the RFD's idempotency rule.
|
|
764
|
+
*/
|
|
765
|
+
async unstable_disableProvider(params) {
|
|
766
|
+
if (params.providerId === PROVIDER_ID) {
|
|
767
|
+
this.providerConfig = undefined;
|
|
768
|
+
this.gatewayAuthRequest = undefined;
|
|
769
|
+
}
|
|
770
|
+
// Unknown provider: idempotent success.
|
|
771
|
+
return {};
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Resolve the effective client-managed routing config. `providers/set` takes
|
|
775
|
+
* precedence; otherwise fall back to the legacy gateway auth request. Returns
|
|
776
|
+
* `null` when neither is configured.
|
|
777
|
+
*/
|
|
778
|
+
resolveProviderConfig() {
|
|
779
|
+
if (this.providerConfig) {
|
|
780
|
+
return this.providerConfig;
|
|
781
|
+
}
|
|
782
|
+
return gatewayRequestToProviderConfig(this.gatewayAuthRequest);
|
|
783
|
+
}
|
|
552
784
|
async logout(_params) {
|
|
553
|
-
// Clear in-memory gateway credentials supplied via `authenticate
|
|
554
|
-
//
|
|
555
|
-
//
|
|
785
|
+
// Clear in-memory gateway credentials supplied via `authenticate` and any
|
|
786
|
+
// provider routing set via `providers/set`. Neither touches the on-disk
|
|
787
|
+
// credential store, so dropping these references is the whole logout for
|
|
788
|
+
// those paths.
|
|
556
789
|
this.gatewayAuthRequest = undefined;
|
|
790
|
+
this.providerConfig = undefined;
|
|
557
791
|
// For the Claude/Console login methods the credentials live in the native
|
|
558
792
|
// CLI's store (keychain or config dir), which only the binary can clear.
|
|
559
793
|
// `claude auth logout` is non-interactive and idempotent.
|
|
@@ -666,23 +900,34 @@ export class ClaudeAcpAgent {
|
|
|
666
900
|
// gateways that don't carry a stable/matching id across the stream and the
|
|
667
901
|
// consolidated message. Reset after each consolidated message consumes it.
|
|
668
902
|
const streamedBlocks = [];
|
|
903
|
+
// Tool-use blocks start streaming before their JSON input. Keep the
|
|
904
|
+
// partial input per parent message and block index so completed top-level
|
|
905
|
+
// fields can refine the pending tool call while it streams. Entries are
|
|
906
|
+
// dropped at block/message boundaries; the whole map is swept when a turn
|
|
907
|
+
// settles, since an interrupted subagent stream (keyed by a
|
|
908
|
+
// parent_tool_use_id that never recurs) has no boundary event of its own.
|
|
909
|
+
const streamedToolInputs = new Map();
|
|
669
910
|
// Stop reason accumulated for the active turn (result subtype, refusal,
|
|
670
911
|
// max_tokens, …). Reset per turn; read when the turn settles at idle.
|
|
671
912
|
let stopReason = "end_turn";
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
913
|
+
/** The consumer's single send chokepoint: every `sessionUpdate` in this
|
|
914
|
+
* loop goes through here (never `this.client.sessionUpdate` directly) so
|
|
915
|
+
* answer-delivery tracking is a property of sending, not something each
|
|
916
|
+
* emission site must remember. A top-level `agent_message_chunk` marks
|
|
917
|
+
* the stretch's answer as delivered; subagent-attributed chunks are
|
|
918
|
+
* recognizable by the `parentToolUseId` meta that toAcpNotifications
|
|
919
|
+
* stamps from `parent_tool_use_id`, and never reach the top-level feed
|
|
920
|
+
* as the turn's answer. */
|
|
921
|
+
const sendUpdate = async (notification) => {
|
|
922
|
+
const { update } = notification;
|
|
923
|
+
if (update.sessionUpdate === "agent_message_chunk") {
|
|
924
|
+
const claudeMeta = update._meta?.claudeCode;
|
|
925
|
+
if (!claudeMeta?.parentToolUseId) {
|
|
926
|
+
session.emittedAssistantText = true;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
await this.client.sessionUpdate(notification);
|
|
930
|
+
};
|
|
686
931
|
const resetTurnScratch = () => {
|
|
687
932
|
lastAssistantTotalUsage = null;
|
|
688
933
|
lastAssistantUsage = null;
|
|
@@ -718,6 +963,31 @@ export class ClaudeAcpAgent {
|
|
|
718
963
|
session.activeTurn = turn;
|
|
719
964
|
session.cancelled = false;
|
|
720
965
|
session.pendingOrphanResults = 0;
|
|
966
|
+
session.orphanCommands?.clear();
|
|
967
|
+
// Two-phase sweep of registry entries the level signal ended (see
|
|
968
|
+
// the endedPerLevel field doc): armed at the first activation,
|
|
969
|
+
// deleted at the second — the same activation-time self-heal as the
|
|
970
|
+
// orphan lanes, and the growth bound for leaked entries whose settle
|
|
971
|
+
// bookends never arrive. The one-activation grace lets a corrective
|
|
972
|
+
// inclusive level rescue a live async agent that a racing payload
|
|
973
|
+
// absent-marked (deletion is irreversible: levels never ADD entries).
|
|
974
|
+
// Local-only commands don't advance the clock: two quick /context
|
|
975
|
+
// calls would otherwise burn the whole grace in seconds of wall time
|
|
976
|
+
// while the corrective level is still in flight, and they interact
|
|
977
|
+
// with no tasks — a later real turn still bounds growth.
|
|
978
|
+
if (!turn.isLocalOnlyCommand) {
|
|
979
|
+
for (const [taskId, record] of session.liveBackgroundTasks) {
|
|
980
|
+
if (!record.endedPerLevel) {
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (record.endedPerLevel === "sweep-armed") {
|
|
984
|
+
session.liveBackgroundTasks.delete(taskId);
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
record.endedPerLevel = "sweep-armed";
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
721
991
|
resetTurnScratch();
|
|
722
992
|
};
|
|
723
993
|
/** Ensure there is an active turn before a user-turn result that carries no
|
|
@@ -737,18 +1007,165 @@ export class ClaudeAcpAgent {
|
|
|
737
1007
|
* result), so we skip those and only promote once the count is drained. */
|
|
738
1008
|
const ensureActiveTurn = () => {
|
|
739
1009
|
if (session.activeTurn) {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1010
|
+
if (!isHeldOpen(session.activeTurn)) {
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
// A held turn (Turn.deferredSettle) already produced its result, so
|
|
1014
|
+
// this incoming user-turn result cannot be its — it belongs to the
|
|
1015
|
+
// next queued command (an echo-less one, e.g. `/context` sent while
|
|
1016
|
+
// the hold drains; a normal prompt's echo would have handed the held
|
|
1017
|
+
// turn off before its result). Settle the held turn with its
|
|
1018
|
+
// recorded outcome — the user moving on outranks the hold, same
|
|
1019
|
+
// contract as the echo hand-off — and fall through to promote the
|
|
1020
|
+
// queue head, which this result belongs to. Without this, the head
|
|
1021
|
+
// would never be promoted (echo-less turns have no other promotion
|
|
1022
|
+
// path) and its prompt would hang, while this result's outcome
|
|
1023
|
+
// overwrote the held turn's. Orphan lanes below are necessarily
|
|
1024
|
+
// empty while a turn is held: orphans are seeded by cancel(), which
|
|
1025
|
+
// inline-settles a held turn, and activation cleared older ones.
|
|
1026
|
+
// settleActive also closes the held turn's delivery stretch, so the
|
|
1027
|
+
// promoted command's own delivery decision is not judged against the
|
|
1028
|
+
// held turn's followup text (issue #453) — the caller snapshots the
|
|
1029
|
+
// flag AFTER this runs.
|
|
1030
|
+
settleActive(session.activeTurn.deferredSettle);
|
|
745
1031
|
}
|
|
1032
|
+
// Orphan accounting runs BEFORE the head check: an orphan's echo-less
|
|
1033
|
+
// result can arrive with an EMPTY queue (the common post-cancel
|
|
1034
|
+
// timeline — the active turn settled at the interrupt's idle and the
|
|
1035
|
+
// user hasn't typed yet), and it must still be consumed here. Skipping
|
|
1036
|
+
// the bookkeeping when there is nothing to promote would leave a
|
|
1037
|
+
// phantom entry/count that swallows the next live echo-less result
|
|
1038
|
+
// (e.g. /compact) instead.
|
|
746
1039
|
if ((session.pendingOrphanResults ?? 0) > 0) {
|
|
747
1040
|
session.pendingOrphanResults--;
|
|
748
1041
|
return;
|
|
749
1042
|
}
|
|
1043
|
+
// msg_lifecycle_v1 lane. Attribute this echo-less result using the
|
|
1044
|
+
// entries' states — turns run sequentially and frames arrive in stream
|
|
1045
|
+
// order, so at any result: every "zombie" is from an already-dead turn
|
|
1046
|
+
// whose own result already passed before the frame that created the
|
|
1047
|
+
// newest entry (or never existed), every "started" entry was dispatched
|
|
1048
|
+
// into THE turn that emitted this result (an older turn's entries got
|
|
1049
|
+
// their terminal frames before a newer turn's "started" frames), and a
|
|
1050
|
+
// "pending" entry was not dispatched before it. One result therefore
|
|
1051
|
+
// covers ALL started and zombie entries at once (N coalesced commands
|
|
1052
|
+
// share ONE result); their outstanding terminal frames then no-op on
|
|
1053
|
+
// the missing entries. NOTE this ordering argument is asserted from
|
|
1054
|
+
// observed CLI behavior, not a documented wire contract — if a dead
|
|
1055
|
+
// turn's late result could lag past the NEXT turn's dispatch frames,
|
|
1056
|
+
// deleting a zombie and a started entry on one result would
|
|
1057
|
+
// double-consume it. The unexpected-transition logging in the frame
|
|
1058
|
+
// handler is the tripwire for that class of drift.
|
|
1059
|
+
if (session.orphanCommands?.size) {
|
|
1060
|
+
let consumedOrphanResult = false;
|
|
1061
|
+
let oldestPending;
|
|
1062
|
+
for (const [uuid, state] of session.orphanCommands) {
|
|
1063
|
+
if (state === "started" || state === "zombie") {
|
|
1064
|
+
consumedOrphanResult = true;
|
|
1065
|
+
session.orphanCommands.delete(uuid);
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
oldestPending ??= uuid;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
if (consumedOrphanResult) {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (oldestPending !== undefined) {
|
|
1075
|
+
// No dispatch was seen before this result, so it is very likely a
|
|
1076
|
+
// live turn's — but a lost "started" frame would mean it IS the
|
|
1077
|
+
// orphan's (dup-over-loss: prefer one wrong skip over
|
|
1078
|
+
// misattributing a dead turn's outcome to a live prompt). Grant
|
|
1079
|
+
// each pending entry exactly one skip, like the count lane did.
|
|
1080
|
+
session.orphanCommands.delete(oldestPending);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const head = firstUnsettledQueuedTurn();
|
|
1085
|
+
if (!head) {
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
750
1088
|
activateTurn(head);
|
|
751
1089
|
};
|
|
1090
|
+
/** Result-time bookkeeping that must run whether or not the result can be
|
|
1091
|
+
* attributed to a turn. (1) Latch `commandResultSeen` on every queued
|
|
1092
|
+
* turn whose command is known dispatched with no terminal frame yet —
|
|
1093
|
+
* the emitting turn is the one it was dispatched (possibly folded) into,
|
|
1094
|
+
* so its result has now passed; a later cancel() must not seed an orphan
|
|
1095
|
+
* entry that waits for it (see Turn.commandResultSeen). (2) When a turn
|
|
1096
|
+
* is ACTIVE, the result is attributed to it and never reaches
|
|
1097
|
+
* ensureActiveTurn — but it still covers the map's started entries
|
|
1098
|
+
* (commands folded into the active turn share its result) and zombies
|
|
1099
|
+
* (their late results have already passed or never existed), so drain
|
|
1100
|
+
* them here or they would zombify/linger and swallow a later live
|
|
1101
|
+
* echo-less result. */
|
|
1102
|
+
const recordResultForOrphanCommands = () => {
|
|
1103
|
+
for (const turn of session.turnQueue ?? []) {
|
|
1104
|
+
if (!turn.settled && turn.commandStarted && !turn.commandFinished) {
|
|
1105
|
+
turn.commandResultSeen = true;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
if (session.activeTurn && session.orphanCommands?.size) {
|
|
1109
|
+
for (const [uuid, state] of session.orphanCommands) {
|
|
1110
|
+
if (state === "started" || state === "zombie") {
|
|
1111
|
+
session.orphanCommands.delete(uuid);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
/** The unsettled in-flight turn owning this prompt uuid, if any. */
|
|
1117
|
+
const findUnsettledTurn = (uuid) => (session.turnQueue ?? []).find((t) => t.promptUuid === uuid && !t.settled);
|
|
1118
|
+
/** The first queued turn still awaiting its outcome, if any — the single
|
|
1119
|
+
* spelling of "a prompt is pending" shared by the head promotion and
|
|
1120
|
+
* the autonomous stretch-close guard. */
|
|
1121
|
+
const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
|
|
1122
|
+
/** Whether any background subagent this turn spawned is still live —
|
|
1123
|
+
* while true, the turn's settlement stays deferred so the subagent's
|
|
1124
|
+
* output and permission requests land inside it (see
|
|
1125
|
+
* Turn.deferredSettle). */
|
|
1126
|
+
const turnAwaitingSubagents = (turn) => {
|
|
1127
|
+
if (!turn.spawnedTaskIds?.size) {
|
|
1128
|
+
return false;
|
|
1129
|
+
}
|
|
1130
|
+
for (const taskId of turn.spawnedTaskIds) {
|
|
1131
|
+
const record = session.liveBackgroundTasks.get(taskId);
|
|
1132
|
+
// The isSubagent read is defense in depth for the shells-never-defer
|
|
1133
|
+
// contract: spawnedTaskIds only ever holds subagent ids today, but a
|
|
1134
|
+
// future add site must not silently let a long-lived shell hold a
|
|
1135
|
+
// prompt open. endedPerLevel entries are kept for attribution only —
|
|
1136
|
+
// the level signal says the task is gone (or its bookends were
|
|
1137
|
+
// lost), so a hold must not wait on them.
|
|
1138
|
+
if (record?.isSubagent && !record.endedPerLevel) {
|
|
1139
|
+
return true;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return false;
|
|
1143
|
+
};
|
|
1144
|
+
/** Settle the active turn's stored deferred outcome once none of its
|
|
1145
|
+
* spawned subagents is live. The single drain rule shared by the
|
|
1146
|
+
* followup-result and idle settle sites, so the two lanes can't drift. */
|
|
1147
|
+
const settleDeferredIfDrained = () => {
|
|
1148
|
+
const turn = session.activeTurn;
|
|
1149
|
+
if (isHeldOpen(turn) && !turnAwaitingSubagents(turn)) {
|
|
1150
|
+
settleActive(turn.deferredSettle);
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
/** Settle the active turn with `outcome` now — unless subagents it
|
|
1154
|
+
* spawned are still live, in which case store the outcome and hold the
|
|
1155
|
+
* turn open (see Turn.deferredSettle). Every result-time settle of a
|
|
1156
|
+
* turn that can have spawned subagents must route through here: a site
|
|
1157
|
+
* calling settleActive directly bypasses the hold and re-opens the
|
|
1158
|
+
* out-of-turn permission deadlock (issue #866) through its lane. */
|
|
1159
|
+
const settleOrDefer = (outcome) => {
|
|
1160
|
+
if (session.activeTurn &&
|
|
1161
|
+
!session.activeTurn.settled &&
|
|
1162
|
+
turnAwaitingSubagents(session.activeTurn)) {
|
|
1163
|
+
session.activeTurn.deferredSettle = outcome;
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
settleActive(outcome);
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
752
1169
|
/** Settle the active turn's deferred exactly once, disarm the force-cancel
|
|
753
1170
|
* backstop (the turn is over), and drop it from the queue. */
|
|
754
1171
|
const settleActive = (result) => {
|
|
@@ -756,22 +1173,34 @@ export class ClaudeAcpAgent {
|
|
|
756
1173
|
if (!turn || turn.settled) {
|
|
757
1174
|
return;
|
|
758
1175
|
}
|
|
1176
|
+
// Captured before the settled flip below (isHeldOpen tests !settled).
|
|
1177
|
+
const wasHeld = isHeldOpen(turn);
|
|
759
1178
|
turn.settled = true;
|
|
760
|
-
|
|
761
|
-
clearTimeout(session.forceCancelTimer);
|
|
762
|
-
session.forceCancelTimer = undefined;
|
|
763
|
-
}
|
|
1179
|
+
disarmForceCancel(session);
|
|
764
1180
|
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
765
1181
|
session.activeTurn = null;
|
|
1182
|
+
streamedToolInputs.clear();
|
|
1183
|
+
if (wasHeld) {
|
|
1184
|
+
// Settling a held turn is its delivery-stretch boundary: the turn's
|
|
1185
|
+
// answer finished long ago, so text streamed since the last boundary
|
|
1186
|
+
// is normally its followups' — left latched it would suppress a
|
|
1187
|
+
// following replayed turn's issue-#453 result-text fallback (the
|
|
1188
|
+
// common post-hold sequence). Known trade: at the echo hand-off an
|
|
1189
|
+
// incoming turn's pre-echo deltas share this one boolean, so a
|
|
1190
|
+
// STREAMING replay on a usage-omitting backend could re-emit its
|
|
1191
|
+
// answer — the flag cannot attribute text to a turn before its
|
|
1192
|
+
// echo, and the suppression direction is the common one, so the
|
|
1193
|
+
// clear wins. Every held-settle lane inherits this: the drain
|
|
1194
|
+
// settle, both hand-offs, and stream-done; cancel()'s inline mirror
|
|
1195
|
+
// carries its own copy.
|
|
1196
|
+
session.emittedAssistantText = false;
|
|
1197
|
+
}
|
|
766
1198
|
turn.resolve(result);
|
|
767
1199
|
};
|
|
768
1200
|
/** Reject the active turn (auth required, error result, …) without tearing
|
|
769
1201
|
* down the consumer: the stream continues to idle and later turns proceed. */
|
|
770
1202
|
const failActive = (error) => {
|
|
771
|
-
|
|
772
|
-
clearTimeout(session.forceCancelTimer);
|
|
773
|
-
session.forceCancelTimer = undefined;
|
|
774
|
-
}
|
|
1203
|
+
disarmForceCancel(session);
|
|
775
1204
|
const turn = session.activeTurn;
|
|
776
1205
|
if (!turn || turn.settled) {
|
|
777
1206
|
return;
|
|
@@ -779,14 +1208,17 @@ export class ClaudeAcpAgent {
|
|
|
779
1208
|
turn.settled = true;
|
|
780
1209
|
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
781
1210
|
session.activeTurn = null;
|
|
1211
|
+
streamedToolInputs.clear();
|
|
1212
|
+
// A failed turn's stretch is over, and some failure lanes (the issue
|
|
1213
|
+
// #825 idle-fail) never see the result whose `finally` would close it —
|
|
1214
|
+
// start the next stretch clean, or its stale delivery record would
|
|
1215
|
+
// suppress the next turn's issue-#453 result-text fallback.
|
|
1216
|
+
session.emittedAssistantText = false;
|
|
782
1217
|
turn.reject(error);
|
|
783
1218
|
};
|
|
784
1219
|
/** Reject every in-flight turn — used when the stream dies. */
|
|
785
1220
|
const failAllTurns = (error) => {
|
|
786
|
-
|
|
787
|
-
clearTimeout(session.forceCancelTimer);
|
|
788
|
-
session.forceCancelTimer = undefined;
|
|
789
|
-
}
|
|
1221
|
+
disarmForceCancel(session);
|
|
790
1222
|
const turns = session.activeTurn
|
|
791
1223
|
? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
|
|
792
1224
|
: [...(session.turnQueue ?? [])];
|
|
@@ -794,8 +1226,18 @@ export class ClaudeAcpAgent {
|
|
|
794
1226
|
session.turnQueue = [];
|
|
795
1227
|
for (const turn of turns) {
|
|
796
1228
|
if (!turn.settled) {
|
|
1229
|
+
const wasHeld = isHeldOpen(turn);
|
|
797
1230
|
turn.settled = true;
|
|
798
|
-
|
|
1231
|
+
if (wasHeld) {
|
|
1232
|
+
// A held turn's answer already streamed and its outcome is
|
|
1233
|
+
// recorded — a stream death during the post-answer hold is a
|
|
1234
|
+
// background failure, not the turn's. Resolve with the real
|
|
1235
|
+
// outcome, mirroring the stream-done path.
|
|
1236
|
+
turn.resolve(turn.deferredSettle);
|
|
1237
|
+
}
|
|
1238
|
+
else {
|
|
1239
|
+
turn.reject(error);
|
|
1240
|
+
}
|
|
799
1241
|
}
|
|
800
1242
|
}
|
|
801
1243
|
};
|
|
@@ -836,9 +1278,45 @@ export class ClaudeAcpAgent {
|
|
|
836
1278
|
// turn being abandoned. Stale counts self-heal: activation resets
|
|
837
1279
|
// them (see activateTurn).
|
|
838
1280
|
if (session.activeTurn && !session.activeTurn.settled) {
|
|
839
|
-
|
|
1281
|
+
// Seed by what the frames already told us, mirroring cancel()'s
|
|
1282
|
+
// queued-turn sweep — the consumer may have drained the wedged
|
|
1283
|
+
// turn's result and/or terminal frame before the backstop fired,
|
|
1284
|
+
// and an entry seeded for a result or frame that is already
|
|
1285
|
+
// spent would never drain (it would swallow an unrelated later
|
|
1286
|
+
// echo-less result instead).
|
|
1287
|
+
const active = session.activeTurn;
|
|
1288
|
+
if (active.commandFinished === "completed" || active.commandFinished === "discarded") {
|
|
1289
|
+
// Finished SDK-side; any result already passed. Nothing to
|
|
1290
|
+
// track.
|
|
1291
|
+
}
|
|
1292
|
+
else if (active.commandFinished === "cancelled") {
|
|
1293
|
+
// Aborted after dispatch: its late result may still come —
|
|
1294
|
+
// unless it already did.
|
|
1295
|
+
if (!active.commandResultSeen) {
|
|
1296
|
+
this.trackOrphanCommand(session, active.promptUuid, "zombie");
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
else if (active.commandResultSeen) {
|
|
1300
|
+
// Its result was already consumed (dropped at the cancelled
|
|
1301
|
+
// guard); only the terminal frame is outstanding, which no-ops
|
|
1302
|
+
// with no entry. Nothing to track.
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
// The wedged turn WAS dispatched (it's active), so track it
|
|
1306
|
+
// "started": its late result (if the SDK recovers) is skipped
|
|
1307
|
+
// echo-less, and its terminal frame — or that skip plus
|
|
1308
|
+
// activation's clear when the frame is lost to the wedge — is
|
|
1309
|
+
// what drains it.
|
|
1310
|
+
this.trackOrphanCommand(session, active.promptUuid, "started");
|
|
1311
|
+
}
|
|
840
1312
|
}
|
|
841
1313
|
settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
|
|
1314
|
+
// The cancelled turn's result may never come (that's why the
|
|
1315
|
+
// backstop fired) — close its delivery stretch here so partial
|
|
1316
|
+
// streamed text can't suppress the next turn's issue-#453 fallback.
|
|
1317
|
+
// If a late orphan result does arrive, its `finally` clears again;
|
|
1318
|
+
// FIFO ordering means no live turn's text can have streamed yet.
|
|
1319
|
+
session.emittedAssistantText = false;
|
|
842
1320
|
// If the session is being torn down, abandon the in-flight next()
|
|
843
1321
|
// (swallowing any later rejection so it can't surface as unhandled)
|
|
844
1322
|
// and stop; otherwise re-arm and keep consuming — `pendingNext`
|
|
@@ -862,11 +1340,15 @@ export class ClaudeAcpAgent {
|
|
|
862
1340
|
// turn's real outcome.
|
|
863
1341
|
//
|
|
864
1342
|
// Settle the turn that was in flight so its prompt() doesn't hang:
|
|
865
|
-
// cancelled if a cancel is pending, otherwise the
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1343
|
+
// cancelled if a cancel is pending, otherwise the outcome a
|
|
1344
|
+
// deferred turn already recorded (see Turn.deferredSettle) or the
|
|
1345
|
+
// accumulated scratch outcome. The scratch currently still equals
|
|
1346
|
+
// a deferred turn's stored outcome (followup results never mutate
|
|
1347
|
+
// it), but the stored one is the authoritative source.
|
|
1348
|
+
const inFlight = session.activeTurn;
|
|
1349
|
+
settleActive(session.cancelled
|
|
1350
|
+
? { stopReason: "cancelled", usage: sessionUsage(session) }
|
|
1351
|
+
: (inFlight?.deferredSettle ?? { stopReason, usage: sessionUsage(session) }));
|
|
870
1352
|
// Queued turns the SDK never started never ran, so reject them rather
|
|
871
1353
|
// than reporting a success (end_turn) — or a misleading "cancelled" —
|
|
872
1354
|
// for a prompt that produced no output. (A cancel already settled the
|
|
@@ -893,19 +1375,116 @@ export class ClaudeAcpAgent {
|
|
|
893
1375
|
message: message,
|
|
894
1376
|
});
|
|
895
1377
|
}
|
|
1378
|
+
// CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
|
|
1379
|
+
// uuid-stamped queued command (queued/started/completed/cancelled/
|
|
1380
|
+
// discarded) as `command_lifecycle` frames — 2-3 per prompt, since
|
|
1381
|
+
// prompt() stamps a uuid on every message. The frame is @internal and
|
|
1382
|
+
// absent from the SDKMessage union, so handle it BEFORE the exhaustive
|
|
1383
|
+
// switch: it must not reach `unreachable`'s error log, and a `case`
|
|
1384
|
+
// for it wouldn't typecheck. It feeds only the orphan accounting (see
|
|
1385
|
+
// Session.orphanCommands); turn settlement stays driven by
|
|
1386
|
+
// echoes/results/idle. (Raw-mode emission above still forwards these
|
|
1387
|
+
// frames.)
|
|
1388
|
+
if (message.type === "command_lifecycle") {
|
|
1389
|
+
const frame = message;
|
|
1390
|
+
switch (frame.state) {
|
|
1391
|
+
case "started": {
|
|
1392
|
+
// Remember dispatch on the live turn so a cancel() that orphans
|
|
1393
|
+
// it seeds the right state (see Turn.commandStarted)...
|
|
1394
|
+
const queued = findUnsettledTurn(frame.command_uuid);
|
|
1395
|
+
if (queued) {
|
|
1396
|
+
queued.commandStarted = true;
|
|
1397
|
+
}
|
|
1398
|
+
// ...and promote an already-orphaned command: once dispatched,
|
|
1399
|
+
// a bare `cancelled` no longer means "dropped without running".
|
|
1400
|
+
const state = session.orphanCommands?.get(frame.command_uuid);
|
|
1401
|
+
if (state === "pending") {
|
|
1402
|
+
session.orphanCommands.set(frame.command_uuid, "started");
|
|
1403
|
+
}
|
|
1404
|
+
else if (state === "zombie") {
|
|
1405
|
+
// "started" after the command's terminal frame: the ordering
|
|
1406
|
+
// the whole lane rests on has been violated (frames are
|
|
1407
|
+
// per-uuid FIFO). Surface it — a silent drift here degrades
|
|
1408
|
+
// into swallowed or misattributed results.
|
|
1409
|
+
this.logger.error(`Session ${params.sessionId}: command_lifecycle "started" for ${frame.command_uuid} after its terminal frame; orphan accounting may be off for this cancel.`);
|
|
1410
|
+
}
|
|
1411
|
+
break;
|
|
1412
|
+
}
|
|
1413
|
+
case "completed":
|
|
1414
|
+
case "discarded":
|
|
1415
|
+
case "cancelled": {
|
|
1416
|
+
// Terminal frames. Latch the fate on a still-queued turn so a
|
|
1417
|
+
// later cancel() doesn't seed an orphan entry for a command
|
|
1418
|
+
// whose one-and-only terminal frame has already been consumed
|
|
1419
|
+
// (nothing would ever drain that entry).
|
|
1420
|
+
const queued = findUnsettledTurn(frame.command_uuid);
|
|
1421
|
+
if (queued) {
|
|
1422
|
+
queued.commandFinished = frame.state;
|
|
1423
|
+
}
|
|
1424
|
+
if (frame.state === "cancelled") {
|
|
1425
|
+
// Ambiguous by design (dup-over-loss): dropped before
|
|
1426
|
+
// dispatch (no result will ever come — safe to forget) vs
|
|
1427
|
+
// consumed into a turn that was aborted/failed. For the
|
|
1428
|
+
// latter, any result the dead turn managed to emit has
|
|
1429
|
+
// already deleted the entry (see
|
|
1430
|
+
// recordResultForOrphanCommands / ensureActiveTurn), so a
|
|
1431
|
+
// still-"started" entry means no result was seen since
|
|
1432
|
+
// dispatch — it becomes a zombie for the next
|
|
1433
|
+
// echo-less-result skip.
|
|
1434
|
+
const state = session.orphanCommands?.get(frame.command_uuid);
|
|
1435
|
+
if (state === "pending") {
|
|
1436
|
+
session.orphanCommands?.delete(frame.command_uuid);
|
|
1437
|
+
}
|
|
1438
|
+
else if (state === "started") {
|
|
1439
|
+
session.orphanCommands?.set(frame.command_uuid, "zombie");
|
|
1440
|
+
}
|
|
1441
|
+
break;
|
|
1442
|
+
}
|
|
1443
|
+
// Exactly-one-terminal: the command is finished. "completed" is
|
|
1444
|
+
// emitted after any result its turn produced (fresh turn) or the
|
|
1445
|
+
// command folded into another turn whose result is attributed
|
|
1446
|
+
// elsewhere — either way no echo-less result remains to skip.
|
|
1447
|
+
// "discarded" = session ended with it still queued; no result.
|
|
1448
|
+
session.orphanCommands?.delete(frame.command_uuid);
|
|
1449
|
+
break;
|
|
1450
|
+
}
|
|
1451
|
+
default:
|
|
1452
|
+
// "queued" carries no fate information. Anything else is a
|
|
1453
|
+
// state this adapter doesn't know — likely a CLI that grew the
|
|
1454
|
+
// v1 vocabulary. The entry still drains by result coverage or
|
|
1455
|
+
// activation's clear (bounded damage), but log it so the
|
|
1456
|
+
// degradation is visible instead of silent.
|
|
1457
|
+
if (frame.state !== "queued") {
|
|
1458
|
+
this.logger.error(`Session ${params.sessionId}: unknown command_lifecycle state "${frame.state}" for ${frame.command_uuid}; treating as uninformative.`);
|
|
1459
|
+
}
|
|
1460
|
+
break;
|
|
1461
|
+
}
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
896
1464
|
switch (message.type) {
|
|
897
1465
|
case "system":
|
|
898
1466
|
switch (message.subtype) {
|
|
899
1467
|
case "init":
|
|
1468
|
+
// Latch the lifecycle capability so cancel() routes orphan
|
|
1469
|
+
// accounting through `orphanCommands` (per-uuid, exact)
|
|
1470
|
+
// instead of the coalescing-blind count. Never unlatch: init
|
|
1471
|
+
// re-emits per turn and the capability can't be lost mid-CLI.
|
|
1472
|
+
if (message.capabilities?.includes("msg_lifecycle_v1")) {
|
|
1473
|
+
session.msgLifecycleV1 = true;
|
|
1474
|
+
}
|
|
900
1475
|
// A fresh `system`/init (e.g. after reinitialize) can carry an
|
|
901
1476
|
// updated Fast mode state; reconcile it with what we seeded at
|
|
902
1477
|
// session creation.
|
|
903
1478
|
await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
|
|
904
1479
|
break;
|
|
905
1480
|
case "status": {
|
|
1481
|
+
// These banners count as delivered text (via sendUpdate), so
|
|
1482
|
+
// an echo-less turn that only ever emits them (e.g. `/compact`,
|
|
1483
|
+
// promoted at its own result) doesn't have its result text
|
|
1484
|
+
// re-emitted by the issue-#453 fallback.
|
|
906
1485
|
if (message.status === "compacting") {
|
|
907
1486
|
compactionInProgress = true;
|
|
908
|
-
await
|
|
1487
|
+
await sendUpdate({
|
|
909
1488
|
sessionId: message.session_id,
|
|
910
1489
|
update: {
|
|
911
1490
|
sessionUpdate: "agent_message_chunk",
|
|
@@ -918,7 +1497,7 @@ export class ClaudeAcpAgent {
|
|
|
918
1497
|
// message carrying `compact_result`, not the `compact_boundary`
|
|
919
1498
|
// message (which only fires when there's content to compact).
|
|
920
1499
|
compactionInProgress = false;
|
|
921
|
-
await
|
|
1500
|
+
await sendUpdate({
|
|
922
1501
|
sessionId: message.session_id,
|
|
923
1502
|
update: {
|
|
924
1503
|
sessionUpdate: "agent_message_chunk",
|
|
@@ -929,7 +1508,7 @@ export class ClaudeAcpAgent {
|
|
|
929
1508
|
else if (message.compact_result === "failed" && compactionInProgress) {
|
|
930
1509
|
compactionInProgress = false;
|
|
931
1510
|
const reason = message.compact_error ? `: ${message.compact_error}` : ".";
|
|
932
|
-
await
|
|
1511
|
+
await sendUpdate({
|
|
933
1512
|
sessionId: message.session_id,
|
|
934
1513
|
update: {
|
|
935
1514
|
sessionUpdate: "agent_message_chunk",
|
|
@@ -954,9 +1533,9 @@ export class ClaudeAcpAgent {
|
|
|
954
1533
|
// dropped dramatically) and replaced within seconds by the next
|
|
955
1534
|
// result message.
|
|
956
1535
|
//
|
|
957
|
-
// `size` keeps coming from session.contextWindowSize
|
|
958
|
-
//
|
|
959
|
-
// window
|
|
1536
|
+
// `size` keeps coming from session.contextWindowSize —
|
|
1537
|
+
// compaction frees occupancy, it doesn't change the model's
|
|
1538
|
+
// window.
|
|
960
1539
|
//
|
|
961
1540
|
// The "Compacting completed." text is emitted from the `status`
|
|
962
1541
|
// handler (keyed on `compact_result`), not here, so the failure
|
|
@@ -964,7 +1543,7 @@ export class ClaudeAcpAgent {
|
|
|
964
1543
|
const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
|
|
965
1544
|
lastAssistantUsage = null;
|
|
966
1545
|
lastAssistantTotalUsage = usedTokens ?? 0;
|
|
967
|
-
await
|
|
1546
|
+
await sendUpdate({
|
|
968
1547
|
sessionId: message.session_id,
|
|
969
1548
|
update: {
|
|
970
1549
|
sessionUpdate: "usage_update",
|
|
@@ -975,7 +1554,7 @@ export class ClaudeAcpAgent {
|
|
|
975
1554
|
break;
|
|
976
1555
|
}
|
|
977
1556
|
case "local_command_output": {
|
|
978
|
-
await
|
|
1557
|
+
await sendUpdate({
|
|
979
1558
|
sessionId: message.session_id,
|
|
980
1559
|
update: {
|
|
981
1560
|
sessionUpdate: "agent_message_chunk",
|
|
@@ -985,6 +1564,7 @@ export class ClaudeAcpAgent {
|
|
|
985
1564
|
break;
|
|
986
1565
|
}
|
|
987
1566
|
case "session_state_changed": {
|
|
1567
|
+
session.lastSessionState = message.state;
|
|
988
1568
|
if (message.state === "idle") {
|
|
989
1569
|
// A non-cancelled turn normally settled at its terminal
|
|
990
1570
|
// `result` already (issue #773), and that result recorded an
|
|
@@ -1017,14 +1597,38 @@ export class ClaudeAcpAgent {
|
|
|
1017
1597
|
// when the cancel pre-empted the result (wedge/force-cancel).
|
|
1018
1598
|
if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
|
|
1019
1599
|
settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
|
|
1600
|
+
// An interrupt can pre-empt the turn's result entirely
|
|
1601
|
+
// (nothing ran the result-case `finally`), so close the
|
|
1602
|
+
// delivery stretch here: idle is the SDK's authoritative
|
|
1603
|
+
// turn-over signal, and stale partial-text state would
|
|
1604
|
+
// suppress the next turn's issue-#453 fallback.
|
|
1605
|
+
session.emittedAssistantText = false;
|
|
1020
1606
|
}
|
|
1021
|
-
else if (
|
|
1607
|
+
else if (isHeldOpen(session.activeTurn)) {
|
|
1608
|
+
// A turn held open for its background subagents (see
|
|
1609
|
+
// Turn.deferredSettle). Idles keep their normal cadence
|
|
1610
|
+
// during the hold — the CLI emits one per processing
|
|
1611
|
+
// cycle (the turn's own trailer, then one per followup),
|
|
1612
|
+
// NOT one final "all drained" signal — so each one
|
|
1613
|
+
// absorbs an outstanding trailer debt, and the turn only
|
|
1614
|
+
// settles once none of its spawned subagents is left
|
|
1615
|
+
// (the followup-result settle usually got there first;
|
|
1616
|
+
// this is the fallback when no followup came). Mid-hold
|
|
1617
|
+
// idles never fall through: a held turn HAS its result,
|
|
1618
|
+
// so reading its idle as "turn abandoned without a
|
|
1619
|
+
// result" (issue #825) would fail a healthy prompt.
|
|
1620
|
+
if (session.owedTrailingIdles > 0) {
|
|
1621
|
+
session.owedTrailingIdles--;
|
|
1622
|
+
}
|
|
1623
|
+
settleDeferredIfDrained();
|
|
1624
|
+
}
|
|
1625
|
+
else if (session.owedTrailingIdles > 0) {
|
|
1022
1626
|
// Absorb a settled turn's trailing idle. Also covers a
|
|
1023
1627
|
// cancel that landed between a turn's counted result and
|
|
1024
1628
|
// this lagged idle (no active turn to settle): the idle
|
|
1025
1629
|
// still belongs to that settled turn, and skipping the
|
|
1026
1630
|
// decrement would leak the debt permanently.
|
|
1027
|
-
owedTrailingIdles--;
|
|
1631
|
+
session.owedTrailingIdles--;
|
|
1028
1632
|
}
|
|
1029
1633
|
else if (!session.cancelled &&
|
|
1030
1634
|
session.activeTurn &&
|
|
@@ -1066,7 +1670,7 @@ export class ClaudeAcpAgent {
|
|
|
1066
1670
|
const title = isSynthesis
|
|
1067
1671
|
? "Recalled synthesized memory"
|
|
1068
1672
|
: `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
|
|
1069
|
-
await
|
|
1673
|
+
await sendUpdate({
|
|
1070
1674
|
sessionId: message.session_id,
|
|
1071
1675
|
update: {
|
|
1072
1676
|
sessionUpdate: "tool_call",
|
|
@@ -1093,7 +1697,7 @@ export class ClaudeAcpAgent {
|
|
|
1093
1697
|
// list with this payload: supportedCommands() is captured once
|
|
1094
1698
|
// at initialize and never reflects mid-session changes, so we
|
|
1095
1699
|
// forward message.commands directly rather than re-querying.
|
|
1096
|
-
await
|
|
1700
|
+
await sendUpdate({
|
|
1097
1701
|
sessionId: message.session_id,
|
|
1098
1702
|
update: {
|
|
1099
1703
|
sessionUpdate: "available_commands_update",
|
|
@@ -1117,7 +1721,7 @@ export class ClaudeAcpAgent {
|
|
|
1117
1721
|
// rejection reason — otherwise the client shows a tool call
|
|
1118
1722
|
// that silently never resolves.
|
|
1119
1723
|
const reason = message.decision_reason ?? message.message;
|
|
1120
|
-
await
|
|
1724
|
+
await sendUpdate({
|
|
1121
1725
|
sessionId: message.session_id,
|
|
1122
1726
|
update: {
|
|
1123
1727
|
sessionUpdate: "tool_call_update",
|
|
@@ -1149,10 +1753,14 @@ export class ClaudeAcpAgent {
|
|
|
1149
1753
|
// instead of a silent stop. ACP's agent_message_chunk has no
|
|
1150
1754
|
// severity field, so fold the level into the text for the more
|
|
1151
1755
|
// prominent levels ('info' is transcript-only noise — leave plain).
|
|
1756
|
+
// Sending via sendUpdate also marks the notice as this stretch's
|
|
1757
|
+
// delivered text: a hook-blocked turn's result repeats the block
|
|
1758
|
+
// reason with zero output tokens, and the issue-#453 fallback
|
|
1759
|
+
// must not emit it a second time.
|
|
1152
1760
|
const text = message.level === "info"
|
|
1153
1761
|
? message.content
|
|
1154
1762
|
: `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
|
|
1155
|
-
await
|
|
1763
|
+
await sendUpdate({
|
|
1156
1764
|
sessionId: message.session_id,
|
|
1157
1765
|
update: {
|
|
1158
1766
|
sessionUpdate: "agent_message_chunk",
|
|
@@ -1165,10 +1773,51 @@ export class ClaudeAcpAgent {
|
|
|
1165
1773
|
case "hook_progress":
|
|
1166
1774
|
case "hook_response":
|
|
1167
1775
|
case "files_persisted":
|
|
1776
|
+
case "task_progress":
|
|
1777
|
+
break;
|
|
1168
1778
|
case "task_started":
|
|
1779
|
+
// For subagent tasks `task_id` is the subagent's agent id (the
|
|
1780
|
+
// SDK keys its task registry by agent id) and `tool_use_id` is
|
|
1781
|
+
// the Agent/Task tool_use that spawned it — recorded so the
|
|
1782
|
+
// subagent's permission requests, which reach canUseTool with
|
|
1783
|
+
// only `agentID`, can attribute their eagerly-emitted
|
|
1784
|
+
// tool_call to the parent tool call. Non-subagent tasks (e.g.
|
|
1785
|
+
// background Bash) land here too; their task_ids never match
|
|
1786
|
+
// an agentID, so those entries are inert for attribution.
|
|
1787
|
+
//
|
|
1788
|
+
// `isSubagent` marks Task/Agent-tool subagents — the tasks
|
|
1789
|
+
// whose completion wakes the model for a followup, so the
|
|
1790
|
+
// ones worth deferring turn settlement for. A sync subagent
|
|
1791
|
+
// is pruned (terminal task_updated) before its turn's result
|
|
1792
|
+
// can arrive, so registry membership at result time means an
|
|
1793
|
+
// async subagent. Their spawn is also recorded on the active
|
|
1794
|
+
// turn: a turn only ever waits on its own subagents, and a
|
|
1795
|
+
// spawn during a held-open drain window (an agent chain)
|
|
1796
|
+
// extends that turn's hold.
|
|
1797
|
+
session.liveBackgroundTasks.set(message.task_id, {
|
|
1798
|
+
parentToolUseId: message.tool_use_id,
|
|
1799
|
+
isSubagent: !!message.subagent_type,
|
|
1800
|
+
});
|
|
1801
|
+
if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
|
|
1802
|
+
(session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
|
|
1803
|
+
}
|
|
1804
|
+
break;
|
|
1169
1805
|
case "task_notification":
|
|
1170
|
-
|
|
1806
|
+
// The task settled — no further tool calls can originate
|
|
1807
|
+
// from it, so its registry entry can be dropped.
|
|
1808
|
+
session.liveBackgroundTasks.delete(message.task_id);
|
|
1809
|
+
break;
|
|
1171
1810
|
case "task_updated":
|
|
1811
|
+
// terminal-status task_updated patch and a (deduplicated)
|
|
1812
|
+
// task_notification when a task settles, but only the patch is
|
|
1813
|
+
// guaranteed per transition — prune on it too so the registry
|
|
1814
|
+
// can't grow for the session's lifetime if a notification is
|
|
1815
|
+
// skipped.
|
|
1816
|
+
if (message.patch.status === "completed" ||
|
|
1817
|
+
message.patch.status === "failed" ||
|
|
1818
|
+
message.patch.status === "killed") {
|
|
1819
|
+
session.liveBackgroundTasks.delete(message.task_id);
|
|
1820
|
+
}
|
|
1172
1821
|
break;
|
|
1173
1822
|
case "worker_shutting_down":
|
|
1174
1823
|
// A Remote Control worker announced a graceful teardown. This is a
|
|
@@ -1220,7 +1869,7 @@ export class ClaudeAcpAgent {
|
|
|
1220
1869
|
const outcome = persistent
|
|
1221
1870
|
? `The session will continue on ${message.fallback_model}.`
|
|
1222
1871
|
: `The session stays on ${message.original_model}.`;
|
|
1223
|
-
await
|
|
1872
|
+
await sendUpdate({
|
|
1224
1873
|
sessionId: message.session_id,
|
|
1225
1874
|
update: {
|
|
1226
1875
|
sessionUpdate: "agent_message_chunk",
|
|
@@ -1254,183 +1903,334 @@ export class ClaudeAcpAgent {
|
|
|
1254
1903
|
lastRefusalExplanation = message.api_refusal_explanation ?? message.content;
|
|
1255
1904
|
}
|
|
1256
1905
|
break;
|
|
1906
|
+
// `control_request_progress` only reports on side_question
|
|
1907
|
+
// control requests, which this adapter never issues.
|
|
1908
|
+
case "control_request_progress":
|
|
1909
|
+
break;
|
|
1910
|
+
case "background_tasks_changed":
|
|
1911
|
+
// A level signal: the full live background-task set on every
|
|
1912
|
+
// membership change, with REPLACE semantics. Used only to
|
|
1913
|
+
// reconcile `liveBackgroundTasks` — dropping (or, for
|
|
1914
|
+
// subagent entries, unpinning) any entry whose settle
|
|
1915
|
+
// bookend (task_notification / terminal task_updated) was
|
|
1916
|
+
// lost, so a leaked subagent entry can't defer its spawning
|
|
1917
|
+
// turn's settlement forever. Growth of retained
|
|
1918
|
+
// (endedPerLevel) subagent entries is bounded by the
|
|
1919
|
+
// activation-time sweep in activateTurn, not here. It never
|
|
1920
|
+
// ADDS entries (the payload carries no attribution or
|
|
1921
|
+
// subagent marker), so the unspecified ordering vs. the edge
|
|
1922
|
+
// bookends is safe: a level that precedes its task_started
|
|
1923
|
+
// simply no-ops here.
|
|
1924
|
+
if (session.liveBackgroundTasks.size > 0) {
|
|
1925
|
+
const live = new Set(message.tasks.map((t) => t.task_id));
|
|
1926
|
+
for (const [taskId, record] of session.liveBackgroundTasks) {
|
|
1927
|
+
if (live.has(taskId)) {
|
|
1928
|
+
// The level proves the task live in the background
|
|
1929
|
+
// universe (e.g. a foreground agent was backgrounded
|
|
1930
|
+
// after an earlier absent-marking, or that marking was
|
|
1931
|
+
// a racing payload built before the task registered) —
|
|
1932
|
+
// un-end it so a hold waits on it again, and disarm
|
|
1933
|
+
// the activation sweep.
|
|
1934
|
+
record.endedPerLevel = undefined;
|
|
1935
|
+
continue;
|
|
1936
|
+
}
|
|
1937
|
+
if (record.isSubagent) {
|
|
1938
|
+
// The level's universe is BACKGROUND tasks only, so a
|
|
1939
|
+
// live sync (foreground) subagent is legitimately
|
|
1940
|
+
// absent — deleting its entry would strand its
|
|
1941
|
+
// permission attribution (#859). Keep the entry but
|
|
1942
|
+
// stop any hold from waiting on the id: an absent id
|
|
1943
|
+
// can equally be a leaked async entry whose settle
|
|
1944
|
+
// bookends were lost.
|
|
1945
|
+
record.endedPerLevel ??= "ended";
|
|
1946
|
+
}
|
|
1947
|
+
else {
|
|
1948
|
+
session.liveBackgroundTasks.delete(taskId);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
break;
|
|
1257
1953
|
default:
|
|
1258
1954
|
unreachable(message, this.logger);
|
|
1259
1955
|
break;
|
|
1260
1956
|
}
|
|
1261
1957
|
break;
|
|
1262
1958
|
case "result": {
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
//
|
|
1267
|
-
|
|
1268
|
-
//
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
// no user-message echo to promote them, so do it here from the head.
|
|
1278
|
-
// Promote BEFORE accumulating usage, since activation resets the
|
|
1279
|
-
// accumulator — promoting after would discard this result's tokens.
|
|
1280
|
-
if (!isTaskNotification) {
|
|
1281
|
-
ensureActiveTurn();
|
|
1282
|
-
}
|
|
1283
|
-
// Every user-turn result terminates a turn (settle, reject, or
|
|
1284
|
-
// orphan skip) and the SDK follows it with a trailing
|
|
1285
|
-
// `session_state_changed: idle` — record the debt so the idle
|
|
1286
|
-
// handler absorbs that idle rather than reading it as a turn the
|
|
1287
|
-
// SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
|
|
1288
|
-
// turn's own result. It is dropped at the `session.cancelled`
|
|
1289
|
-
// guard, and either the idle itself settles the turn (consuming
|
|
1290
|
-
// the trailer) or the next echo's hand-off does (which records
|
|
1291
|
-
// the debt there instead) — counting here too would double it.
|
|
1292
|
-
// Results skipped while cancelled with NO active turn — orphaned
|
|
1293
|
-
// queued turns the SDK still ran, or a force-cancelled turn's
|
|
1294
|
-
// late result after the backstop settled it — get no such settle,
|
|
1295
|
-
// so their trailers must be counted here or they'd later be read
|
|
1296
|
-
// as the next healthy turn being abandoned and false-fail it.
|
|
1297
|
-
if (!isTaskNotification && (!session.cancelled || !session.activeTurn)) {
|
|
1298
|
-
owedTrailingIdles++;
|
|
1299
|
-
}
|
|
1300
|
-
// Accumulate usage into the user turn's tally. Skip task-notification
|
|
1301
|
-
// followups: their cost is real but is reported separately via the
|
|
1302
|
-
// usage_update below, and `session.accumulatedUsage` is only reset on
|
|
1303
|
-
// turn activation — so folding a task-notification result that lands
|
|
1304
|
-
// after the next turn is active (but before it settles) would leak
|
|
1305
|
-
// those tokens into that turn's PromptResponse.usage.
|
|
1306
|
-
if (!isTaskNotification) {
|
|
1307
|
-
session.accumulatedUsage.inputTokens += message.usage.input_tokens;
|
|
1308
|
-
session.accumulatedUsage.outputTokens += message.usage.output_tokens;
|
|
1309
|
-
session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
|
|
1310
|
-
session.accumulatedUsage.cachedWriteTokens +=
|
|
1311
|
-
message.usage.cache_creation_input_tokens;
|
|
1312
|
-
}
|
|
1313
|
-
const matchingModelUsage = lastAssistantModel
|
|
1314
|
-
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
|
1315
|
-
: null;
|
|
1316
|
-
// Only overwrite when we have an authoritative value — a miss
|
|
1317
|
-
// (e.g. a turn with no top-level assistant message) would
|
|
1318
|
-
// otherwise discard the window learned on a prior turn and
|
|
1319
|
-
// leave the next prompt's mid-stream updates reporting 200k.
|
|
1320
|
-
if (matchingModelUsage) {
|
|
1321
|
-
session.contextWindowSize = matchingModelUsage.contextWindow;
|
|
1322
|
-
}
|
|
1323
|
-
// Send usage_update notification
|
|
1324
|
-
if (lastAssistantTotalUsage !== null) {
|
|
1325
|
-
await this.client.sessionUpdate({
|
|
1326
|
-
sessionId: params.sessionId,
|
|
1327
|
-
update: {
|
|
1328
|
-
sessionUpdate: "usage_update",
|
|
1329
|
-
used: lastAssistantTotalUsage,
|
|
1330
|
-
size: session.contextWindowSize,
|
|
1331
|
-
cost: {
|
|
1332
|
-
amount: message.total_cost_usd,
|
|
1333
|
-
currency: "USD",
|
|
1334
|
-
},
|
|
1335
|
-
...(message.origin && {
|
|
1336
|
-
_meta: { "_claude/origin": message.origin },
|
|
1337
|
-
}),
|
|
1338
|
-
},
|
|
1339
|
-
});
|
|
1340
|
-
}
|
|
1341
|
-
if (session.cancelled) {
|
|
1342
|
-
if (!isTaskNotification) {
|
|
1343
|
-
stopReason = "cancelled";
|
|
1959
|
+
// A result from an autonomous cycle — a task-notification
|
|
1960
|
+
// followup, or a peer/coordinator/observer message the model
|
|
1961
|
+
// handled on its own (see AUTONOMOUS_RESULT_ORIGINS) — is not
|
|
1962
|
+
// the user's prompt's. Autonomous results must never touch the
|
|
1963
|
+
// user-turn lifecycle (stop reason, settles, failActive,
|
|
1964
|
+
// slash-command output forwarding), though their cost is real.
|
|
1965
|
+
const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
|
|
1966
|
+
try {
|
|
1967
|
+
// Reconcile the Fast mode toggle with the SDK's reported state.
|
|
1968
|
+
// Gated to user-driven turns like every other side effect below;
|
|
1969
|
+
// an autonomous cycle's state lands on the next user turn's
|
|
1970
|
+
// result. Runs even when the turn errors or was cancelled.
|
|
1971
|
+
if (!isAutonomousResult) {
|
|
1972
|
+
await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
|
|
1344
1973
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
if (
|
|
1355
|
-
|
|
1974
|
+
// A user-turn result needs an active turn so its stop reason is
|
|
1975
|
+
// attributed and the turn settles at idle. Local-only commands carry
|
|
1976
|
+
// no user-message echo to promote them, so do it here from the head.
|
|
1977
|
+
// Promote BEFORE accumulating usage, since activation resets the
|
|
1978
|
+
// accumulator — promoting after would discard this result's tokens.
|
|
1979
|
+
// The orphan bookkeeping runs first: it covers folded/zombie
|
|
1980
|
+
// commands whose shared or late result this is, even when the
|
|
1981
|
+
// result is the ACTIVE turn's (ensureActiveTurn never looks at
|
|
1982
|
+
// the map in that case).
|
|
1983
|
+
if (!isAutonomousResult) {
|
|
1984
|
+
recordResultForOrphanCommands();
|
|
1985
|
+
ensureActiveTurn();
|
|
1986
|
+
}
|
|
1987
|
+
// A result closes the stretch of output it terminates: snapshot
|
|
1988
|
+
// the delivery record — AFTER ensureActiveTurn, whose held-turn
|
|
1989
|
+
// hand-off closes the held stretch, so an echo-less command
|
|
1990
|
+
// promoted here is judged on its own delivery, not on the held
|
|
1991
|
+
// turn's followup text — and before the handling below can emit
|
|
1992
|
+
// anything of its own; the `finally` then clears it so every
|
|
1993
|
+
// exit from this case (the cancelled-guard and refusal breaks
|
|
1994
|
+
// included) starts the next stretch clean. Clearing up front
|
|
1995
|
+
// instead would let result-time emissions (refusal explanation,
|
|
1996
|
+
// result-text forwarding) taint the next stretch and suppress a
|
|
1997
|
+
// following replayed turn's fallback. Autonomous cycles run
|
|
1998
|
+
// alongside a user turn and must not clear its flag (they exit
|
|
1999
|
+
// through the early break below, which the gated `finally`
|
|
2000
|
+
// leaves alone).
|
|
2001
|
+
const deliveredAssistantText = session.emittedAssistantText;
|
|
2002
|
+
// Every user-turn result terminates a turn (settle, reject, or
|
|
2003
|
+
// orphan skip) and the SDK follows it with a trailing
|
|
2004
|
+
// `session_state_changed: idle` — record the debt so the idle
|
|
2005
|
+
// handler absorbs that idle rather than reading it as a turn the
|
|
2006
|
+
// SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
|
|
2007
|
+
// turn's own result. It is dropped at the `session.cancelled`
|
|
2008
|
+
// guard, and either the idle itself settles the turn (consuming
|
|
2009
|
+
// the trailer) or the next echo's hand-off does (which records
|
|
2010
|
+
// the debt there instead) — counting here too would double it.
|
|
2011
|
+
// Results skipped while cancelled with NO active turn — orphaned
|
|
2012
|
+
// queued turns the SDK still ran, or a force-cancelled turn's
|
|
2013
|
+
// late result after the backstop settled it — get no such settle,
|
|
2014
|
+
// so their trailers must be counted here or they'd later be read
|
|
2015
|
+
// as the next healthy turn being abandoned and false-fail it.
|
|
2016
|
+
// Autonomous results (followups, peer/channel/coordinator
|
|
2017
|
+
// cycles) are counted too: each is its own processing cycle with
|
|
2018
|
+
// its own trailing idle, and that idle can lag past the next
|
|
2019
|
+
// prompt's echo — which, un-owed, would be read as the fresh
|
|
2020
|
+
// turn being abandoned (#825 false-fail). That lag was mostly
|
|
2021
|
+
// unreachable when such cycles only ran with no pending turn,
|
|
2022
|
+
// but a held turn settling AT a followup result unblocks the
|
|
2023
|
+
// client at exactly that point, making the race the common
|
|
2024
|
+
// case.
|
|
2025
|
+
// The cancelled-ACTIVE-turn exclusion applies only to that
|
|
2026
|
+
// turn's OWN result — a followup result arriving inside the
|
|
2027
|
+
// cancel window still gets its own trailer and must be counted,
|
|
2028
|
+
// or that idle would later false-fail the next prompt.
|
|
2029
|
+
if (isAutonomousResult || !session.cancelled || !session.activeTurn) {
|
|
2030
|
+
session.owedTrailingIdles++;
|
|
2031
|
+
}
|
|
2032
|
+
// Accumulate usage into the user turn's tally. Skip autonomous
|
|
2033
|
+
// results: their cost is real but is reported separately via the
|
|
2034
|
+
// usage_update below, and `session.accumulatedUsage` is only reset on
|
|
2035
|
+
// turn activation — so folding an autonomous result that lands
|
|
2036
|
+
// after the next turn is active (but before it settles) would leak
|
|
2037
|
+
// those tokens into that turn's PromptResponse.usage.
|
|
2038
|
+
if (!isAutonomousResult) {
|
|
2039
|
+
session.accumulatedUsage.inputTokens += message.usage.input_tokens;
|
|
2040
|
+
session.accumulatedUsage.outputTokens += message.usage.output_tokens;
|
|
2041
|
+
session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
|
|
2042
|
+
session.accumulatedUsage.cachedWriteTokens +=
|
|
2043
|
+
message.usage.cache_creation_input_tokens;
|
|
2044
|
+
}
|
|
2045
|
+
const matchingModelUsage = lastAssistantModel
|
|
2046
|
+
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
|
2047
|
+
: null;
|
|
2048
|
+
// Only overwrite when we have an authoritative value — a miss
|
|
2049
|
+
// (e.g. a turn with no top-level assistant message) would
|
|
2050
|
+
// otherwise discard the window learned on a prior turn and
|
|
2051
|
+
// leave the next prompt's mid-stream updates reporting 200k.
|
|
2052
|
+
if (matchingModelUsage) {
|
|
2053
|
+
session.contextWindowSize = matchingModelUsage.contextWindow;
|
|
2054
|
+
}
|
|
2055
|
+
// Send usage_update notification
|
|
2056
|
+
if (lastAssistantTotalUsage !== null) {
|
|
2057
|
+
await sendUpdate({
|
|
1356
2058
|
sessionId: params.sessionId,
|
|
1357
2059
|
update: {
|
|
1358
|
-
sessionUpdate: "
|
|
1359
|
-
|
|
2060
|
+
sessionUpdate: "usage_update",
|
|
2061
|
+
used: lastAssistantTotalUsage,
|
|
2062
|
+
size: session.contextWindowSize,
|
|
2063
|
+
cost: {
|
|
2064
|
+
amount: message.total_cost_usd,
|
|
2065
|
+
currency: "USD",
|
|
2066
|
+
},
|
|
2067
|
+
...(message.origin && {
|
|
2068
|
+
_meta: { "_claude/origin": message.origin },
|
|
2069
|
+
}),
|
|
1360
2070
|
},
|
|
1361
2071
|
});
|
|
1362
2072
|
}
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
}
|
|
1367
|
-
switch (message.subtype) {
|
|
1368
|
-
case "success": {
|
|
1369
|
-
if (message.result.includes("Please run /login")) {
|
|
1370
|
-
failActive(RequestError.authRequired());
|
|
1371
|
-
break;
|
|
1372
|
-
}
|
|
1373
|
-
if (message.stop_reason === "max_tokens") {
|
|
1374
|
-
if (!isTaskNotification) {
|
|
1375
|
-
stopReason = "max_tokens";
|
|
1376
|
-
}
|
|
1377
|
-
break;
|
|
2073
|
+
if (session.cancelled) {
|
|
2074
|
+
if (!isAutonomousResult) {
|
|
2075
|
+
stopReason = "cancelled";
|
|
1378
2076
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
2077
|
+
break;
|
|
2078
|
+
}
|
|
2079
|
+
// A held turn (see Turn.deferredSettle) settles at its
|
|
2080
|
+
// followup's terminal result: this is the earliest point at which
|
|
2081
|
+
// the promised summary has fully streamed — the trailing idle
|
|
2082
|
+
// would work too, but a client should not wait out another idle
|
|
2083
|
+
// round-trip for a response whose content is already complete.
|
|
2084
|
+
// (While the turn still awaits another of its subagents —
|
|
2085
|
+
// parallel spawns — the helper holds; the next notification's
|
|
2086
|
+
// followup settles it instead. Other autonomous origins — peer/
|
|
2087
|
+
// channel/coordinator cycles — reach here too: settling a
|
|
2088
|
+
// drained hold at their results is as good as the idle
|
|
2089
|
+
// fallback.) Then stop: everything below is user-turn
|
|
2090
|
+
// lifecycle, and an autonomous outcome must never touch it —
|
|
2091
|
+
// its is_error or "Please run /login" text would otherwise
|
|
2092
|
+
// failActive a live turn (the held one, or the user's next
|
|
2093
|
+
// prompt) whose own result recorded a different outcome.
|
|
2094
|
+
if (isAutonomousResult) {
|
|
2095
|
+
settleDeferredIfDrained();
|
|
2096
|
+
// With no turn in flight OR QUEUED (also after the settle
|
|
2097
|
+
// above), the stretch holds only autonomous prose — close
|
|
2098
|
+
// it, so a replayed next prompt isn't silently suppressed by
|
|
2099
|
+
// the issue-#453 delivery check. A live turn's flag may
|
|
2100
|
+
// guard the USER's already-streamed text — and so may a
|
|
2101
|
+
// QUEUED turn's: with mid-message echo lag its deltas stream
|
|
2102
|
+
// before the echo activates it (activeTurn still null), and
|
|
2103
|
+
// clearing then would re-emit that answer via the fallback,
|
|
2104
|
+
// the duplicate direction the flag's doc forbids.
|
|
2105
|
+
if (!session.activeTurn && !firstUnsettledQueuedTurn()) {
|
|
2106
|
+
session.emittedAssistantText = false;
|
|
1382
2107
|
}
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
2108
|
+
break;
|
|
2109
|
+
}
|
|
2110
|
+
// A refusal can arrive on any result subtype (and may even set
|
|
2111
|
+
// is_error), so handle it before the subtype switch — otherwise the
|
|
2112
|
+
// is_error throw below would surface it as an internal error. The
|
|
2113
|
+
// refused assistant message carries no visible content, so surface
|
|
2114
|
+
// the classifier's explanation (when available) and report ACP's
|
|
2115
|
+
// dedicated `refusal` stop reason.
|
|
2116
|
+
if (message.stop_reason === "refusal") {
|
|
2117
|
+
if (lastRefusalExplanation) {
|
|
2118
|
+
await sendUpdate({
|
|
2119
|
+
sessionId: params.sessionId,
|
|
2120
|
+
update: {
|
|
2121
|
+
sessionUpdate: "agent_message_chunk",
|
|
2122
|
+
content: { type: "text", text: lastRefusalExplanation },
|
|
2123
|
+
},
|
|
2124
|
+
});
|
|
1391
2125
|
}
|
|
2126
|
+
stopReason = "refusal";
|
|
2127
|
+
// Through the deferral gate, not settleActive: a refusal can
|
|
2128
|
+
// land on a turn whose spawned subagents are still live, and
|
|
2129
|
+
// settling it out from under them would strand their output
|
|
2130
|
+
// and permission requests out-of-turn (issue #866's deadlock,
|
|
2131
|
+
// through the refusal lane).
|
|
2132
|
+
settleOrDefer({ stopReason: "refusal", usage: sessionUsage(session) });
|
|
1392
2133
|
break;
|
|
1393
2134
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
if (
|
|
2135
|
+
switch (message.subtype) {
|
|
2136
|
+
case "success": {
|
|
2137
|
+
if (message.result.includes("Please run /login")) {
|
|
2138
|
+
failActive(RequestError.authRequired());
|
|
2139
|
+
break;
|
|
2140
|
+
}
|
|
2141
|
+
if (message.stop_reason === "max_tokens") {
|
|
1397
2142
|
stopReason = "max_tokens";
|
|
2143
|
+
break;
|
|
2144
|
+
}
|
|
2145
|
+
if (message.is_error) {
|
|
2146
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
|
|
2147
|
+
break;
|
|
2148
|
+
}
|
|
2149
|
+
// The result text is forwarded in two cases. Local-only
|
|
2150
|
+
// commands (no model invocation): the result IS the command
|
|
2151
|
+
// output. Otherwise the result is normally a trailing copy of
|
|
2152
|
+
// text that already streamed — but a cache-replayed turn
|
|
2153
|
+
// generates no tokens, and some CLIs then skip streaming
|
|
2154
|
+
// entirely and answer on the `result` alone: no `stream_event`
|
|
2155
|
+
// deltas, no consolidated `assistant` message (issue #453).
|
|
2156
|
+
// Forward it rather than end the turn silently:
|
|
2157
|
+
// `deliveredAssistantText` covers whatever already reached the
|
|
2158
|
+
// client (a turn that showed its answer cannot emit it twice),
|
|
2159
|
+
// and the output-token check keeps the fallback to the
|
|
2160
|
+
// replayed turns it was reported for. `?? 0`: typed non-null,
|
|
2161
|
+
// but third-party backends have been observed omitting usage
|
|
2162
|
+
// token fields (see snapshotFromUsage), and the replay lane
|
|
2163
|
+
// was reported from exactly such a backend — treat a missing
|
|
2164
|
+
// count as the replay signature rather than silently disabling
|
|
2165
|
+
// the fallback there. (Autonomous results never get here —
|
|
2166
|
+
// they exit at the early break above — so no background
|
|
2167
|
+
// prose can be injected into the feed.)
|
|
2168
|
+
if (session.activeTurn?.isLocalOnlyCommand ||
|
|
2169
|
+
(!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
|
|
2170
|
+
for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
|
|
2171
|
+
await sendUpdate(notification);
|
|
2172
|
+
}
|
|
1398
2173
|
}
|
|
1399
2174
|
break;
|
|
1400
2175
|
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
2176
|
+
case "error_during_execution": {
|
|
2177
|
+
if (message.stop_reason === "max_tokens") {
|
|
2178
|
+
stopReason = "max_tokens";
|
|
2179
|
+
break;
|
|
2180
|
+
}
|
|
2181
|
+
if (message.is_error) {
|
|
2182
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
2183
|
+
break;
|
|
2184
|
+
}
|
|
1406
2185
|
stopReason = "end_turn";
|
|
1407
|
-
}
|
|
1408
|
-
break;
|
|
1409
|
-
}
|
|
1410
|
-
case "error_max_budget_usd":
|
|
1411
|
-
case "error_max_turns":
|
|
1412
|
-
case "error_max_structured_output_retries":
|
|
1413
|
-
if (message.is_error) {
|
|
1414
|
-
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
1415
2186
|
break;
|
|
1416
2187
|
}
|
|
1417
|
-
|
|
2188
|
+
case "error_max_budget_usd":
|
|
2189
|
+
case "error_max_turns":
|
|
2190
|
+
case "error_max_structured_output_retries":
|
|
2191
|
+
if (message.is_error) {
|
|
2192
|
+
failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
|
|
2193
|
+
break;
|
|
2194
|
+
}
|
|
1418
2195
|
stopReason = "max_turn_requests";
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
2196
|
+
break;
|
|
2197
|
+
default:
|
|
2198
|
+
unreachable(message, this.logger);
|
|
2199
|
+
break;
|
|
2200
|
+
}
|
|
2201
|
+
// Settle the user turn at its terminal result so the client unlocks
|
|
2202
|
+
// as soon as the answer is done, rather than waiting for the SDK's
|
|
2203
|
+
// trailing `idle` (which can lag while background work runs — issue
|
|
2204
|
+
// #773). The consumer keeps draining afterward (absorbing idle and
|
|
2205
|
+
// forwarding any background output).
|
|
2206
|
+
//
|
|
2207
|
+
// One exception: while background subagents this turn spawned are
|
|
2208
|
+
// still live, settling now would strand their remaining work
|
|
2209
|
+
// outside any turn — ACP allows out-of-turn session/update, but
|
|
2210
|
+
// many clients stop consuming at the prompt response, and a
|
|
2211
|
+
// subagent's permission request would block on an RPC nobody
|
|
2212
|
+
// answers (issues #864/#866). Hold the turn open instead: store
|
|
2213
|
+
// the outcome and settle with it once the subagents are done —
|
|
2214
|
+
// at their followup's terminal result (see the deferred-settle
|
|
2215
|
+
// block above the subtype switch) or at an idle with none of
|
|
2216
|
+
// them left — so the subagents' streamed output, their
|
|
2217
|
+
// permission requests, and the model's promised summary all land
|
|
2218
|
+
// inside the turn. `session/cancel` and the next prompt's echo
|
|
2219
|
+
// hand-off still settle a deferred turn early, so a long-running
|
|
2220
|
+
// subagent never holds the prompt hostage.
|
|
2221
|
+
//
|
|
2222
|
+
// is_error/auth already settled via failActive (activeTurn is null
|
|
2223
|
+
// then, so both branches no-op); cancellation is left to the
|
|
2224
|
+
// idle/abort path. settleActive is idempotent, so a duplicate
|
|
2225
|
+
// idle is a no-op.
|
|
2226
|
+
if (!session.cancelled) {
|
|
2227
|
+
settleOrDefer({ stopReason, usage: sessionUsage(session) });
|
|
2228
|
+
}
|
|
1424
2229
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
// forwarding any background output). is_error/auth already settled
|
|
1430
|
-
// via failActive; cancellation is left to the idle/abort path.
|
|
1431
|
-
// settleActive is idempotent, so a duplicate idle is a no-op.
|
|
1432
|
-
if (!isTaskNotification && !session.cancelled) {
|
|
1433
|
-
settleActive({ stopReason, usage: sessionUsage(session) });
|
|
2230
|
+
finally {
|
|
2231
|
+
if (!isAutonomousResult) {
|
|
2232
|
+
session.emittedAssistantText = false;
|
|
2233
|
+
}
|
|
1434
2234
|
}
|
|
1435
2235
|
break;
|
|
1436
2236
|
}
|
|
@@ -1493,11 +2293,10 @@ export class ClaudeAcpAgent {
|
|
|
1493
2293
|
const model = message.event.message.model;
|
|
1494
2294
|
if (model && model !== "<synthetic>") {
|
|
1495
2295
|
lastAssistantModel = model;
|
|
1496
|
-
// Only upgrade from the default — once
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
// default so this branch runs again for the new model.
|
|
2296
|
+
// Only upgrade from the default — once the SDK has given us
|
|
2297
|
+
// an authoritative window (seeded at session creation,
|
|
2298
|
+
// refreshed on model switches in `applyConfigOptionValue`,
|
|
2299
|
+
// confirmed by each `result`), trust it over the heuristic.
|
|
1501
2300
|
if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
|
|
1502
2301
|
const inferred = inferContextWindowFromModel(model);
|
|
1503
2302
|
if (inferred !== null) {
|
|
@@ -1523,7 +2322,7 @@ export class ClaudeAcpAgent {
|
|
|
1523
2322
|
const nextUsage = totalTokens(lastAssistantUsage);
|
|
1524
2323
|
if (nextUsage !== lastAssistantTotalUsage) {
|
|
1525
2324
|
lastAssistantTotalUsage = nextUsage;
|
|
1526
|
-
await
|
|
2325
|
+
await sendUpdate({
|
|
1527
2326
|
sessionId: params.sessionId,
|
|
1528
2327
|
update: {
|
|
1529
2328
|
sessionUpdate: "usage_update",
|
|
@@ -1539,8 +2338,11 @@ export class ClaudeAcpAgent {
|
|
|
1539
2338
|
taskState: session.taskState,
|
|
1540
2339
|
emittedToolCalls: session.emittedToolCalls,
|
|
1541
2340
|
messageId: currentStreamMessageId,
|
|
2341
|
+
streamedToolInputs,
|
|
1542
2342
|
})) {
|
|
1543
|
-
|
|
2343
|
+
// sendUpdate records delivery; a subagent stream's chunks carry
|
|
2344
|
+
// the stamped parentToolUseId meta and are excluded there.
|
|
2345
|
+
await sendUpdate(notification);
|
|
1544
2346
|
}
|
|
1545
2347
|
break;
|
|
1546
2348
|
}
|
|
@@ -1561,7 +2363,7 @@ export class ClaudeAcpAgent {
|
|
|
1561
2363
|
// is still promoted — activateTurn() clears the flag. The turn's own
|
|
1562
2364
|
// echo is then dropped from the feed (the client already shows it).
|
|
1563
2365
|
if (message.type === "user" && "uuid" in message && message.uuid) {
|
|
1564
|
-
const queued = (
|
|
2366
|
+
const queued = findUnsettledTurn(message.uuid);
|
|
1565
2367
|
if (queued) {
|
|
1566
2368
|
// Only (re)activate if this isn't already the active turn — a
|
|
1567
2369
|
// turn promoted early (e.g. by a result that preceded its echo)
|
|
@@ -1579,15 +2381,38 @@ export class ClaudeAcpAgent {
|
|
|
1579
2381
|
// debt so that lagged idle is absorbed rather than read
|
|
1580
2382
|
// as the freshly-activated turn ending without a result
|
|
1581
2383
|
// (which would false-fail a healthy turn — issue #825).
|
|
1582
|
-
|
|
2384
|
+
// Counted for a DEFERRED turn too, even though its own
|
|
2385
|
+
// result already recorded a debt that may still be
|
|
2386
|
+
// outstanding: the interrupt can produce a trailer of
|
|
2387
|
+
// its own, and over-counting is benign (absorbs one
|
|
2388
|
+
// future idle) while under-counting risks the false
|
|
2389
|
+
// fail this debt exists to prevent.
|
|
2390
|
+
session.owedTrailingIdles++;
|
|
1583
2391
|
// Before activateTurn resets the accumulator, so the
|
|
1584
2392
|
// usage still belongs to the cancelled turn.
|
|
1585
2393
|
settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
|
|
1586
2394
|
}
|
|
2395
|
+
else if (isHeldOpen(session.activeTurn)) {
|
|
2396
|
+
// A turn held open for its background subagents (see
|
|
2397
|
+
// Turn.deferredSettle) hands off with the real outcome
|
|
2398
|
+
// its result recorded, not a guessed end_turn — the
|
|
2399
|
+
// user moving on must not block behind a long-running
|
|
2400
|
+
// subagent, but it must not rewrite the stop reason
|
|
2401
|
+
// either. Its trailing-idle debt stands and is absorbed
|
|
2402
|
+
// when the drain idle eventually arrives.
|
|
2403
|
+
settleActive(session.activeTurn.deferredSettle);
|
|
2404
|
+
}
|
|
1587
2405
|
else {
|
|
1588
2406
|
settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
|
|
1589
2407
|
}
|
|
1590
2408
|
}
|
|
2409
|
+
// Unlike the no-result teardown lanes, this hand-off must
|
|
2410
|
+
// NOT clear emittedAssistantText for a NON-held previous
|
|
2411
|
+
// turn (a held one's settleActive above closes its own
|
|
2412
|
+
// stretch): the echo can land
|
|
2413
|
+
// mid-message, so deltas already streamed belong to the turn
|
|
2414
|
+
// being activated — clearing would forget them and let its
|
|
2415
|
+
// result re-emit the answer.
|
|
1591
2416
|
activateTurn(queued);
|
|
1592
2417
|
}
|
|
1593
2418
|
break;
|
|
@@ -1640,7 +2465,7 @@ export class ClaudeAcpAgent {
|
|
|
1640
2465
|
taskState: session.taskState,
|
|
1641
2466
|
messageId: messageIdForGrouping(message),
|
|
1642
2467
|
})) {
|
|
1643
|
-
await
|
|
2468
|
+
await sendUpdate(notification);
|
|
1644
2469
|
}
|
|
1645
2470
|
}
|
|
1646
2471
|
else {
|
|
@@ -1664,12 +2489,7 @@ export class ClaudeAcpAgent {
|
|
|
1664
2489
|
if (message.message.role === "system") {
|
|
1665
2490
|
break;
|
|
1666
2491
|
}
|
|
1667
|
-
if (message.type === "assistant" &&
|
|
1668
|
-
message.message.model === "<synthetic>" &&
|
|
1669
|
-
Array.isArray(message.message.content) &&
|
|
1670
|
-
message.message.content.length === 1 &&
|
|
1671
|
-
message.message.content[0].type === "text" &&
|
|
1672
|
-
message.message.content[0].text.includes("Please run /login")) {
|
|
2492
|
+
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
1673
2493
|
failActive(RequestError.authRequired());
|
|
1674
2494
|
break;
|
|
1675
2495
|
}
|
|
@@ -1752,13 +2572,18 @@ export class ClaudeAcpAgent {
|
|
|
1752
2572
|
taskState: session.taskState,
|
|
1753
2573
|
emittedToolCalls: session.emittedToolCalls,
|
|
1754
2574
|
messageId: messageIdForGrouping(message),
|
|
2575
|
+
toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
|
|
1755
2576
|
})) {
|
|
1756
|
-
|
|
2577
|
+
// sendUpdate records delivery. Subagent text/thinking is
|
|
2578
|
+
// filtered out of `content` above; blocks that do pass through
|
|
2579
|
+
// (e.g. a subagent image) carry the stamped parentToolUseId
|
|
2580
|
+
// meta and are excluded there.
|
|
2581
|
+
await sendUpdate(notification);
|
|
1757
2582
|
}
|
|
1758
2583
|
break;
|
|
1759
2584
|
}
|
|
1760
2585
|
case "tool_progress": {
|
|
1761
|
-
await
|
|
2586
|
+
await sendUpdate({
|
|
1762
2587
|
sessionId: message.session_id,
|
|
1763
2588
|
update: {
|
|
1764
2589
|
sessionUpdate: "tool_call_update",
|
|
@@ -1776,7 +2601,7 @@ export class ClaudeAcpAgent {
|
|
|
1776
2601
|
}
|
|
1777
2602
|
case "rate_limit_event": {
|
|
1778
2603
|
if (lastAssistantTotalUsage !== null) {
|
|
1779
|
-
await
|
|
2604
|
+
await sendUpdate({
|
|
1780
2605
|
sessionId: message.session_id,
|
|
1781
2606
|
update: {
|
|
1782
2607
|
sessionUpdate: "usage_update",
|
|
@@ -1791,16 +2616,13 @@ export class ClaudeAcpAgent {
|
|
|
1791
2616
|
// `conversation_reset` (from `/clear`, plan-mode exit, fresh-session
|
|
1792
2617
|
// flows) is safe to drop: turn lifecycle here is driven by
|
|
1793
2618
|
// results/idle, and the client owns its own transcript view.
|
|
1794
|
-
// `control_request_progress` only reports on side_question control
|
|
1795
|
-
// requests, which this adapter never issues.
|
|
1796
2619
|
case "tool_use_summary":
|
|
1797
2620
|
case "auth_status":
|
|
1798
2621
|
case "prompt_suggestion":
|
|
1799
2622
|
case "conversation_reset":
|
|
1800
|
-
case "control_request_progress":
|
|
1801
2623
|
break;
|
|
1802
2624
|
default:
|
|
1803
|
-
unreachable(message);
|
|
2625
|
+
unreachable(message, this.logger);
|
|
1804
2626
|
break;
|
|
1805
2627
|
}
|
|
1806
2628
|
}
|
|
@@ -1837,6 +2659,30 @@ export class ClaudeAcpAgent {
|
|
|
1837
2659
|
}
|
|
1838
2660
|
}
|
|
1839
2661
|
}
|
|
2662
|
+
/** Route one orphaned command into the session's orphan-accounting lane:
|
|
2663
|
+
* the per-uuid map on msg_lifecycle_v1 CLIs (drained by the command's own
|
|
2664
|
+
* terminal lifecycle frame and the echo-less-result skip), the plain count
|
|
2665
|
+
* elsewhere (the count lane can't express per-command states, so `state`
|
|
2666
|
+
* only matters on the map lane). Both orphan-producing paths — cancel()'s
|
|
2667
|
+
* queued-turn sweep and the consumer's force-cancel wedge path — must seed
|
|
2668
|
+
* through here so the lane split stays a single mechanism.
|
|
2669
|
+
*
|
|
2670
|
+
* Known window: `msgLifecycleV1` is only learnable from the stream's first
|
|
2671
|
+
* `system`/init (the control-channel initialize carries no capabilities),
|
|
2672
|
+
* so a cancel that beats that drain seeds the COUNT lane on a
|
|
2673
|
+
* lifecycle-capable CLI — where command coalescing can leave the count
|
|
2674
|
+
* stale by N-1 (the pre-map bug, confined to this sub-second window and
|
|
2675
|
+
* still healed by the next activation's reset). Structural until the SDK
|
|
2676
|
+
* exposes capabilities before the stream starts. */
|
|
2677
|
+
trackOrphanCommand(session, uuid, state) {
|
|
2678
|
+
if (session.msgLifecycleV1) {
|
|
2679
|
+
session.orphanCommands ??= new Map();
|
|
2680
|
+
session.orphanCommands.set(uuid, state);
|
|
2681
|
+
}
|
|
2682
|
+
else {
|
|
2683
|
+
session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
1840
2686
|
async cancel(params) {
|
|
1841
2687
|
const session = this.sessions[params.sessionId];
|
|
1842
2688
|
if (!session) {
|
|
@@ -1850,28 +2696,117 @@ export class ClaudeAcpAgent {
|
|
|
1850
2696
|
return;
|
|
1851
2697
|
}
|
|
1852
2698
|
session.cancelled = true;
|
|
2699
|
+
// Capture the orphan-accounting lane before anything can await: the
|
|
2700
|
+
// consumer latches msgLifecycleV1 when it drains the first system/init,
|
|
2701
|
+
// which can happen DURING the awaited interrupt() below — the receipt
|
|
2702
|
+
// reconciliation must act on the same lane the seeding used, or a
|
|
2703
|
+
// count-lane orphan would be left for the map-lane receipt path (which
|
|
2704
|
+
// never decrements the count) to miss.
|
|
2705
|
+
const lifecycleLane = session.msgLifecycleV1 === true;
|
|
1853
2706
|
// Settle queued turns that haven't started yet (no echo seen) right away —
|
|
1854
2707
|
// they have no in-flight SDK work to interrupt. The active turn is settled
|
|
1855
2708
|
// by the consumer when it observes the interrupt's trailing idle (or via the
|
|
1856
2709
|
// backstop below). Mirrors the old pendingMessages cancellation.
|
|
2710
|
+
const orphanedTurns = [];
|
|
1857
2711
|
if (session.turnQueue) {
|
|
1858
|
-
let orphaned = 0;
|
|
1859
2712
|
for (const turn of session.turnQueue) {
|
|
1860
2713
|
if (turn !== session.activeTurn && !turn.settled) {
|
|
1861
2714
|
turn.settled = true;
|
|
1862
2715
|
// Deliberately no `usage`: a queued turn never ran, so the session
|
|
1863
2716
|
// accumulator (the active turn's tally) is not its spend.
|
|
1864
2717
|
turn.resolve({ stopReason: "cancelled" });
|
|
1865
|
-
|
|
2718
|
+
orphanedTurns.push(turn);
|
|
1866
2719
|
}
|
|
1867
2720
|
}
|
|
1868
2721
|
// Each removed queued turn's user message was already pushed to the SDK,
|
|
1869
2722
|
// which processes input FIFO and will still emit a result for it with no
|
|
1870
|
-
// uuid to match.
|
|
2723
|
+
// uuid to match. Track those so the consumer skips them (see
|
|
1871
2724
|
// ensureActiveTurn) rather than misattributing them to the head.
|
|
1872
|
-
|
|
2725
|
+
// msg_lifecycle_v1 CLIs get per-uuid tracking drained by the command's
|
|
2726
|
+
// own terminal lifecycle frame — exact under command coalescing, where
|
|
2727
|
+
// N queued commands fold into ONE turn emitting one result and a plain
|
|
2728
|
+
// count would go stale by N-1 and swallow a later echo-less result.
|
|
2729
|
+
// Older CLIs keep the count and its activation-time self-heal (they
|
|
2730
|
+
// never see lifecycle frames, so commandStarted/commandFinished stay
|
|
2731
|
+
// unset and every turn takes the plain-seed path below).
|
|
2732
|
+
for (const turn of orphanedTurns) {
|
|
2733
|
+
if (turn.commandFinished === "completed" || turn.commandFinished === "discarded") {
|
|
2734
|
+
// The command already finished SDK-side and its terminal frame was
|
|
2735
|
+
// consumed while the turn sat queued — nothing is left to skip, and
|
|
2736
|
+
// a seeded entry would never drain.
|
|
2737
|
+
continue;
|
|
2738
|
+
}
|
|
2739
|
+
if (turn.commandFinished === "cancelled") {
|
|
2740
|
+
// Terminal frame already consumed. Dispatched-then-aborted: the
|
|
2741
|
+
// dead turn's late result may still come — seed the zombie the
|
|
2742
|
+
// frame handler would have made — unless that result already
|
|
2743
|
+
// passed pre-cancel (commandResultSeen: e.g. the command folded
|
|
2744
|
+
// into the active turn and their shared result was attributed
|
|
2745
|
+
// there), in which case a zombie would be a phantom that swallows
|
|
2746
|
+
// an unrelated later result. Never dispatched: dropped, no result
|
|
2747
|
+
// coming, nothing to track.
|
|
2748
|
+
if (turn.commandStarted && !turn.commandResultSeen) {
|
|
2749
|
+
this.trackOrphanCommand(session, turn.promptUuid, "zombie");
|
|
2750
|
+
}
|
|
2751
|
+
continue;
|
|
2752
|
+
}
|
|
2753
|
+
if (turn.commandStarted && turn.commandResultSeen) {
|
|
2754
|
+
// Dispatched and its turn's result already passed; only its
|
|
2755
|
+
// terminal frame is outstanding, which no-ops with no entry.
|
|
2756
|
+
continue;
|
|
2757
|
+
}
|
|
2758
|
+
this.trackOrphanCommand(session, turn.promptUuid, turn.commandStarted ? "started" : "pending");
|
|
2759
|
+
}
|
|
1873
2760
|
session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
|
|
1874
2761
|
}
|
|
2762
|
+
// A deferred active turn (see Turn.deferredSettle) already has its
|
|
2763
|
+
// result — it is only held open for its background subagents, which the
|
|
2764
|
+
// interrupt below tears down. Settle it "cancelled" NOW: during the hold
|
|
2765
|
+
// the session is typically already in state idle (the CLI's trailer
|
|
2766
|
+
// fired at the result), so the interrupt may produce no fresh idle for
|
|
2767
|
+
// the consumer's cancelled-settle path to run on, and the cancel would
|
|
2768
|
+
// otherwise stall until the force-cancel backstop. Any outstanding
|
|
2769
|
+
// trailer debt is absorbed by the idle handler when its idle does come.
|
|
2770
|
+
// The turn's own usage snapshot is reported per the cancelled-usage
|
|
2771
|
+
// contract (issue #844).
|
|
2772
|
+
{
|
|
2773
|
+
const active = session.activeTurn;
|
|
2774
|
+
if (isHeldOpen(active)) {
|
|
2775
|
+
active.settled = true;
|
|
2776
|
+
// Mirror settleActive's invariants (it is consumer-scoped and
|
|
2777
|
+
// unreachable from here): disarm the backstop — none should be
|
|
2778
|
+
// armed for a held turn, but a drift here must not leave a timer
|
|
2779
|
+
// firing on a settled turn — and drop the turn from the queue.
|
|
2780
|
+
disarmForceCancel(session);
|
|
2781
|
+
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== active);
|
|
2782
|
+
session.activeTurn = null;
|
|
2783
|
+
// Settling a held turn closes its delivery stretch: any streamed
|
|
2784
|
+
// text since the last boundary was its followups', and left latched
|
|
2785
|
+
// it would suppress a following replayed turn's issue-#453 fallback.
|
|
2786
|
+
session.emittedAssistantText = false;
|
|
2787
|
+
// When the interrupt below pre-empts a live cycle — running, or
|
|
2788
|
+
// blocked on a permission request (requires_action, the #866 shape
|
|
2789
|
+
// users cancel out of) — it produces a trailer idle with no counted
|
|
2790
|
+
// result; with the hold's own trailer typically already absorbed,
|
|
2791
|
+
// that idle would be un-owed and could lag past the next prompt's
|
|
2792
|
+
// echo — read as the fresh turn ending without a result (issue #825
|
|
2793
|
+
// false-fail). Pre-count it unless the session sits idle: there the
|
|
2794
|
+
// interrupt emits nothing, and a debt that never drains would mask
|
|
2795
|
+
// one future #825 detection. (lastSessionState is last-CONSUMED, so
|
|
2796
|
+
// both stale reads exist and both are accepted one-cycle windows: a
|
|
2797
|
+
// running transition still in the backlog reads as stale idle and
|
|
2798
|
+
// under-counts — that false-fail additionally needs the trailer to
|
|
2799
|
+
// lag past the next echo — while a cycle already completed into the
|
|
2800
|
+
// backlog reads as stale non-idle and over-counts, masking one
|
|
2801
|
+
// future #825 detection. Undefined — no state event consumed —
|
|
2802
|
+
// pre-counts; that only occurs on CLIs whose missing idle events
|
|
2803
|
+
// also disable the detector the debt could mask.)
|
|
2804
|
+
if (session.lastSessionState !== "idle") {
|
|
2805
|
+
session.owedTrailingIdles++;
|
|
2806
|
+
}
|
|
2807
|
+
active.resolve({ stopReason: "cancelled", usage: active.deferredSettle.usage });
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
1875
2810
|
// Arm a backstop before interrupting: if a turn is actively consuming the
|
|
1876
2811
|
// query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
|
|
1877
2812
|
// block — issue #680), force the consumer to settle the active turn
|
|
@@ -1893,7 +2828,48 @@ export class ClaudeAcpAgent {
|
|
|
1893
2828
|
cancelController.abort();
|
|
1894
2829
|
}, this.forceCancelGraceMs);
|
|
1895
2830
|
}
|
|
1896
|
-
await session.query.interrupt();
|
|
2831
|
+
const receipt = await session.query.interrupt();
|
|
2832
|
+
// On CLIs advertising `interrupt_receipt_v1`, the receipt's `still_queued`
|
|
2833
|
+
// lists exactly which queued messages survive the interrupt and will still
|
|
2834
|
+
// run. An orphaned turn whose uuid is absent was dropped by the interrupt
|
|
2835
|
+
// and will never emit a result — uncount it now instead of leaving a stale
|
|
2836
|
+
// skip that activateTurn's reset only clears once a later live ECHO
|
|
2837
|
+
// arrives: an echo-less result in between (a local-only command like
|
|
2838
|
+
// `/context`) would be wrongly swallowed by the leftover count. Subtracting
|
|
2839
|
+
// a count (rather than tracking uuids) stays race-safe against the
|
|
2840
|
+
// consumer draining concurrently: dropped uuids produce no results, so the
|
|
2841
|
+
// consumer's decrements only ever consume the still-queued share. Unknown
|
|
2842
|
+
// uuids in the receipt (internally-enqueued messages) are ignored, per its
|
|
2843
|
+
// contract. Older CLIs resolve `undefined` (guard the FIELD, not just the
|
|
2844
|
+
// receipt, so a bare `{}` success from a gateway can't read as "everything
|
|
2845
|
+
// was dropped") — keep the count-everything behavior and its
|
|
2846
|
+
// activation-time self-heal.
|
|
2847
|
+
if (Array.isArray(receipt?.still_queued) && orphanedTurns.length > 0) {
|
|
2848
|
+
const stillQueued = new Set(receipt.still_queued);
|
|
2849
|
+
if (lifecycleLane) {
|
|
2850
|
+
// Lifecycle lane: forget dropped orphans by uuid. Only entries still
|
|
2851
|
+
// "pending" — an orphan absent from `still_queued` because it was
|
|
2852
|
+
// DISPATCHED before the interrupt (not dropped) has usually been
|
|
2853
|
+
// promoted to "started" by its lifecycle frame by now, and its own
|
|
2854
|
+
// terminal frame must stay in charge of its fate. (If that frame is
|
|
2855
|
+
// still in the consumer's backlog we mis-forget — the same exposure
|
|
2856
|
+
// the count lane has always had for a dropped-then-run command.)
|
|
2857
|
+
// Mostly redundant with the "cancelled"-frame removal, but a receipt
|
|
2858
|
+
// survives paths where that frame was never emitted.
|
|
2859
|
+
for (const turn of orphanedTurns) {
|
|
2860
|
+
if (!stillQueued.has(turn.promptUuid) &&
|
|
2861
|
+
session.orphanCommands?.get(turn.promptUuid) === "pending") {
|
|
2862
|
+
session.orphanCommands.delete(turn.promptUuid);
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
else {
|
|
2867
|
+
const dropped = orphanedTurns.filter((turn) => !stillQueued.has(turn.promptUuid)).length;
|
|
2868
|
+
if (dropped > 0) {
|
|
2869
|
+
session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
1897
2873
|
}
|
|
1898
2874
|
/** Mark a session's SDK query stream as permanently ended and release the
|
|
1899
2875
|
* resources tied to it: drop the consumer handle, dispose the settings
|
|
@@ -1939,10 +2915,7 @@ export class ClaudeAcpAgent {
|
|
|
1939
2915
|
// after the floor, and clear the timer so it can't outlive the deleted
|
|
1940
2916
|
// session (it isn't unref'd and would otherwise keep the event loop alive
|
|
1941
2917
|
// until it fires).
|
|
1942
|
-
|
|
1943
|
-
clearTimeout(session.forceCancelTimer);
|
|
1944
|
-
session.forceCancelTimer = undefined;
|
|
1945
|
-
}
|
|
2918
|
+
disarmForceCancel(session);
|
|
1946
2919
|
session.cancelController?.abort();
|
|
1947
2920
|
this.closeQueryStream(session);
|
|
1948
2921
|
// Abort the SDK abort signal only on explicit destroy. closeQueryStream
|
|
@@ -2122,6 +3095,12 @@ export class ClaudeAcpAgent {
|
|
|
2122
3095
|
if (replaySession && replayMessageId && message.uuid) {
|
|
2123
3096
|
replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
|
|
2124
3097
|
}
|
|
3098
|
+
// The live prompt loop converts the synthetic "Please run /login"
|
|
3099
|
+
// assistant message into an authRequired error instead of showing its
|
|
3100
|
+
// TUI-specific text; skip it on replay too (issue #863).
|
|
3101
|
+
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
|
|
3102
|
+
continue;
|
|
3103
|
+
}
|
|
2125
3104
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
2126
3105
|
let content = message.message.content;
|
|
2127
3106
|
// @ts-expect-error - untyped in SDK but we handle all of these
|
|
@@ -2160,14 +3139,14 @@ export class ClaudeAcpAgent {
|
|
|
2160
3139
|
* outcome or a `requestCancelled` rejection). Either way we surface the same
|
|
2161
3140
|
* "Tool use aborted" the callers already expect, so a cancelled dialog no
|
|
2162
3141
|
* longer leaves the `await` hanging. */
|
|
2163
|
-
async requestPermissionFromClient(params, toolName, signal) {
|
|
3142
|
+
async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
|
|
2164
3143
|
// The SDK may invoke `canUseTool` (and therefore this permission request)
|
|
2165
3144
|
// before the assistant message's tool_use block streams to us. Some ACP clients
|
|
2166
3145
|
// expect the `tool_call` a permission request references to already exist,
|
|
2167
3146
|
// so emit it now if it hasn't been sent yet. The streamed tool_use chunk
|
|
2168
3147
|
// later refines it with a `tool_call_update` rather than emitting a
|
|
2169
3148
|
// duplicate (see `emittedToolCalls` in `toAcpNotifications`).
|
|
2170
|
-
await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput);
|
|
3149
|
+
await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
|
|
2171
3150
|
try {
|
|
2172
3151
|
return await this.client.requestPermission(params, signal);
|
|
2173
3152
|
}
|
|
@@ -2184,10 +3163,15 @@ export class ClaudeAcpAgent {
|
|
|
2184
3163
|
* instead of emitting a duplicate (see `emittedToolCalls`). Built via the same
|
|
2185
3164
|
* `toolCallNotification` helper as the streamed path so the two are identical.
|
|
2186
3165
|
* Tools the stream renders as a plan (TodoWrite) or suppresses (Task*) are
|
|
2187
|
-
*
|
|
2188
|
-
|
|
3166
|
+
* emitted too: a permission request referencing a tool call the client has
|
|
3167
|
+
* never seen can trip strict clients (issue #851), so the reference must
|
|
3168
|
+
* always resolve. Since the streamed path never completes those calls, they
|
|
3169
|
+
* are resolved at tool_result time instead (see `toAcpNotifications`).
|
|
3170
|
+
* `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
|
|
3171
|
+
* that spawned it, matching the streamed path's `_meta`. */
|
|
3172
|
+
async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
|
|
2189
3173
|
const session = this.sessions[sessionId];
|
|
2190
|
-
if (!session
|
|
3174
|
+
if (!session) {
|
|
2191
3175
|
return;
|
|
2192
3176
|
}
|
|
2193
3177
|
if (session.emittedToolCalls.has(toolCallId)) {
|
|
@@ -2195,13 +3179,20 @@ export class ClaudeAcpAgent {
|
|
|
2195
3179
|
}
|
|
2196
3180
|
session.emittedToolCalls.add(toolCallId);
|
|
2197
3181
|
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
update
|
|
2201
|
-
|
|
3182
|
+
const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
|
|
3183
|
+
if (parentToolUseId) {
|
|
3184
|
+
update._meta = {
|
|
3185
|
+
...update._meta,
|
|
3186
|
+
claudeCode: {
|
|
3187
|
+
...(update._meta?.claudeCode || {}),
|
|
3188
|
+
parentToolUseId,
|
|
3189
|
+
},
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
await this.client.sessionUpdate({ sessionId, update });
|
|
2202
3193
|
}
|
|
2203
3194
|
canUseTool(sessionId) {
|
|
2204
|
-
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
|
3195
|
+
return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID }) => {
|
|
2205
3196
|
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
|
|
2206
3197
|
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
2207
3198
|
const session = this.sessions[sessionId];
|
|
@@ -2211,6 +3202,23 @@ export class ClaudeAcpAgent {
|
|
|
2211
3202
|
message: "Session not found",
|
|
2212
3203
|
};
|
|
2213
3204
|
}
|
|
3205
|
+
// When the tool call originates inside a subagent, attribute the eagerly
|
|
3206
|
+
// emitted tool_call (and the permission request itself) to the Agent/Task
|
|
3207
|
+
// tool call that spawned the subagent, mirroring the streamed subagent
|
|
3208
|
+
// path's `_meta.claudeCode.parentToolUseId` (see `liveBackgroundTasks`).
|
|
3209
|
+
const parentToolUseId = agentID
|
|
3210
|
+
? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
|
|
3211
|
+
: undefined;
|
|
3212
|
+
if (agentID && !parentToolUseId) {
|
|
3213
|
+
// The attribution rests on an undocumented SDK invariant
|
|
3214
|
+
// (task_started.task_id === canUseTool's agentID for subagent tasks;
|
|
3215
|
+
// verified against the bundled CLI). Should an SDK bump break it — or
|
|
3216
|
+
// the consumer lose the race with task_started — the lookup misses and
|
|
3217
|
+
// the request goes out unattributed; log it so the regression is
|
|
3218
|
+
// observable rather than silent.
|
|
3219
|
+
this.logger.log(`[claude-agent-acp] No parent tool_use recorded for subagent ${agentID}; ` +
|
|
3220
|
+
`sending the ${toolName} permission request unattributed`);
|
|
3221
|
+
}
|
|
2214
3222
|
// AskUserQuestion is surfaced to us as a normal permission check (the SDK
|
|
2215
3223
|
// routes it through canUseTool whenever a callback is registered, rather
|
|
2216
3224
|
// than the interactive dialog). Present it as an ACP form elicitation and
|
|
@@ -2218,7 +3226,7 @@ export class ClaudeAcpAgent {
|
|
|
2218
3226
|
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
|
|
2219
3227
|
// Like permission requests, the elicitation references this toolUseID, so
|
|
2220
3228
|
// make sure the tool_call has surfaced to the client before we send it.
|
|
2221
|
-
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
|
|
3229
|
+
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
|
|
2222
3230
|
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
|
|
2223
3231
|
}
|
|
2224
3232
|
if (toolName === "ExitPlanMode") {
|
|
@@ -2252,8 +3260,13 @@ export class ClaudeAcpAgent {
|
|
|
2252
3260
|
toolCallId: toolUseID,
|
|
2253
3261
|
rawInput: toolInput,
|
|
2254
3262
|
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
3263
|
+
// `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
|
|
3264
|
+
// so clients can rely on one shape everywhere.
|
|
3265
|
+
...(parentToolUseId
|
|
3266
|
+
? { _meta: { claudeCode: { toolName, parentToolUseId } } }
|
|
3267
|
+
: {}),
|
|
2255
3268
|
},
|
|
2256
|
-
}, toolName, signal);
|
|
3269
|
+
}, toolName, signal, parentToolUseId);
|
|
2257
3270
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
2258
3271
|
throw new Error("Tool use aborted");
|
|
2259
3272
|
}
|
|
@@ -2311,8 +3324,13 @@ export class ClaudeAcpAgent {
|
|
|
2311
3324
|
toolCallId: toolUseID,
|
|
2312
3325
|
rawInput: toolInput,
|
|
2313
3326
|
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
3327
|
+
// `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
|
|
3328
|
+
// so clients can rely on one shape everywhere.
|
|
3329
|
+
...(parentToolUseId
|
|
3330
|
+
? { _meta: { claudeCode: { toolName, parentToolUseId } } }
|
|
3331
|
+
: {}),
|
|
2314
3332
|
},
|
|
2315
|
-
}, toolName, signal);
|
|
3333
|
+
}, toolName, signal, parentToolUseId);
|
|
2316
3334
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
2317
3335
|
throw new Error("Tool use aborted");
|
|
2318
3336
|
}
|
|
@@ -2488,12 +3506,17 @@ export class ClaudeAcpAgent {
|
|
|
2488
3506
|
// carries no "1m" token.
|
|
2489
3507
|
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
|
2490
3508
|
if (session.models.currentModelId !== value) {
|
|
2491
|
-
// The cached context window was learned for the previous model
|
|
2492
|
-
//
|
|
2493
|
-
//
|
|
2494
|
-
//
|
|
3509
|
+
// The cached context window was learned for the previous model. The
|
|
3510
|
+
// SDK is already running the new model here (user-driven switches call
|
|
3511
|
+
// `query.setModel` before this, and the refusal-fallback sync only
|
|
3512
|
+
// reconciles a switch the SDK already made), so ask it for the new
|
|
3513
|
+
// window; fall back to the text heuristic so mid-stream updates
|
|
3514
|
+
// between now and the next `result` reflect the user's selection
|
|
3515
|
+
// instead of the old model's window.
|
|
2495
3516
|
session.contextWindowSize =
|
|
2496
|
-
|
|
3517
|
+
(await fetchContextWindowSize(session.query, this.logger)) ??
|
|
3518
|
+
inferContextWindowFromModel(value, newModelInfo?.resolvedModel, newModelInfo?.displayName, newModelInfo?.description) ??
|
|
3519
|
+
DEFAULT_CONTEXT_WINDOW;
|
|
2497
3520
|
}
|
|
2498
3521
|
session.models = { ...session.models, currentModelId: value };
|
|
2499
3522
|
// Recompute availableModes for the new model and clamp the current
|
|
@@ -2841,7 +3864,10 @@ export class ClaudeAcpAgent {
|
|
|
2841
3864
|
env: {
|
|
2842
3865
|
...process.env,
|
|
2843
3866
|
...userProvidedOptions?.env,
|
|
2844
|
-
|
|
3867
|
+
// Client-managed LLM routing: `providers/set` config wins, else the
|
|
3868
|
+
// legacy gateway auth request. Baked into the query at creation, so it
|
|
3869
|
+
// only affects sessions started after the change (matching the RFD).
|
|
3870
|
+
...createEnvForProvider(this.resolveProviderConfig()),
|
|
2845
3871
|
// Opt-in to session state events like when the agent is idle
|
|
2846
3872
|
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
|
2847
3873
|
},
|
|
@@ -3091,6 +4117,22 @@ export class ClaudeAcpAgent {
|
|
|
3091
4117
|
effortLevel: initialEffort.currentValue,
|
|
3092
4118
|
});
|
|
3093
4119
|
}
|
|
4120
|
+
// Seed the context window from the SDK's authoritative report. Text
|
|
4121
|
+
// inference alone misses aliases that resolve to extended-context models
|
|
4122
|
+
// with no "1m" token anywhere in their id or description (e.g. `sonnet` →
|
|
4123
|
+
// claude-sonnet-5, natively ~1M): those streamed `usage_update.size:
|
|
4124
|
+
// 200000` until the first result's modelUsage corrected it — again on
|
|
4125
|
+
// every process restart or session re-creation, since the learned window
|
|
4126
|
+
// lives only on the Session (issue #596).
|
|
4127
|
+
//
|
|
4128
|
+
// The inference fallback is deliberately keyed to the allowlisted entry: a
|
|
4129
|
+
// fallback-resolved sibling's resolvedModel/displayName/description can
|
|
4130
|
+
// describe a different context lane than the verbatim live id (e.g. an
|
|
4131
|
+
// "opus[1m]" row matched for a bare 200k id), so on the fallback path only
|
|
4132
|
+
// the id itself is a trustworthy window signal.
|
|
4133
|
+
const contextWindowSize = (await fetchContextWindowSize(q, this.logger)) ??
|
|
4134
|
+
inferContextWindowFromModel(models.currentModelId, allowlistedModelInfo?.resolvedModel, allowlistedModelInfo?.displayName, allowlistedModelInfo?.description) ??
|
|
4135
|
+
DEFAULT_CONTEXT_WINDOW;
|
|
3094
4136
|
this.sessions[sessionId] = {
|
|
3095
4137
|
query: q,
|
|
3096
4138
|
input: input,
|
|
@@ -3113,16 +4155,13 @@ export class ClaudeAcpAgent {
|
|
|
3113
4155
|
fastModeEnabled,
|
|
3114
4156
|
abortController,
|
|
3115
4157
|
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
3116
|
-
contextWindowSize
|
|
3117
|
-
// Deliberately keyed to the allowlisted entry: a fallback-resolved
|
|
3118
|
-
// sibling's displayName/description can describe a different context
|
|
3119
|
-
// lane than the verbatim live id (e.g. an "opus[1m]" row matched for
|
|
3120
|
-
// a bare 200k id), so on the fallback path only the id itself is a
|
|
3121
|
-
// trustworthy window signal.
|
|
3122
|
-
inferContextWindowFromModel(models.currentModelId, allowlistedModelInfo?.displayName, allowlistedModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
|
|
4158
|
+
contextWindowSize,
|
|
3123
4159
|
taskState,
|
|
3124
4160
|
toolUseCache: {},
|
|
3125
4161
|
emittedToolCalls: new Set(),
|
|
4162
|
+
liveBackgroundTasks: new Map(),
|
|
4163
|
+
emittedAssistantText: false,
|
|
4164
|
+
owedTrailingIdles: 0,
|
|
3126
4165
|
messageIdToUuid: new Map(),
|
|
3127
4166
|
};
|
|
3128
4167
|
return {
|
|
@@ -3192,27 +4231,76 @@ function snapshotFromUsage(usage) {
|
|
|
3192
4231
|
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
|
3193
4232
|
};
|
|
3194
4233
|
}
|
|
3195
|
-
|
|
4234
|
+
/**
|
|
4235
|
+
* Adapt a legacy gateway `authenticate` request into the shared
|
|
4236
|
+
* {@link ProviderConfig} shape. Returns `null` when no gateway request is
|
|
4237
|
+
* present. `methodId` selects the protocol: `gateway-bedrock` → bedrock,
|
|
4238
|
+
* otherwise anthropic.
|
|
4239
|
+
*/
|
|
4240
|
+
function gatewayRequestToProviderConfig(request) {
|
|
3196
4241
|
if (!request?._meta) {
|
|
4242
|
+
return null;
|
|
4243
|
+
}
|
|
4244
|
+
return {
|
|
4245
|
+
apiType: request.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
|
|
4246
|
+
baseUrl: request._meta.gateway.baseUrl,
|
|
4247
|
+
headers: request._meta.gateway.headers,
|
|
4248
|
+
};
|
|
4249
|
+
}
|
|
4250
|
+
/**
|
|
4251
|
+
* Map a resolved provider config into the Claude Code env vars that redirect API
|
|
4252
|
+
* traffic and inject headers. Returns an empty object when routing is
|
|
4253
|
+
* unconfigured. The token/bypass placeholders (`" "`) are required so the CLI
|
|
4254
|
+
* skips its normal login/credential checks when a gateway is in use.
|
|
4255
|
+
*/
|
|
4256
|
+
function createEnvForProvider(config) {
|
|
4257
|
+
if (!config) {
|
|
3197
4258
|
return {};
|
|
3198
4259
|
}
|
|
3199
|
-
const customHeaders = Object.entries(
|
|
4260
|
+
const customHeaders = Object.entries(config.headers)
|
|
3200
4261
|
.map(([key, value]) => `${key}: ${value}`)
|
|
3201
4262
|
.join("\n");
|
|
3202
|
-
if (
|
|
4263
|
+
if (config.apiType === "bedrock") {
|
|
3203
4264
|
return {
|
|
3204
4265
|
CLAUDE_CODE_USE_BEDROCK: "1",
|
|
3205
4266
|
AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
|
|
3206
|
-
ANTHROPIC_BEDROCK_BASE_URL:
|
|
4267
|
+
ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
|
|
4268
|
+
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
4269
|
+
};
|
|
4270
|
+
}
|
|
4271
|
+
if (config.apiType === "vertex") {
|
|
4272
|
+
// `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
|
|
4273
|
+
// validation; fall back to empty strings defensively.
|
|
4274
|
+
return {
|
|
4275
|
+
CLAUDE_CODE_USE_VERTEX: "1",
|
|
4276
|
+
ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
|
|
4277
|
+
ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
|
|
4278
|
+
CLOUD_ML_REGION: config.vertex?.region ?? "",
|
|
3207
4279
|
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
3208
4280
|
};
|
|
3209
4281
|
}
|
|
3210
4282
|
return {
|
|
3211
|
-
ANTHROPIC_BASE_URL:
|
|
4283
|
+
ANTHROPIC_BASE_URL: config.baseUrl,
|
|
3212
4284
|
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
|
3213
4285
|
ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
|
|
3214
4286
|
};
|
|
3215
4287
|
}
|
|
4288
|
+
/**
|
|
4289
|
+
* Validate a provider base URL: must be a non-empty absolute http(s) URL.
|
|
4290
|
+
*/
|
|
4291
|
+
function isValidBaseUrl(baseUrl) {
|
|
4292
|
+
if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
|
|
4293
|
+
return false;
|
|
4294
|
+
}
|
|
4295
|
+
let parsed;
|
|
4296
|
+
try {
|
|
4297
|
+
parsed = new URL(baseUrl);
|
|
4298
|
+
}
|
|
4299
|
+
catch {
|
|
4300
|
+
return false;
|
|
4301
|
+
}
|
|
4302
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
4303
|
+
}
|
|
3216
4304
|
/**
|
|
3217
4305
|
* Build the list of permission modes the agent will advertise for the given
|
|
3218
4306
|
* model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
|
|
@@ -3974,10 +5062,12 @@ function isTaskTool(toolName) {
|
|
|
3974
5062
|
toolName === "TaskList" ||
|
|
3975
5063
|
toolName === "TaskGet");
|
|
3976
5064
|
}
|
|
3977
|
-
/** Whether
|
|
5065
|
+
/** Whether the streamed tool_use path surfaces this tool as a standalone
|
|
3978
5066
|
* `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
|
|
3979
5067
|
* suppressed (their plan snapshot is emitted at tool_result time), so neither
|
|
3980
|
-
* produces a tool_call
|
|
5068
|
+
* produces a streamed tool_call/tool_call_update — which means a
|
|
5069
|
+
* permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
|
|
5070
|
+
* resolved explicitly at tool_result time. */
|
|
3981
5071
|
function shouldEmitToolCall(toolName) {
|
|
3982
5072
|
return toolName !== "TodoWrite" && !isTaskTool(toolName);
|
|
3983
5073
|
}
|
|
@@ -4012,6 +5102,28 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
|
|
|
4012
5102
|
...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
|
|
4013
5103
|
};
|
|
4014
5104
|
}
|
|
5105
|
+
/** Refine a pending tool call from the complete top-level fields recovered
|
|
5106
|
+
* from its still-streaming input. Shares `toolInfoFromToolUse` with the
|
|
5107
|
+
* consolidated path but never carries `content`: content built from partial
|
|
5108
|
+
* input is misleading (an Edit missing its `new_string` renders as a pure
|
|
5109
|
+
* deletion) or invalid (a Write diff without `content` lacks the required
|
|
5110
|
+
* `newText`), and the consolidated message supplies it moments later. */
|
|
5111
|
+
function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
|
|
5112
|
+
// TodoWrite/Task* never surfaced a tool_call to refine (plan lane).
|
|
5113
|
+
if (!shouldEmitToolCall(toolUse.name)) {
|
|
5114
|
+
return undefined;
|
|
5115
|
+
}
|
|
5116
|
+
const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
|
|
5117
|
+
return {
|
|
5118
|
+
_meta: { claudeCode: { toolName: toolUse.name } },
|
|
5119
|
+
toolCallId: toolUse.id,
|
|
5120
|
+
sessionUpdate: "tool_call_update",
|
|
5121
|
+
rawInput: input,
|
|
5122
|
+
title,
|
|
5123
|
+
kind,
|
|
5124
|
+
...(locations ? { locations } : {}),
|
|
5125
|
+
};
|
|
5126
|
+
}
|
|
4015
5127
|
/**
|
|
4016
5128
|
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
4017
5129
|
* Only handles text, image, and thinking chunks for now.
|
|
@@ -4043,6 +5155,15 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
4043
5155
|
}
|
|
4044
5156
|
return [{ sessionId, update }];
|
|
4045
5157
|
}
|
|
5158
|
+
// `tool_use_result` is message-level and carries no tool_use_id of its own:
|
|
5159
|
+
// it describes "the" tool_result block of the message it rode in on. If
|
|
5160
|
+
// several tool_result blocks were ever batched into one message it couldn't
|
|
5161
|
+
// be attributed, so it is only honored when the message carries exactly one.
|
|
5162
|
+
const toolUseResult = options?.toolUseResult !== undefined &&
|
|
5163
|
+
content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result")
|
|
5164
|
+
.length === 1
|
|
5165
|
+
? options.toolUseResult
|
|
5166
|
+
: undefined;
|
|
4046
5167
|
const output = [];
|
|
4047
5168
|
// Only handle the first chunk for streaming; extend as needed for batching
|
|
4048
5169
|
for (const chunk of content) {
|
|
@@ -4184,12 +5305,59 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
4184
5305
|
case "bash_code_execution_tool_result":
|
|
4185
5306
|
case "text_editor_code_execution_tool_result":
|
|
4186
5307
|
case "mcp_tool_result": {
|
|
5308
|
+
const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
|
|
4187
5309
|
options?.emittedToolCalls?.delete(chunk.tool_use_id);
|
|
4188
5310
|
const toolUse = toolUseCache[chunk.tool_use_id];
|
|
4189
5311
|
if (!toolUse) {
|
|
5312
|
+
// The permission flow may have surfaced this tool_call even though
|
|
5313
|
+
// its tool_use never reached the cache (e.g. the assistant message
|
|
5314
|
+
// carrying it was dropped by the cancelled-turn guard and a straggler
|
|
5315
|
+
// result landed later). Resolve the surfaced call anyway so it can't
|
|
5316
|
+
// stay pending in the client forever; without the cache entry the
|
|
5317
|
+
// tool name is unknown, so no claudeCode meta is attached.
|
|
5318
|
+
if (wasEmitted) {
|
|
5319
|
+
output.push({
|
|
5320
|
+
sessionId,
|
|
5321
|
+
update: {
|
|
5322
|
+
toolCallId: chunk.tool_use_id,
|
|
5323
|
+
sessionUpdate: "tool_call_update",
|
|
5324
|
+
status: "is_error" in chunk && chunk.is_error
|
|
5325
|
+
? "failed"
|
|
5326
|
+
: "completed",
|
|
5327
|
+
rawOutput: chunk.content,
|
|
5328
|
+
},
|
|
5329
|
+
});
|
|
5330
|
+
}
|
|
4190
5331
|
logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
|
|
4191
5332
|
break;
|
|
4192
5333
|
}
|
|
5334
|
+
// A permission request may have surfaced a plan-rendered (TodoWrite) or
|
|
5335
|
+
// suppressed (Task*) tool as a real tool_call so the request referenced
|
|
5336
|
+
// a tool call the client knows about (see `ensureToolCallEmitted`,
|
|
5337
|
+
// issue #851). The branches below never emit a tool_call_update for
|
|
5338
|
+
// those tools, which would leave the surfaced call pending in the
|
|
5339
|
+
// client forever — resolve it here. `wasEmitted` is only ever true for
|
|
5340
|
+
// these tools via the permission flow: the streamed plan/suppressed
|
|
5341
|
+
// branches don't record emissions.
|
|
5342
|
+
if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
|
|
5343
|
+
output.push({
|
|
5344
|
+
sessionId,
|
|
5345
|
+
update: {
|
|
5346
|
+
_meta: {
|
|
5347
|
+
claudeCode: {
|
|
5348
|
+
toolName: toolUse.name,
|
|
5349
|
+
...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
|
|
5350
|
+
},
|
|
5351
|
+
},
|
|
5352
|
+
toolCallId: chunk.tool_use_id,
|
|
5353
|
+
sessionUpdate: "tool_call_update",
|
|
5354
|
+
status: "is_error" in chunk && chunk.is_error
|
|
5355
|
+
? "failed"
|
|
5356
|
+
: "completed",
|
|
5357
|
+
rawOutput: chunk.content,
|
|
5358
|
+
},
|
|
5359
|
+
});
|
|
5360
|
+
}
|
|
4193
5361
|
if (isTaskTool(toolUse.name)) {
|
|
4194
5362
|
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
|
|
4195
5363
|
// TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
|
|
@@ -4213,7 +5381,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
4213
5381
|
}
|
|
4214
5382
|
}
|
|
4215
5383
|
else if (toolUse.name !== "TodoWrite") {
|
|
4216
|
-
const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
|
|
5384
|
+
const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput, toolUseResult);
|
|
4217
5385
|
// When terminal output is supported, send terminal_output as a
|
|
4218
5386
|
// separate notification to match codex-acp's streaming lifecycle:
|
|
4219
5387
|
// 1. tool_call → _meta.terminal_info (already sent above)
|
|
@@ -4290,35 +5458,105 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
4290
5458
|
}
|
|
4291
5459
|
export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
|
|
4292
5460
|
const event = message.event;
|
|
5461
|
+
const streamKey = message.parent_tool_use_id ?? "";
|
|
5462
|
+
const streamedToolInputs = options?.streamedToolInputs;
|
|
5463
|
+
const forwardedOptions = {
|
|
5464
|
+
clientCapabilities: options?.clientCapabilities,
|
|
5465
|
+
parentToolUseId: message.parent_tool_use_id,
|
|
5466
|
+
cwd: options?.cwd,
|
|
5467
|
+
taskState: options?.taskState,
|
|
5468
|
+
emittedToolCalls: options?.emittedToolCalls,
|
|
5469
|
+
messageId: options?.messageId,
|
|
5470
|
+
};
|
|
4293
5471
|
switch (event.type) {
|
|
4294
|
-
case "content_block_start":
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
5472
|
+
case "content_block_start": {
|
|
5473
|
+
const block = event.content_block;
|
|
5474
|
+
if (streamedToolInputs &&
|
|
5475
|
+
(block.type === "tool_use" ||
|
|
5476
|
+
block.type === "server_tool_use" ||
|
|
5477
|
+
block.type === "mcp_tool_use")) {
|
|
5478
|
+
let inputsForMessage = streamedToolInputs.get(streamKey);
|
|
5479
|
+
if (!inputsForMessage) {
|
|
5480
|
+
inputsForMessage = new Map();
|
|
5481
|
+
streamedToolInputs.set(streamKey, inputsForMessage);
|
|
5482
|
+
}
|
|
5483
|
+
inputsForMessage.set(event.index, {
|
|
5484
|
+
id: block.id,
|
|
5485
|
+
name: block.name,
|
|
5486
|
+
partialJson: "",
|
|
5487
|
+
scannedTo: 0,
|
|
5488
|
+
inString: false,
|
|
5489
|
+
escaped: false,
|
|
5490
|
+
objectDepth: 0,
|
|
5491
|
+
arrayDepth: 0,
|
|
5492
|
+
lastTopLevelComma: -1,
|
|
5493
|
+
emittedThroughComma: -1,
|
|
5494
|
+
});
|
|
5495
|
+
}
|
|
5496
|
+
return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
|
|
5497
|
+
}
|
|
5498
|
+
case "content_block_delta": {
|
|
5499
|
+
if (event.delta.type === "input_json_delta") {
|
|
5500
|
+
const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
|
|
5501
|
+
if (!streamedInput)
|
|
5502
|
+
return [];
|
|
5503
|
+
streamedInput.partialJson += event.delta.partial_json;
|
|
5504
|
+
if (scanStreamedToolInput(streamedInput)) {
|
|
5505
|
+
// Input complete: the consolidated assistant message replays the
|
|
5506
|
+
// block with its full input and refines the call there; emitting
|
|
5507
|
+
// here too would send a duplicate identical update.
|
|
5508
|
+
const inputsForMessage = streamedToolInputs?.get(streamKey);
|
|
5509
|
+
inputsForMessage?.delete(event.index);
|
|
5510
|
+
if (inputsForMessage?.size === 0)
|
|
5511
|
+
streamedToolInputs?.delete(streamKey);
|
|
5512
|
+
return [];
|
|
5513
|
+
}
|
|
5514
|
+
if (streamedInput.lastTopLevelComma <= streamedInput.emittedThroughComma) {
|
|
5515
|
+
return [];
|
|
5516
|
+
}
|
|
5517
|
+
streamedInput.emittedThroughComma = streamedInput.lastTopLevelComma;
|
|
5518
|
+
const input = recoveredToolInput(streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma));
|
|
5519
|
+
if (!input)
|
|
5520
|
+
return [];
|
|
5521
|
+
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
5522
|
+
const update = streamedInputRefinement(streamedInput, input, supportsTerminalOutput, options?.cwd);
|
|
5523
|
+
if (!update)
|
|
5524
|
+
return [];
|
|
5525
|
+
if (message.parent_tool_use_id) {
|
|
5526
|
+
update._meta = {
|
|
5527
|
+
...update._meta,
|
|
5528
|
+
claudeCode: {
|
|
5529
|
+
...(update._meta?.claudeCode || {}),
|
|
5530
|
+
parentToolUseId: message.parent_tool_use_id,
|
|
5531
|
+
},
|
|
5532
|
+
};
|
|
5533
|
+
}
|
|
5534
|
+
applyMessageId(update, options?.messageId);
|
|
5535
|
+
return [{ sessionId, update }];
|
|
5536
|
+
}
|
|
5537
|
+
return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
|
|
5538
|
+
}
|
|
4312
5539
|
// No content. `ping` is a Messages-API keep-alive event that the SDK's
|
|
4313
5540
|
// `BetaRawMessageStreamEvent` union doesn't include even though the
|
|
4314
5541
|
// wire format emits it; the `as never` cast lets us no-op it here
|
|
4315
5542
|
// instead of letting it fall through to `unreachable`.
|
|
4316
5543
|
case "ping":
|
|
4317
|
-
case "message_start":
|
|
4318
5544
|
case "message_delta":
|
|
5545
|
+
return [];
|
|
5546
|
+
// A message boundary ends every input stream on this lane: message_stop is
|
|
5547
|
+
// the normal end, and a message_start clears anything a prior message on
|
|
5548
|
+
// the lane left behind (e.g. a stream cut short mid-block).
|
|
5549
|
+
case "message_start":
|
|
4319
5550
|
case "message_stop":
|
|
4320
|
-
|
|
5551
|
+
streamedToolInputs?.delete(streamKey);
|
|
5552
|
+
return [];
|
|
5553
|
+
case "content_block_stop": {
|
|
5554
|
+
const inputsForMessage = streamedToolInputs?.get(streamKey);
|
|
5555
|
+
inputsForMessage?.delete(event.index);
|
|
5556
|
+
if (inputsForMessage?.size === 0)
|
|
5557
|
+
streamedToolInputs?.delete(streamKey);
|
|
4321
5558
|
return [];
|
|
5559
|
+
}
|
|
4322
5560
|
default:
|
|
4323
5561
|
unreachable(event, logger);
|
|
4324
5562
|
return [];
|
|
@@ -4376,6 +5614,9 @@ export function runAcp() {
|
|
|
4376
5614
|
.onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
|
|
4377
5615
|
.onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
|
|
4378
5616
|
.onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
|
|
5617
|
+
.onRequest(methods.agent.providers.list, (ctx) => agent.unstable_listProviders(ctx.params))
|
|
5618
|
+
.onRequest(methods.agent.providers.set, (ctx) => agent.unstable_setProvider(ctx.params))
|
|
5619
|
+
.onRequest(methods.agent.providers.disable, (ctx) => agent.unstable_disableProvider(ctx.params))
|
|
4379
5620
|
.onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
|
|
4380
5621
|
.onRequest(methods.agent.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal))
|
|
4381
5622
|
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
|
|
@@ -4397,12 +5638,14 @@ function commonPrefixLength(a, b) {
|
|
|
4397
5638
|
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
|
4398
5639
|
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
|
4399
5640
|
* matching things like "10m" or embedded substrings. Semantic aliases like
|
|
4400
|
-
* `default` carry no such token in the ID, but
|
|
4401
|
-
* `displayName`/`description`
|
|
4402
|
-
*
|
|
4403
|
-
*
|
|
4404
|
-
*
|
|
4405
|
-
*
|
|
5641
|
+
* `default` carry no such token in the ID, but their `resolvedModel` and the
|
|
5642
|
+
* SDK's human-facing `displayName`/`description` can (e.g.
|
|
5643
|
+
* "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
|
|
5644
|
+
* This text scan can't catch every model — some resolve to extended-context
|
|
5645
|
+
* models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
|
|
5646
|
+
* ~1M) — which is why `fetchContextWindowSize` is preferred wherever a live
|
|
5647
|
+
* query is available. A miss falls back to the default window and is
|
|
5648
|
+
* corrected by `result.modelUsage` within one turn. */
|
|
4406
5649
|
function inferContextWindowFromModel(...texts) {
|
|
4407
5650
|
if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
|
|
4408
5651
|
return 1_000_000;
|
|
@@ -4412,12 +5655,7 @@ function inferContextWindowFromModel(...texts) {
|
|
|
4412
5655
|
* `getContextUsage` control request. Unlike the per-message API usage numbers
|
|
4413
5656
|
* (which only count message tokens), this `totalTokens` includes the system
|
|
4414
5657
|
* prompt, tool schemas, MCP tools, and memory-file overhead — the real
|
|
4415
|
-
* occupancy the user sees. Returns `null` on any control-request failure.
|
|
4416
|
-
*
|
|
4417
|
-
* Note: we deliberately do NOT use this response's window fields for `size`.
|
|
4418
|
-
* They have been observed to under-report extended (1M) context windows, so
|
|
4419
|
-
* the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
|
|
4420
|
-
* which handle the 1M variants correctly. */
|
|
5658
|
+
* occupancy the user sees. Returns `null` on any control-request failure. */
|
|
4421
5659
|
async function fetchContextUsedTokens(query, logger) {
|
|
4422
5660
|
try {
|
|
4423
5661
|
const usage = await query.getContextUsage();
|
|
@@ -4428,6 +5666,28 @@ async function fetchContextUsedTokens(query, logger) {
|
|
|
4428
5666
|
return null;
|
|
4429
5667
|
}
|
|
4430
5668
|
}
|
|
5669
|
+
/** Fetch the current model's full context window (`rawMaxTokens`) via the
|
|
5670
|
+
* `getContextUsage` control request — the same source `/context` prints.
|
|
5671
|
+
* This is the only pre-`result` signal that covers semantic aliases whose
|
|
5672
|
+
* text carries no "1m" token (e.g. `sonnet` → claude-sonnet-5, natively
|
|
5673
|
+
* ~1M), so it's the primary window source at session creation and on model
|
|
5674
|
+
* switches; `inferContextWindowFromModel` remains the fallback. A returned
|
|
5675
|
+
* window is still superseded by each `result.modelUsage.contextWindow`.
|
|
5676
|
+
*
|
|
5677
|
+
* (Older CLIs under-reported extended 1M windows here — commit 20ef663
|
|
5678
|
+
* dropped the field for that reason — but the CLI vendored by the pinned
|
|
5679
|
+
* SDK reports them correctly again.) Returns `null` on any control-request
|
|
5680
|
+
* failure or a nonsensical (non-positive) window. */
|
|
5681
|
+
async function fetchContextWindowSize(query, logger) {
|
|
5682
|
+
try {
|
|
5683
|
+
const usage = await query.getContextUsage();
|
|
5684
|
+
return usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null;
|
|
5685
|
+
}
|
|
5686
|
+
catch (error) {
|
|
5687
|
+
logger.error("Failed to fetch context window size from SDK:", error);
|
|
5688
|
+
return null;
|
|
5689
|
+
}
|
|
5690
|
+
}
|
|
4431
5691
|
/** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
|
|
4432
5692
|
* option. The `maxThinkingTokens` option it used to feed is deprecated and
|
|
4433
5693
|
* reduced to on/off on current models, so map the value to explicit thinking
|