@agentclientprotocol/claude-agent-acp 0.57.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acp-agent.js CHANGED
@@ -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,67 @@ 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
+ * Advance the lexer state across the fragment appended since the last delta:
102
+ * just enough JSON awareness (string/escape, nesting depth) to spot commas
103
+ * that sit at the top level of the input object — everything before such a
104
+ * comma is a set of complete fields. Returns true once the input object's
105
+ * closing brace arrives.
106
+ */
107
+ function scanStreamedToolInput(state) {
108
+ let complete = false;
109
+ for (let index = state.scannedTo; index < state.partialJson.length; index++) {
110
+ const character = state.partialJson[index];
111
+ if (state.inString) {
112
+ if (state.escaped) {
113
+ state.escaped = false;
114
+ }
115
+ else if (character === "\\") {
116
+ state.escaped = true;
117
+ }
118
+ else if (character === '"') {
119
+ state.inString = false;
120
+ }
121
+ continue;
122
+ }
123
+ if (character === '"') {
124
+ state.inString = true;
125
+ }
126
+ else if (character === "{") {
127
+ state.objectDepth++;
128
+ }
129
+ else if (character === "}") {
130
+ state.objectDepth--;
131
+ if (state.objectDepth === 0) {
132
+ complete = true;
133
+ }
134
+ }
135
+ else if (character === "[") {
136
+ state.arrayDepth++;
137
+ }
138
+ else if (character === "]") {
139
+ state.arrayDepth--;
140
+ }
141
+ else if (character === "," && state.objectDepth === 1 && state.arrayDepth === 0) {
142
+ state.lastTopLevelComma = index;
143
+ }
144
+ }
145
+ state.scannedTo = state.partialJson.length;
146
+ return complete;
147
+ }
148
+ /** Parse the complete top-level fields before a top-level comma by closing the
149
+ * object at that boundary. */
150
+ function recoveredToolInput(prefix) {
151
+ try {
152
+ const value = JSON.parse(prefix + "}");
153
+ return value !== null && typeof value === "object" && !Array.isArray(value)
154
+ ? value
155
+ : undefined;
156
+ }
157
+ catch {
158
+ return undefined;
159
+ }
160
+ }
59
161
  export async function claudeCliPath() {
60
162
  if (process.env.CLAUDE_CODE_EXECUTABLE) {
61
163
  return process.env.CLAUDE_CODE_EXECUTABLE;
@@ -188,6 +290,36 @@ export function stripLocalCommandMetadata(content) {
188
290
  export function isLocalCommandMetadata(content) {
189
291
  return stripLocalCommandMetadata(content) === null;
190
292
  }
293
+ /**
294
+ * True for the synthetic assistant message the CLI injects into the transcript
295
+ * when a turn fails authentication (e.g. "Not logged in · Please run /login",
296
+ * "Session expired. Please run /login to sign in again."). The `/login`
297
+ * instruction is Claude Code TUI-specific and meaningless to ACP clients
298
+ * (issue #863). The live prompt loop suppresses the text and fails the turn
299
+ * with `authRequired` so the client can run its own auth flow; replay must
300
+ * skip it too — both for parity with what the client saw live and because the
301
+ * message stays in the transcript forever, so it would resurface on every
302
+ * session/load even after the user has logged back in.
303
+ *
304
+ * Takes the API message (`message.message`), which replay only knows as
305
+ * `unknown`. The persisted record's structured `error: "authentication_failed"`
306
+ * marker is stripped by `getSessionMessages`, so the synthetic model + text is
307
+ * all both paths have to match on.
308
+ */
309
+ export function isSyntheticLoginMessage(apiMessage) {
310
+ if (!apiMessage || typeof apiMessage !== "object") {
311
+ return false;
312
+ }
313
+ const { model, content } = apiMessage;
314
+ if (model !== "<synthetic>" || !Array.isArray(content) || content.length !== 1) {
315
+ return false;
316
+ }
317
+ const block = content[0];
318
+ return (!!block &&
319
+ block.type === "text" &&
320
+ typeof block.text === "string" &&
321
+ block.text.includes("Please run /login"));
322
+ }
191
323
  const PERMISSION_MODE_ALIASES = {
192
324
  auto: "auto",
193
325
  default: "default",
@@ -666,23 +798,34 @@ export class ClaudeAcpAgent {
666
798
  // gateways that don't carry a stable/matching id across the stream and the
667
799
  // consolidated message. Reset after each consolidated message consumes it.
668
800
  const streamedBlocks = [];
801
+ // Tool-use blocks start streaming before their JSON input. Keep the
802
+ // partial input per parent message and block index so completed top-level
803
+ // fields can refine the pending tool call while it streams. Entries are
804
+ // dropped at block/message boundaries; the whole map is swept when a turn
805
+ // settles, since an interrupted subagent stream (keyed by a
806
+ // parent_tool_use_id that never recurs) has no boundary event of its own.
807
+ const streamedToolInputs = new Map();
669
808
  // Stop reason accumulated for the active turn (result subtype, refusal,
670
809
  // max_tokens, …). Reset per turn; read when the turn settles at idle.
671
810
  let stopReason = "end_turn";
672
- // How many trailing `session_state_changed: idle` messages are already
673
- // accounted for: every user-turn result that terminates a turn (settle,
674
- // reject, or orphan skip) is followed by one, as is a cancelled turn
675
- // settled by the next turn's echo hand-off. The idle handler absorbs owed
676
- // idles; an idle that arrives when NONE is owed while the active turn is
677
- // still unsettled means the SDK ended the turn without ever emitting its
678
- // result, so the turn will never settle on its own (issue #825).
679
- // Stream-level debt, deliberately NOT reset per turn: a lagged idle can
680
- // arrive after the next turn has already activated (issue #773), and the
681
- // debt is what attributes it to the turn that owed it. Over-counting (an
682
- // idle the SDK never emits, e.g. CLI binaries without session-state
683
- // events — issue #497) is benign: the counter just absorbs one future
684
- // idle, and detection degrades to the status quo rather than misfiring.
685
- let owedTrailingIdles = 0;
811
+ /** The consumer's single send chokepoint: every `sessionUpdate` in this
812
+ * loop goes through here (never `this.client.sessionUpdate` directly) so
813
+ * answer-delivery tracking is a property of sending, not something each
814
+ * emission site must remember. A top-level `agent_message_chunk` marks
815
+ * the stretch's answer as delivered; subagent-attributed chunks are
816
+ * recognizable by the `parentToolUseId` meta that toAcpNotifications
817
+ * stamps from `parent_tool_use_id`, and never reach the top-level feed
818
+ * as the turn's answer. */
819
+ const sendUpdate = async (notification) => {
820
+ const { update } = notification;
821
+ if (update.sessionUpdate === "agent_message_chunk") {
822
+ const claudeMeta = update._meta?.claudeCode;
823
+ if (!claudeMeta?.parentToolUseId) {
824
+ session.emittedAssistantText = true;
825
+ }
826
+ }
827
+ await this.client.sessionUpdate(notification);
828
+ };
686
829
  const resetTurnScratch = () => {
687
830
  lastAssistantTotalUsage = null;
688
831
  lastAssistantUsage = null;
@@ -718,6 +861,31 @@ export class ClaudeAcpAgent {
718
861
  session.activeTurn = turn;
719
862
  session.cancelled = false;
720
863
  session.pendingOrphanResults = 0;
864
+ session.orphanCommands?.clear();
865
+ // Two-phase sweep of registry entries the level signal ended (see
866
+ // the endedPerLevel field doc): armed at the first activation,
867
+ // deleted at the second — the same activation-time self-heal as the
868
+ // orphan lanes, and the growth bound for leaked entries whose settle
869
+ // bookends never arrive. The one-activation grace lets a corrective
870
+ // inclusive level rescue a live async agent that a racing payload
871
+ // absent-marked (deletion is irreversible: levels never ADD entries).
872
+ // Local-only commands don't advance the clock: two quick /context
873
+ // calls would otherwise burn the whole grace in seconds of wall time
874
+ // while the corrective level is still in flight, and they interact
875
+ // with no tasks — a later real turn still bounds growth.
876
+ if (!turn.isLocalOnlyCommand) {
877
+ for (const [taskId, record] of session.liveBackgroundTasks) {
878
+ if (!record.endedPerLevel) {
879
+ continue;
880
+ }
881
+ if (record.endedPerLevel === "sweep-armed") {
882
+ session.liveBackgroundTasks.delete(taskId);
883
+ }
884
+ else {
885
+ record.endedPerLevel = "sweep-armed";
886
+ }
887
+ }
888
+ }
721
889
  resetTurnScratch();
722
890
  };
723
891
  /** Ensure there is an active turn before a user-turn result that carries no
@@ -737,18 +905,165 @@ export class ClaudeAcpAgent {
737
905
  * result), so we skip those and only promote once the count is drained. */
738
906
  const ensureActiveTurn = () => {
739
907
  if (session.activeTurn) {
740
- return;
741
- }
742
- const head = (session.turnQueue ?? []).find((t) => !t.settled);
743
- if (!head) {
744
- return;
908
+ if (!isHeldOpen(session.activeTurn)) {
909
+ return;
910
+ }
911
+ // A held turn (Turn.deferredSettle) already produced its result, so
912
+ // this incoming user-turn result cannot be its — it belongs to the
913
+ // next queued command (an echo-less one, e.g. `/context` sent while
914
+ // the hold drains; a normal prompt's echo would have handed the held
915
+ // turn off before its result). Settle the held turn with its
916
+ // recorded outcome — the user moving on outranks the hold, same
917
+ // contract as the echo hand-off — and fall through to promote the
918
+ // queue head, which this result belongs to. Without this, the head
919
+ // would never be promoted (echo-less turns have no other promotion
920
+ // path) and its prompt would hang, while this result's outcome
921
+ // overwrote the held turn's. Orphan lanes below are necessarily
922
+ // empty while a turn is held: orphans are seeded by cancel(), which
923
+ // inline-settles a held turn, and activation cleared older ones.
924
+ // settleActive also closes the held turn's delivery stretch, so the
925
+ // promoted command's own delivery decision is not judged against the
926
+ // held turn's followup text (issue #453) — the caller snapshots the
927
+ // flag AFTER this runs.
928
+ settleActive(session.activeTurn.deferredSettle);
745
929
  }
930
+ // Orphan accounting runs BEFORE the head check: an orphan's echo-less
931
+ // result can arrive with an EMPTY queue (the common post-cancel
932
+ // timeline — the active turn settled at the interrupt's idle and the
933
+ // user hasn't typed yet), and it must still be consumed here. Skipping
934
+ // the bookkeeping when there is nothing to promote would leave a
935
+ // phantom entry/count that swallows the next live echo-less result
936
+ // (e.g. /compact) instead.
746
937
  if ((session.pendingOrphanResults ?? 0) > 0) {
747
938
  session.pendingOrphanResults--;
748
939
  return;
749
940
  }
941
+ // msg_lifecycle_v1 lane. Attribute this echo-less result using the
942
+ // entries' states — turns run sequentially and frames arrive in stream
943
+ // order, so at any result: every "zombie" is from an already-dead turn
944
+ // whose own result already passed before the frame that created the
945
+ // newest entry (or never existed), every "started" entry was dispatched
946
+ // into THE turn that emitted this result (an older turn's entries got
947
+ // their terminal frames before a newer turn's "started" frames), and a
948
+ // "pending" entry was not dispatched before it. One result therefore
949
+ // covers ALL started and zombie entries at once (N coalesced commands
950
+ // share ONE result); their outstanding terminal frames then no-op on
951
+ // the missing entries. NOTE this ordering argument is asserted from
952
+ // observed CLI behavior, not a documented wire contract — if a dead
953
+ // turn's late result could lag past the NEXT turn's dispatch frames,
954
+ // deleting a zombie and a started entry on one result would
955
+ // double-consume it. The unexpected-transition logging in the frame
956
+ // handler is the tripwire for that class of drift.
957
+ if (session.orphanCommands?.size) {
958
+ let consumedOrphanResult = false;
959
+ let oldestPending;
960
+ for (const [uuid, state] of session.orphanCommands) {
961
+ if (state === "started" || state === "zombie") {
962
+ consumedOrphanResult = true;
963
+ session.orphanCommands.delete(uuid);
964
+ }
965
+ else {
966
+ oldestPending ??= uuid;
967
+ }
968
+ }
969
+ if (consumedOrphanResult) {
970
+ return;
971
+ }
972
+ if (oldestPending !== undefined) {
973
+ // No dispatch was seen before this result, so it is very likely a
974
+ // live turn's — but a lost "started" frame would mean it IS the
975
+ // orphan's (dup-over-loss: prefer one wrong skip over
976
+ // misattributing a dead turn's outcome to a live prompt). Grant
977
+ // each pending entry exactly one skip, like the count lane did.
978
+ session.orphanCommands.delete(oldestPending);
979
+ return;
980
+ }
981
+ }
982
+ const head = firstUnsettledQueuedTurn();
983
+ if (!head) {
984
+ return;
985
+ }
750
986
  activateTurn(head);
751
987
  };
988
+ /** Result-time bookkeeping that must run whether or not the result can be
989
+ * attributed to a turn. (1) Latch `commandResultSeen` on every queued
990
+ * turn whose command is known dispatched with no terminal frame yet —
991
+ * the emitting turn is the one it was dispatched (possibly folded) into,
992
+ * so its result has now passed; a later cancel() must not seed an orphan
993
+ * entry that waits for it (see Turn.commandResultSeen). (2) When a turn
994
+ * is ACTIVE, the result is attributed to it and never reaches
995
+ * ensureActiveTurn — but it still covers the map's started entries
996
+ * (commands folded into the active turn share its result) and zombies
997
+ * (their late results have already passed or never existed), so drain
998
+ * them here or they would zombify/linger and swallow a later live
999
+ * echo-less result. */
1000
+ const recordResultForOrphanCommands = () => {
1001
+ for (const turn of session.turnQueue ?? []) {
1002
+ if (!turn.settled && turn.commandStarted && !turn.commandFinished) {
1003
+ turn.commandResultSeen = true;
1004
+ }
1005
+ }
1006
+ if (session.activeTurn && session.orphanCommands?.size) {
1007
+ for (const [uuid, state] of session.orphanCommands) {
1008
+ if (state === "started" || state === "zombie") {
1009
+ session.orphanCommands.delete(uuid);
1010
+ }
1011
+ }
1012
+ }
1013
+ };
1014
+ /** The unsettled in-flight turn owning this prompt uuid, if any. */
1015
+ const findUnsettledTurn = (uuid) => (session.turnQueue ?? []).find((t) => t.promptUuid === uuid && !t.settled);
1016
+ /** The first queued turn still awaiting its outcome, if any — the single
1017
+ * spelling of "a prompt is pending" shared by the head promotion and
1018
+ * the autonomous stretch-close guard. */
1019
+ const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
1020
+ /** Whether any background subagent this turn spawned is still live —
1021
+ * while true, the turn's settlement stays deferred so the subagent's
1022
+ * output and permission requests land inside it (see
1023
+ * Turn.deferredSettle). */
1024
+ const turnAwaitingSubagents = (turn) => {
1025
+ if (!turn.spawnedTaskIds?.size) {
1026
+ return false;
1027
+ }
1028
+ for (const taskId of turn.spawnedTaskIds) {
1029
+ const record = session.liveBackgroundTasks.get(taskId);
1030
+ // The isSubagent read is defense in depth for the shells-never-defer
1031
+ // contract: spawnedTaskIds only ever holds subagent ids today, but a
1032
+ // future add site must not silently let a long-lived shell hold a
1033
+ // prompt open. endedPerLevel entries are kept for attribution only —
1034
+ // the level signal says the task is gone (or its bookends were
1035
+ // lost), so a hold must not wait on them.
1036
+ if (record?.isSubagent && !record.endedPerLevel) {
1037
+ return true;
1038
+ }
1039
+ }
1040
+ return false;
1041
+ };
1042
+ /** Settle the active turn's stored deferred outcome once none of its
1043
+ * spawned subagents is live. The single drain rule shared by the
1044
+ * followup-result and idle settle sites, so the two lanes can't drift. */
1045
+ const settleDeferredIfDrained = () => {
1046
+ const turn = session.activeTurn;
1047
+ if (isHeldOpen(turn) && !turnAwaitingSubagents(turn)) {
1048
+ settleActive(turn.deferredSettle);
1049
+ }
1050
+ };
1051
+ /** Settle the active turn with `outcome` now — unless subagents it
1052
+ * spawned are still live, in which case store the outcome and hold the
1053
+ * turn open (see Turn.deferredSettle). Every result-time settle of a
1054
+ * turn that can have spawned subagents must route through here: a site
1055
+ * calling settleActive directly bypasses the hold and re-opens the
1056
+ * out-of-turn permission deadlock (issue #866) through its lane. */
1057
+ const settleOrDefer = (outcome) => {
1058
+ if (session.activeTurn &&
1059
+ !session.activeTurn.settled &&
1060
+ turnAwaitingSubagents(session.activeTurn)) {
1061
+ session.activeTurn.deferredSettle = outcome;
1062
+ }
1063
+ else {
1064
+ settleActive(outcome);
1065
+ }
1066
+ };
752
1067
  /** Settle the active turn's deferred exactly once, disarm the force-cancel
753
1068
  * backstop (the turn is over), and drop it from the queue. */
754
1069
  const settleActive = (result) => {
@@ -756,22 +1071,34 @@ export class ClaudeAcpAgent {
756
1071
  if (!turn || turn.settled) {
757
1072
  return;
758
1073
  }
1074
+ // Captured before the settled flip below (isHeldOpen tests !settled).
1075
+ const wasHeld = isHeldOpen(turn);
759
1076
  turn.settled = true;
760
- if (session.forceCancelTimer) {
761
- clearTimeout(session.forceCancelTimer);
762
- session.forceCancelTimer = undefined;
763
- }
1077
+ disarmForceCancel(session);
764
1078
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
765
1079
  session.activeTurn = null;
1080
+ streamedToolInputs.clear();
1081
+ if (wasHeld) {
1082
+ // Settling a held turn is its delivery-stretch boundary: the turn's
1083
+ // answer finished long ago, so text streamed since the last boundary
1084
+ // is normally its followups' — left latched it would suppress a
1085
+ // following replayed turn's issue-#453 result-text fallback (the
1086
+ // common post-hold sequence). Known trade: at the echo hand-off an
1087
+ // incoming turn's pre-echo deltas share this one boolean, so a
1088
+ // STREAMING replay on a usage-omitting backend could re-emit its
1089
+ // answer — the flag cannot attribute text to a turn before its
1090
+ // echo, and the suppression direction is the common one, so the
1091
+ // clear wins. Every held-settle lane inherits this: the drain
1092
+ // settle, both hand-offs, and stream-done; cancel()'s inline mirror
1093
+ // carries its own copy.
1094
+ session.emittedAssistantText = false;
1095
+ }
766
1096
  turn.resolve(result);
767
1097
  };
768
1098
  /** Reject the active turn (auth required, error result, …) without tearing
769
1099
  * down the consumer: the stream continues to idle and later turns proceed. */
770
1100
  const failActive = (error) => {
771
- if (session.forceCancelTimer) {
772
- clearTimeout(session.forceCancelTimer);
773
- session.forceCancelTimer = undefined;
774
- }
1101
+ disarmForceCancel(session);
775
1102
  const turn = session.activeTurn;
776
1103
  if (!turn || turn.settled) {
777
1104
  return;
@@ -779,14 +1106,17 @@ export class ClaudeAcpAgent {
779
1106
  turn.settled = true;
780
1107
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
781
1108
  session.activeTurn = null;
1109
+ streamedToolInputs.clear();
1110
+ // A failed turn's stretch is over, and some failure lanes (the issue
1111
+ // #825 idle-fail) never see the result whose `finally` would close it —
1112
+ // start the next stretch clean, or its stale delivery record would
1113
+ // suppress the next turn's issue-#453 result-text fallback.
1114
+ session.emittedAssistantText = false;
782
1115
  turn.reject(error);
783
1116
  };
784
1117
  /** Reject every in-flight turn — used when the stream dies. */
785
1118
  const failAllTurns = (error) => {
786
- if (session.forceCancelTimer) {
787
- clearTimeout(session.forceCancelTimer);
788
- session.forceCancelTimer = undefined;
789
- }
1119
+ disarmForceCancel(session);
790
1120
  const turns = session.activeTurn
791
1121
  ? [session.activeTurn, ...(session.turnQueue ?? []).filter((t) => t !== session.activeTurn)]
792
1122
  : [...(session.turnQueue ?? [])];
@@ -794,8 +1124,18 @@ export class ClaudeAcpAgent {
794
1124
  session.turnQueue = [];
795
1125
  for (const turn of turns) {
796
1126
  if (!turn.settled) {
1127
+ const wasHeld = isHeldOpen(turn);
797
1128
  turn.settled = true;
798
- turn.reject(error);
1129
+ if (wasHeld) {
1130
+ // A held turn's answer already streamed and its outcome is
1131
+ // recorded — a stream death during the post-answer hold is a
1132
+ // background failure, not the turn's. Resolve with the real
1133
+ // outcome, mirroring the stream-done path.
1134
+ turn.resolve(turn.deferredSettle);
1135
+ }
1136
+ else {
1137
+ turn.reject(error);
1138
+ }
799
1139
  }
800
1140
  }
801
1141
  };
@@ -836,9 +1176,45 @@ export class ClaudeAcpAgent {
836
1176
  // turn being abandoned. Stale counts self-heal: activation resets
837
1177
  // them (see activateTurn).
838
1178
  if (session.activeTurn && !session.activeTurn.settled) {
839
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
1179
+ // Seed by what the frames already told us, mirroring cancel()'s
1180
+ // queued-turn sweep — the consumer may have drained the wedged
1181
+ // turn's result and/or terminal frame before the backstop fired,
1182
+ // and an entry seeded for a result or frame that is already
1183
+ // spent would never drain (it would swallow an unrelated later
1184
+ // echo-less result instead).
1185
+ const active = session.activeTurn;
1186
+ if (active.commandFinished === "completed" || active.commandFinished === "discarded") {
1187
+ // Finished SDK-side; any result already passed. Nothing to
1188
+ // track.
1189
+ }
1190
+ else if (active.commandFinished === "cancelled") {
1191
+ // Aborted after dispatch: its late result may still come —
1192
+ // unless it already did.
1193
+ if (!active.commandResultSeen) {
1194
+ this.trackOrphanCommand(session, active.promptUuid, "zombie");
1195
+ }
1196
+ }
1197
+ else if (active.commandResultSeen) {
1198
+ // Its result was already consumed (dropped at the cancelled
1199
+ // guard); only the terminal frame is outstanding, which no-ops
1200
+ // with no entry. Nothing to track.
1201
+ }
1202
+ else {
1203
+ // The wedged turn WAS dispatched (it's active), so track it
1204
+ // "started": its late result (if the SDK recovers) is skipped
1205
+ // echo-less, and its terminal frame — or that skip plus
1206
+ // activation's clear when the frame is lost to the wedge — is
1207
+ // what drains it.
1208
+ this.trackOrphanCommand(session, active.promptUuid, "started");
1209
+ }
840
1210
  }
841
- settleActive({ stopReason: "cancelled" });
1211
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1212
+ // The cancelled turn's result may never come (that's why the
1213
+ // backstop fired) — close its delivery stretch here so partial
1214
+ // streamed text can't suppress the next turn's issue-#453 fallback.
1215
+ // If a late orphan result does arrive, its `finally` clears again;
1216
+ // FIFO ordering means no live turn's text can have streamed yet.
1217
+ session.emittedAssistantText = false;
842
1218
  // If the session is being torn down, abandon the in-flight next()
843
1219
  // (swallowing any later rejection so it can't surface as unhandled)
844
1220
  // and stop; otherwise re-arm and keep consuming — `pendingNext`
@@ -862,10 +1238,15 @@ export class ClaudeAcpAgent {
862
1238
  // turn's real outcome.
863
1239
  //
864
1240
  // Settle the turn that was in flight so its prompt() doesn't hang:
865
- // cancelled if a cancel is pending, otherwise the accumulated outcome.
1241
+ // cancelled if a cancel is pending, otherwise the outcome a
1242
+ // deferred turn already recorded (see Turn.deferredSettle) or the
1243
+ // accumulated scratch outcome. The scratch currently still equals
1244
+ // a deferred turn's stored outcome (followup results never mutate
1245
+ // it), but the stored one is the authoritative source.
1246
+ const inFlight = session.activeTurn;
866
1247
  settleActive(session.cancelled
867
- ? { stopReason: "cancelled" }
868
- : { stopReason, usage: sessionUsage(session) });
1248
+ ? { stopReason: "cancelled", usage: sessionUsage(session) }
1249
+ : (inFlight?.deferredSettle ?? { stopReason, usage: sessionUsage(session) }));
869
1250
  // Queued turns the SDK never started never ran, so reject them rather
870
1251
  // than reporting a success (end_turn) — or a misleading "cancelled" —
871
1252
  // for a prompt that produced no output. (A cancel already settled the
@@ -892,19 +1273,116 @@ export class ClaudeAcpAgent {
892
1273
  message: message,
893
1274
  });
894
1275
  }
1276
+ // CLIs 2.1.206+ (capability msg_lifecycle_v1) report the fate of every
1277
+ // uuid-stamped queued command (queued/started/completed/cancelled/
1278
+ // discarded) as `command_lifecycle` frames — 2-3 per prompt, since
1279
+ // prompt() stamps a uuid on every message. The frame is @internal and
1280
+ // absent from the SDKMessage union, so handle it BEFORE the exhaustive
1281
+ // switch: it must not reach `unreachable`'s error log, and a `case`
1282
+ // for it wouldn't typecheck. It feeds only the orphan accounting (see
1283
+ // Session.orphanCommands); turn settlement stays driven by
1284
+ // echoes/results/idle. (Raw-mode emission above still forwards these
1285
+ // frames.)
1286
+ if (message.type === "command_lifecycle") {
1287
+ const frame = message;
1288
+ switch (frame.state) {
1289
+ case "started": {
1290
+ // Remember dispatch on the live turn so a cancel() that orphans
1291
+ // it seeds the right state (see Turn.commandStarted)...
1292
+ const queued = findUnsettledTurn(frame.command_uuid);
1293
+ if (queued) {
1294
+ queued.commandStarted = true;
1295
+ }
1296
+ // ...and promote an already-orphaned command: once dispatched,
1297
+ // a bare `cancelled` no longer means "dropped without running".
1298
+ const state = session.orphanCommands?.get(frame.command_uuid);
1299
+ if (state === "pending") {
1300
+ session.orphanCommands.set(frame.command_uuid, "started");
1301
+ }
1302
+ else if (state === "zombie") {
1303
+ // "started" after the command's terminal frame: the ordering
1304
+ // the whole lane rests on has been violated (frames are
1305
+ // per-uuid FIFO). Surface it — a silent drift here degrades
1306
+ // into swallowed or misattributed results.
1307
+ 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.`);
1308
+ }
1309
+ break;
1310
+ }
1311
+ case "completed":
1312
+ case "discarded":
1313
+ case "cancelled": {
1314
+ // Terminal frames. Latch the fate on a still-queued turn so a
1315
+ // later cancel() doesn't seed an orphan entry for a command
1316
+ // whose one-and-only terminal frame has already been consumed
1317
+ // (nothing would ever drain that entry).
1318
+ const queued = findUnsettledTurn(frame.command_uuid);
1319
+ if (queued) {
1320
+ queued.commandFinished = frame.state;
1321
+ }
1322
+ if (frame.state === "cancelled") {
1323
+ // Ambiguous by design (dup-over-loss): dropped before
1324
+ // dispatch (no result will ever come — safe to forget) vs
1325
+ // consumed into a turn that was aborted/failed. For the
1326
+ // latter, any result the dead turn managed to emit has
1327
+ // already deleted the entry (see
1328
+ // recordResultForOrphanCommands / ensureActiveTurn), so a
1329
+ // still-"started" entry means no result was seen since
1330
+ // dispatch — it becomes a zombie for the next
1331
+ // echo-less-result skip.
1332
+ const state = session.orphanCommands?.get(frame.command_uuid);
1333
+ if (state === "pending") {
1334
+ session.orphanCommands?.delete(frame.command_uuid);
1335
+ }
1336
+ else if (state === "started") {
1337
+ session.orphanCommands?.set(frame.command_uuid, "zombie");
1338
+ }
1339
+ break;
1340
+ }
1341
+ // Exactly-one-terminal: the command is finished. "completed" is
1342
+ // emitted after any result its turn produced (fresh turn) or the
1343
+ // command folded into another turn whose result is attributed
1344
+ // elsewhere — either way no echo-less result remains to skip.
1345
+ // "discarded" = session ended with it still queued; no result.
1346
+ session.orphanCommands?.delete(frame.command_uuid);
1347
+ break;
1348
+ }
1349
+ default:
1350
+ // "queued" carries no fate information. Anything else is a
1351
+ // state this adapter doesn't know — likely a CLI that grew the
1352
+ // v1 vocabulary. The entry still drains by result coverage or
1353
+ // activation's clear (bounded damage), but log it so the
1354
+ // degradation is visible instead of silent.
1355
+ if (frame.state !== "queued") {
1356
+ this.logger.error(`Session ${params.sessionId}: unknown command_lifecycle state "${frame.state}" for ${frame.command_uuid}; treating as uninformative.`);
1357
+ }
1358
+ break;
1359
+ }
1360
+ continue;
1361
+ }
895
1362
  switch (message.type) {
896
1363
  case "system":
897
1364
  switch (message.subtype) {
898
1365
  case "init":
1366
+ // Latch the lifecycle capability so cancel() routes orphan
1367
+ // accounting through `orphanCommands` (per-uuid, exact)
1368
+ // instead of the coalescing-blind count. Never unlatch: init
1369
+ // re-emits per turn and the capability can't be lost mid-CLI.
1370
+ if (message.capabilities?.includes("msg_lifecycle_v1")) {
1371
+ session.msgLifecycleV1 = true;
1372
+ }
899
1373
  // A fresh `system`/init (e.g. after reinitialize) can carry an
900
1374
  // updated Fast mode state; reconcile it with what we seeded at
901
1375
  // session creation.
902
1376
  await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
903
1377
  break;
904
1378
  case "status": {
1379
+ // These banners count as delivered text (via sendUpdate), so
1380
+ // an echo-less turn that only ever emits them (e.g. `/compact`,
1381
+ // promoted at its own result) doesn't have its result text
1382
+ // re-emitted by the issue-#453 fallback.
905
1383
  if (message.status === "compacting") {
906
1384
  compactionInProgress = true;
907
- await this.client.sessionUpdate({
1385
+ await sendUpdate({
908
1386
  sessionId: message.session_id,
909
1387
  update: {
910
1388
  sessionUpdate: "agent_message_chunk",
@@ -917,7 +1395,7 @@ export class ClaudeAcpAgent {
917
1395
  // message carrying `compact_result`, not the `compact_boundary`
918
1396
  // message (which only fires when there's content to compact).
919
1397
  compactionInProgress = false;
920
- await this.client.sessionUpdate({
1398
+ await sendUpdate({
921
1399
  sessionId: message.session_id,
922
1400
  update: {
923
1401
  sessionUpdate: "agent_message_chunk",
@@ -928,7 +1406,7 @@ export class ClaudeAcpAgent {
928
1406
  else if (message.compact_result === "failed" && compactionInProgress) {
929
1407
  compactionInProgress = false;
930
1408
  const reason = message.compact_error ? `: ${message.compact_error}` : ".";
931
- await this.client.sessionUpdate({
1409
+ await sendUpdate({
932
1410
  sessionId: message.session_id,
933
1411
  update: {
934
1412
  sessionUpdate: "agent_message_chunk",
@@ -953,9 +1431,9 @@ export class ClaudeAcpAgent {
953
1431
  // dropped dramatically) and replaced within seconds by the next
954
1432
  // result message.
955
1433
  //
956
- // `size` keeps coming from session.contextWindowSize (learned
957
- // from modelUsage / the model heuristic) — getContextUsage's
958
- // window field under-reports extended 1M windows.
1434
+ // `size` keeps coming from session.contextWindowSize —
1435
+ // compaction frees occupancy, it doesn't change the model's
1436
+ // window.
959
1437
  //
960
1438
  // The "Compacting completed." text is emitted from the `status`
961
1439
  // handler (keyed on `compact_result`), not here, so the failure
@@ -963,7 +1441,7 @@ export class ClaudeAcpAgent {
963
1441
  const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
964
1442
  lastAssistantUsage = null;
965
1443
  lastAssistantTotalUsage = usedTokens ?? 0;
966
- await this.client.sessionUpdate({
1444
+ await sendUpdate({
967
1445
  sessionId: message.session_id,
968
1446
  update: {
969
1447
  sessionUpdate: "usage_update",
@@ -974,7 +1452,7 @@ export class ClaudeAcpAgent {
974
1452
  break;
975
1453
  }
976
1454
  case "local_command_output": {
977
- await this.client.sessionUpdate({
1455
+ await sendUpdate({
978
1456
  sessionId: message.session_id,
979
1457
  update: {
980
1458
  sessionUpdate: "agent_message_chunk",
@@ -984,6 +1462,7 @@ export class ClaudeAcpAgent {
984
1462
  break;
985
1463
  }
986
1464
  case "session_state_changed": {
1465
+ session.lastSessionState = message.state;
987
1466
  if (message.state === "idle") {
988
1467
  // A non-cancelled turn normally settled at its terminal
989
1468
  // `result` already (issue #773), and that result recorded an
@@ -1008,16 +1487,46 @@ export class ClaudeAcpAgent {
1008
1487
  // the turn NOW so its session/prompt gets a terminal
1009
1488
  // response, instead of leaving it hanging until the next
1010
1489
  // prompt drains the wreckage.
1490
+ // A cancelled turn still consumed tokens: its dropped result
1491
+ // already fed the accumulator (the usage tally at the result
1492
+ // handler runs before the `session.cancelled` guard), so
1493
+ // report it — clients metering spend would otherwise lose
1494
+ // the interrupted turn's tokens entirely (issue #844). Zero
1495
+ // when the cancel pre-empted the result (wedge/force-cancel).
1011
1496
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
1012
- settleActive({ stopReason: "cancelled" });
1497
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1498
+ // An interrupt can pre-empt the turn's result entirely
1499
+ // (nothing ran the result-case `finally`), so close the
1500
+ // delivery stretch here: idle is the SDK's authoritative
1501
+ // turn-over signal, and stale partial-text state would
1502
+ // suppress the next turn's issue-#453 fallback.
1503
+ session.emittedAssistantText = false;
1504
+ }
1505
+ else if (isHeldOpen(session.activeTurn)) {
1506
+ // A turn held open for its background subagents (see
1507
+ // Turn.deferredSettle). Idles keep their normal cadence
1508
+ // during the hold — the CLI emits one per processing
1509
+ // cycle (the turn's own trailer, then one per followup),
1510
+ // NOT one final "all drained" signal — so each one
1511
+ // absorbs an outstanding trailer debt, and the turn only
1512
+ // settles once none of its spawned subagents is left
1513
+ // (the followup-result settle usually got there first;
1514
+ // this is the fallback when no followup came). Mid-hold
1515
+ // idles never fall through: a held turn HAS its result,
1516
+ // so reading its idle as "turn abandoned without a
1517
+ // result" (issue #825) would fail a healthy prompt.
1518
+ if (session.owedTrailingIdles > 0) {
1519
+ session.owedTrailingIdles--;
1520
+ }
1521
+ settleDeferredIfDrained();
1013
1522
  }
1014
- else if (owedTrailingIdles > 0) {
1523
+ else if (session.owedTrailingIdles > 0) {
1015
1524
  // Absorb a settled turn's trailing idle. Also covers a
1016
1525
  // cancel that landed between a turn's counted result and
1017
1526
  // this lagged idle (no active turn to settle): the idle
1018
1527
  // still belongs to that settled turn, and skipping the
1019
1528
  // decrement would leak the debt permanently.
1020
- owedTrailingIdles--;
1529
+ session.owedTrailingIdles--;
1021
1530
  }
1022
1531
  else if (!session.cancelled &&
1023
1532
  session.activeTurn &&
@@ -1059,7 +1568,7 @@ export class ClaudeAcpAgent {
1059
1568
  const title = isSynthesis
1060
1569
  ? "Recalled synthesized memory"
1061
1570
  : `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
1062
- await this.client.sessionUpdate({
1571
+ await sendUpdate({
1063
1572
  sessionId: message.session_id,
1064
1573
  update: {
1065
1574
  sessionUpdate: "tool_call",
@@ -1086,7 +1595,7 @@ export class ClaudeAcpAgent {
1086
1595
  // list with this payload: supportedCommands() is captured once
1087
1596
  // at initialize and never reflects mid-session changes, so we
1088
1597
  // forward message.commands directly rather than re-querying.
1089
- await this.client.sessionUpdate({
1598
+ await sendUpdate({
1090
1599
  sessionId: message.session_id,
1091
1600
  update: {
1092
1601
  sessionUpdate: "available_commands_update",
@@ -1110,7 +1619,7 @@ export class ClaudeAcpAgent {
1110
1619
  // rejection reason — otherwise the client shows a tool call
1111
1620
  // that silently never resolves.
1112
1621
  const reason = message.decision_reason ?? message.message;
1113
- await this.client.sessionUpdate({
1622
+ await sendUpdate({
1114
1623
  sessionId: message.session_id,
1115
1624
  update: {
1116
1625
  sessionUpdate: "tool_call_update",
@@ -1142,10 +1651,14 @@ export class ClaudeAcpAgent {
1142
1651
  // instead of a silent stop. ACP's agent_message_chunk has no
1143
1652
  // severity field, so fold the level into the text for the more
1144
1653
  // prominent levels ('info' is transcript-only noise — leave plain).
1654
+ // Sending via sendUpdate also marks the notice as this stretch's
1655
+ // delivered text: a hook-blocked turn's result repeats the block
1656
+ // reason with zero output tokens, and the issue-#453 fallback
1657
+ // must not emit it a second time.
1145
1658
  const text = message.level === "info"
1146
1659
  ? message.content
1147
1660
  : `**${message.level[0].toUpperCase()}${message.level.slice(1)}:** ${message.content}`;
1148
- await this.client.sessionUpdate({
1661
+ await sendUpdate({
1149
1662
  sessionId: message.session_id,
1150
1663
  update: {
1151
1664
  sessionUpdate: "agent_message_chunk",
@@ -1158,10 +1671,51 @@ export class ClaudeAcpAgent {
1158
1671
  case "hook_progress":
1159
1672
  case "hook_response":
1160
1673
  case "files_persisted":
1674
+ case "task_progress":
1675
+ break;
1161
1676
  case "task_started":
1677
+ // For subagent tasks `task_id` is the subagent's agent id (the
1678
+ // SDK keys its task registry by agent id) and `tool_use_id` is
1679
+ // the Agent/Task tool_use that spawned it — recorded so the
1680
+ // subagent's permission requests, which reach canUseTool with
1681
+ // only `agentID`, can attribute their eagerly-emitted
1682
+ // tool_call to the parent tool call. Non-subagent tasks (e.g.
1683
+ // background Bash) land here too; their task_ids never match
1684
+ // an agentID, so those entries are inert for attribution.
1685
+ //
1686
+ // `isSubagent` marks Task/Agent-tool subagents — the tasks
1687
+ // whose completion wakes the model for a followup, so the
1688
+ // ones worth deferring turn settlement for. A sync subagent
1689
+ // is pruned (terminal task_updated) before its turn's result
1690
+ // can arrive, so registry membership at result time means an
1691
+ // async subagent. Their spawn is also recorded on the active
1692
+ // turn: a turn only ever waits on its own subagents, and a
1693
+ // spawn during a held-open drain window (an agent chain)
1694
+ // extends that turn's hold.
1695
+ session.liveBackgroundTasks.set(message.task_id, {
1696
+ parentToolUseId: message.tool_use_id,
1697
+ isSubagent: !!message.subagent_type,
1698
+ });
1699
+ if (message.subagent_type && session.activeTurn && !session.activeTurn.settled) {
1700
+ (session.activeTurn.spawnedTaskIds ??= new Set()).add(message.task_id);
1701
+ }
1702
+ break;
1162
1703
  case "task_notification":
1163
- case "task_progress":
1704
+ // The task settled — no further tool calls can originate
1705
+ // from it, so its registry entry can be dropped.
1706
+ session.liveBackgroundTasks.delete(message.task_id);
1707
+ break;
1164
1708
  case "task_updated":
1709
+ // terminal-status task_updated patch and a (deduplicated)
1710
+ // task_notification when a task settles, but only the patch is
1711
+ // guaranteed per transition — prune on it too so the registry
1712
+ // can't grow for the session's lifetime if a notification is
1713
+ // skipped.
1714
+ if (message.patch.status === "completed" ||
1715
+ message.patch.status === "failed" ||
1716
+ message.patch.status === "killed") {
1717
+ session.liveBackgroundTasks.delete(message.task_id);
1718
+ }
1165
1719
  break;
1166
1720
  case "worker_shutting_down":
1167
1721
  // A Remote Control worker announced a graceful teardown. This is a
@@ -1213,7 +1767,7 @@ export class ClaudeAcpAgent {
1213
1767
  const outcome = persistent
1214
1768
  ? `The session will continue on ${message.fallback_model}.`
1215
1769
  : `The session stays on ${message.original_model}.`;
1216
- await this.client.sessionUpdate({
1770
+ await sendUpdate({
1217
1771
  sessionId: message.session_id,
1218
1772
  update: {
1219
1773
  sessionUpdate: "agent_message_chunk",
@@ -1247,183 +1801,334 @@ export class ClaudeAcpAgent {
1247
1801
  lastRefusalExplanation = message.api_refusal_explanation ?? message.content;
1248
1802
  }
1249
1803
  break;
1804
+ // `control_request_progress` only reports on side_question
1805
+ // control requests, which this adapter never issues.
1806
+ case "control_request_progress":
1807
+ break;
1808
+ case "background_tasks_changed":
1809
+ // A level signal: the full live background-task set on every
1810
+ // membership change, with REPLACE semantics. Used only to
1811
+ // reconcile `liveBackgroundTasks` — dropping (or, for
1812
+ // subagent entries, unpinning) any entry whose settle
1813
+ // bookend (task_notification / terminal task_updated) was
1814
+ // lost, so a leaked subagent entry can't defer its spawning
1815
+ // turn's settlement forever. Growth of retained
1816
+ // (endedPerLevel) subagent entries is bounded by the
1817
+ // activation-time sweep in activateTurn, not here. It never
1818
+ // ADDS entries (the payload carries no attribution or
1819
+ // subagent marker), so the unspecified ordering vs. the edge
1820
+ // bookends is safe: a level that precedes its task_started
1821
+ // simply no-ops here.
1822
+ if (session.liveBackgroundTasks.size > 0) {
1823
+ const live = new Set(message.tasks.map((t) => t.task_id));
1824
+ for (const [taskId, record] of session.liveBackgroundTasks) {
1825
+ if (live.has(taskId)) {
1826
+ // The level proves the task live in the background
1827
+ // universe (e.g. a foreground agent was backgrounded
1828
+ // after an earlier absent-marking, or that marking was
1829
+ // a racing payload built before the task registered) —
1830
+ // un-end it so a hold waits on it again, and disarm
1831
+ // the activation sweep.
1832
+ record.endedPerLevel = undefined;
1833
+ continue;
1834
+ }
1835
+ if (record.isSubagent) {
1836
+ // The level's universe is BACKGROUND tasks only, so a
1837
+ // live sync (foreground) subagent is legitimately
1838
+ // absent — deleting its entry would strand its
1839
+ // permission attribution (#859). Keep the entry but
1840
+ // stop any hold from waiting on the id: an absent id
1841
+ // can equally be a leaked async entry whose settle
1842
+ // bookends were lost.
1843
+ record.endedPerLevel ??= "ended";
1844
+ }
1845
+ else {
1846
+ session.liveBackgroundTasks.delete(taskId);
1847
+ }
1848
+ }
1849
+ }
1850
+ break;
1250
1851
  default:
1251
1852
  unreachable(message, this.logger);
1252
1853
  break;
1253
1854
  }
1254
1855
  break;
1255
1856
  case "result": {
1256
- // Task-notification followups are autonomous work triggered by a
1257
- // task-notification system message, not by the user's prompt.
1258
- // They should not influence the user-turn lifecycle (stop reason,
1259
- // slash-command output forwarding) but their cost is real.
1260
- const isTaskNotification = message.origin?.kind === "task-notification";
1261
- // Reconcile the Fast mode toggle with the SDK's reported state.
1262
- // Gated to user-driven turns like every other side effect below; a
1263
- // background followup's state lands on the next user turn's result.
1264
- // Runs even when the turn errors or was cancelled.
1265
- if (!isTaskNotification) {
1266
- await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1267
- }
1268
- // A user-turn result needs an active turn so its stop reason is
1269
- // attributed and the turn settles at idle. Local-only commands carry
1270
- // no user-message echo to promote them, so do it here from the head.
1271
- // Promote BEFORE accumulating usage, since activation resets the
1272
- // accumulator — promoting after would discard this result's tokens.
1273
- if (!isTaskNotification) {
1274
- ensureActiveTurn();
1275
- }
1276
- // Every user-turn result terminates a turn (settle, reject, or
1277
- // orphan skip) and the SDK follows it with a trailing
1278
- // `session_state_changed: idle` — record the debt so the idle
1279
- // handler absorbs that idle rather than reading it as a turn the
1280
- // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
1281
- // turn's own result. It is dropped at the `session.cancelled`
1282
- // guard, and either the idle itself settles the turn (consuming
1283
- // the trailer) or the next echo's hand-off does (which records
1284
- // the debt there instead) — counting here too would double it.
1285
- // Results skipped while cancelled with NO active turn — orphaned
1286
- // queued turns the SDK still ran, or a force-cancelled turn's
1287
- // late result after the backstop settled it — get no such settle,
1288
- // so their trailers must be counted here or they'd later be read
1289
- // as the next healthy turn being abandoned and false-fail it.
1290
- if (!isTaskNotification && (!session.cancelled || !session.activeTurn)) {
1291
- owedTrailingIdles++;
1292
- }
1293
- // Accumulate usage into the user turn's tally. Skip task-notification
1294
- // followups: their cost is real but is reported separately via the
1295
- // usage_update below, and `session.accumulatedUsage` is only reset on
1296
- // turn activation — so folding a task-notification result that lands
1297
- // after the next turn is active (but before it settles) would leak
1298
- // those tokens into that turn's PromptResponse.usage.
1299
- if (!isTaskNotification) {
1300
- session.accumulatedUsage.inputTokens += message.usage.input_tokens;
1301
- session.accumulatedUsage.outputTokens += message.usage.output_tokens;
1302
- session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
1303
- session.accumulatedUsage.cachedWriteTokens +=
1304
- message.usage.cache_creation_input_tokens;
1305
- }
1306
- const matchingModelUsage = lastAssistantModel
1307
- ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
1308
- : null;
1309
- // Only overwrite when we have an authoritative value — a miss
1310
- // (e.g. a turn with no top-level assistant message) would
1311
- // otherwise discard the window learned on a prior turn and
1312
- // leave the next prompt's mid-stream updates reporting 200k.
1313
- if (matchingModelUsage) {
1314
- session.contextWindowSize = matchingModelUsage.contextWindow;
1315
- }
1316
- // Send usage_update notification
1317
- if (lastAssistantTotalUsage !== null) {
1318
- await this.client.sessionUpdate({
1319
- sessionId: params.sessionId,
1320
- update: {
1321
- sessionUpdate: "usage_update",
1322
- used: lastAssistantTotalUsage,
1323
- size: session.contextWindowSize,
1324
- cost: {
1325
- amount: message.total_cost_usd,
1326
- currency: "USD",
1327
- },
1328
- ...(message.origin && {
1329
- _meta: { "_claude/origin": message.origin },
1330
- }),
1331
- },
1332
- });
1333
- }
1334
- if (session.cancelled) {
1335
- if (!isTaskNotification) {
1336
- stopReason = "cancelled";
1857
+ // A result from an autonomous cycle — a task-notification
1858
+ // followup, or a peer/coordinator/observer message the model
1859
+ // handled on its own (see AUTONOMOUS_RESULT_ORIGINS) — is not
1860
+ // the user's prompt's. Autonomous results must never touch the
1861
+ // user-turn lifecycle (stop reason, settles, failActive,
1862
+ // slash-command output forwarding), though their cost is real.
1863
+ const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
1864
+ try {
1865
+ // Reconcile the Fast mode toggle with the SDK's reported state.
1866
+ // Gated to user-driven turns like every other side effect below;
1867
+ // an autonomous cycle's state lands on the next user turn's
1868
+ // result. Runs even when the turn errors or was cancelled.
1869
+ if (!isAutonomousResult) {
1870
+ await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1337
1871
  }
1338
- break;
1339
- }
1340
- // A refusal can arrive on any result subtype (and may even set
1341
- // is_error), so handle it before the subtype switch — otherwise the
1342
- // is_error throw below would surface it as an internal error. The
1343
- // refused assistant message carries no visible content, so surface
1344
- // the classifier's explanation (when available) and report ACP's
1345
- // dedicated `refusal` stop reason.
1346
- if (message.stop_reason === "refusal" && !isTaskNotification) {
1347
- if (lastRefusalExplanation) {
1348
- await this.client.sessionUpdate({
1872
+ // A user-turn result needs an active turn so its stop reason is
1873
+ // attributed and the turn settles at idle. Local-only commands carry
1874
+ // no user-message echo to promote them, so do it here from the head.
1875
+ // Promote BEFORE accumulating usage, since activation resets the
1876
+ // accumulator — promoting after would discard this result's tokens.
1877
+ // The orphan bookkeeping runs first: it covers folded/zombie
1878
+ // commands whose shared or late result this is, even when the
1879
+ // result is the ACTIVE turn's (ensureActiveTurn never looks at
1880
+ // the map in that case).
1881
+ if (!isAutonomousResult) {
1882
+ recordResultForOrphanCommands();
1883
+ ensureActiveTurn();
1884
+ }
1885
+ // A result closes the stretch of output it terminates: snapshot
1886
+ // the delivery record — AFTER ensureActiveTurn, whose held-turn
1887
+ // hand-off closes the held stretch, so an echo-less command
1888
+ // promoted here is judged on its own delivery, not on the held
1889
+ // turn's followup text — and before the handling below can emit
1890
+ // anything of its own; the `finally` then clears it so every
1891
+ // exit from this case (the cancelled-guard and refusal breaks
1892
+ // included) starts the next stretch clean. Clearing up front
1893
+ // instead would let result-time emissions (refusal explanation,
1894
+ // result-text forwarding) taint the next stretch and suppress a
1895
+ // following replayed turn's fallback. Autonomous cycles run
1896
+ // alongside a user turn and must not clear its flag (they exit
1897
+ // through the early break below, which the gated `finally`
1898
+ // leaves alone).
1899
+ const deliveredAssistantText = session.emittedAssistantText;
1900
+ // Every user-turn result terminates a turn (settle, reject, or
1901
+ // orphan skip) and the SDK follows it with a trailing
1902
+ // `session_state_changed: idle` — record the debt so the idle
1903
+ // handler absorbs that idle rather than reading it as a turn the
1904
+ // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
1905
+ // turn's own result. It is dropped at the `session.cancelled`
1906
+ // guard, and either the idle itself settles the turn (consuming
1907
+ // the trailer) or the next echo's hand-off does (which records
1908
+ // the debt there instead) — counting here too would double it.
1909
+ // Results skipped while cancelled with NO active turn — orphaned
1910
+ // queued turns the SDK still ran, or a force-cancelled turn's
1911
+ // late result after the backstop settled it — get no such settle,
1912
+ // so their trailers must be counted here or they'd later be read
1913
+ // as the next healthy turn being abandoned and false-fail it.
1914
+ // Autonomous results (followups, peer/channel/coordinator
1915
+ // cycles) are counted too: each is its own processing cycle with
1916
+ // its own trailing idle, and that idle can lag past the next
1917
+ // prompt's echo — which, un-owed, would be read as the fresh
1918
+ // turn being abandoned (#825 false-fail). That lag was mostly
1919
+ // unreachable when such cycles only ran with no pending turn,
1920
+ // but a held turn settling AT a followup result unblocks the
1921
+ // client at exactly that point, making the race the common
1922
+ // case.
1923
+ // The cancelled-ACTIVE-turn exclusion applies only to that
1924
+ // turn's OWN result — a followup result arriving inside the
1925
+ // cancel window still gets its own trailer and must be counted,
1926
+ // or that idle would later false-fail the next prompt.
1927
+ if (isAutonomousResult || !session.cancelled || !session.activeTurn) {
1928
+ session.owedTrailingIdles++;
1929
+ }
1930
+ // Accumulate usage into the user turn's tally. Skip autonomous
1931
+ // results: their cost is real but is reported separately via the
1932
+ // usage_update below, and `session.accumulatedUsage` is only reset on
1933
+ // turn activation — so folding an autonomous result that lands
1934
+ // after the next turn is active (but before it settles) would leak
1935
+ // those tokens into that turn's PromptResponse.usage.
1936
+ if (!isAutonomousResult) {
1937
+ session.accumulatedUsage.inputTokens += message.usage.input_tokens;
1938
+ session.accumulatedUsage.outputTokens += message.usage.output_tokens;
1939
+ session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
1940
+ session.accumulatedUsage.cachedWriteTokens +=
1941
+ message.usage.cache_creation_input_tokens;
1942
+ }
1943
+ const matchingModelUsage = lastAssistantModel
1944
+ ? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
1945
+ : null;
1946
+ // Only overwrite when we have an authoritative value — a miss
1947
+ // (e.g. a turn with no top-level assistant message) would
1948
+ // otherwise discard the window learned on a prior turn and
1949
+ // leave the next prompt's mid-stream updates reporting 200k.
1950
+ if (matchingModelUsage) {
1951
+ session.contextWindowSize = matchingModelUsage.contextWindow;
1952
+ }
1953
+ // Send usage_update notification
1954
+ if (lastAssistantTotalUsage !== null) {
1955
+ await sendUpdate({
1349
1956
  sessionId: params.sessionId,
1350
1957
  update: {
1351
- sessionUpdate: "agent_message_chunk",
1352
- content: { type: "text", text: lastRefusalExplanation },
1958
+ sessionUpdate: "usage_update",
1959
+ used: lastAssistantTotalUsage,
1960
+ size: session.contextWindowSize,
1961
+ cost: {
1962
+ amount: message.total_cost_usd,
1963
+ currency: "USD",
1964
+ },
1965
+ ...(message.origin && {
1966
+ _meta: { "_claude/origin": message.origin },
1967
+ }),
1353
1968
  },
1354
1969
  });
1355
1970
  }
1356
- stopReason = "refusal";
1357
- settleActive({ stopReason: "refusal", usage: sessionUsage(session) });
1358
- break;
1359
- }
1360
- switch (message.subtype) {
1361
- case "success": {
1362
- if (message.result.includes("Please run /login")) {
1363
- failActive(RequestError.authRequired());
1364
- break;
1971
+ if (session.cancelled) {
1972
+ if (!isAutonomousResult) {
1973
+ stopReason = "cancelled";
1365
1974
  }
1366
- if (message.stop_reason === "max_tokens") {
1367
- if (!isTaskNotification) {
1368
- stopReason = "max_tokens";
1369
- }
1370
- break;
1371
- }
1372
- if (message.is_error) {
1373
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
1374
- break;
1975
+ break;
1976
+ }
1977
+ // A held turn (see Turn.deferredSettle) settles at its
1978
+ // followup's terminal result: this is the earliest point at which
1979
+ // the promised summary has fully streamed — the trailing idle
1980
+ // would work too, but a client should not wait out another idle
1981
+ // round-trip for a response whose content is already complete.
1982
+ // (While the turn still awaits another of its subagents —
1983
+ // parallel spawns — the helper holds; the next notification's
1984
+ // followup settles it instead. Other autonomous origins — peer/
1985
+ // channel/coordinator cycles — reach here too: settling a
1986
+ // drained hold at their results is as good as the idle
1987
+ // fallback.) Then stop: everything below is user-turn
1988
+ // lifecycle, and an autonomous outcome must never touch it —
1989
+ // its is_error or "Please run /login" text would otherwise
1990
+ // failActive a live turn (the held one, or the user's next
1991
+ // prompt) whose own result recorded a different outcome.
1992
+ if (isAutonomousResult) {
1993
+ settleDeferredIfDrained();
1994
+ // With no turn in flight OR QUEUED (also after the settle
1995
+ // above), the stretch holds only autonomous prose — close
1996
+ // it, so a replayed next prompt isn't silently suppressed by
1997
+ // the issue-#453 delivery check. A live turn's flag may
1998
+ // guard the USER's already-streamed text — and so may a
1999
+ // QUEUED turn's: with mid-message echo lag its deltas stream
2000
+ // before the echo activates it (activeTurn still null), and
2001
+ // clearing then would re-emit that answer via the fallback,
2002
+ // the duplicate direction the flag's doc forbids.
2003
+ if (!session.activeTurn && !firstUnsettledQueuedTurn()) {
2004
+ session.emittedAssistantText = false;
1375
2005
  }
1376
- // For local-only commands (no model invocation), the result
1377
- // text is the command output — forward it to the client.
1378
- // Task-notification followups never originate from a user
1379
- // slash command, so skip the forwarding for them.
1380
- if (session.activeTurn?.isLocalOnlyCommand && !isTaskNotification) {
1381
- for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
1382
- await this.client.sessionUpdate(notification);
1383
- }
2006
+ break;
2007
+ }
2008
+ // A refusal can arrive on any result subtype (and may even set
2009
+ // is_error), so handle it before the subtype switch — otherwise the
2010
+ // is_error throw below would surface it as an internal error. The
2011
+ // refused assistant message carries no visible content, so surface
2012
+ // the classifier's explanation (when available) and report ACP's
2013
+ // dedicated `refusal` stop reason.
2014
+ if (message.stop_reason === "refusal") {
2015
+ if (lastRefusalExplanation) {
2016
+ await sendUpdate({
2017
+ sessionId: params.sessionId,
2018
+ update: {
2019
+ sessionUpdate: "agent_message_chunk",
2020
+ content: { type: "text", text: lastRefusalExplanation },
2021
+ },
2022
+ });
1384
2023
  }
2024
+ stopReason = "refusal";
2025
+ // Through the deferral gate, not settleActive: a refusal can
2026
+ // land on a turn whose spawned subagents are still live, and
2027
+ // settling it out from under them would strand their output
2028
+ // and permission requests out-of-turn (issue #866's deadlock,
2029
+ // through the refusal lane).
2030
+ settleOrDefer({ stopReason: "refusal", usage: sessionUsage(session) });
1385
2031
  break;
1386
2032
  }
1387
- case "error_during_execution": {
1388
- if (message.stop_reason === "max_tokens") {
1389
- if (!isTaskNotification) {
2033
+ switch (message.subtype) {
2034
+ case "success": {
2035
+ if (message.result.includes("Please run /login")) {
2036
+ failActive(RequestError.authRequired());
2037
+ break;
2038
+ }
2039
+ if (message.stop_reason === "max_tokens") {
1390
2040
  stopReason = "max_tokens";
2041
+ break;
2042
+ }
2043
+ if (message.is_error) {
2044
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.result));
2045
+ break;
2046
+ }
2047
+ // The result text is forwarded in two cases. Local-only
2048
+ // commands (no model invocation): the result IS the command
2049
+ // output. Otherwise the result is normally a trailing copy of
2050
+ // text that already streamed — but a cache-replayed turn
2051
+ // generates no tokens, and some CLIs then skip streaming
2052
+ // entirely and answer on the `result` alone: no `stream_event`
2053
+ // deltas, no consolidated `assistant` message (issue #453).
2054
+ // Forward it rather than end the turn silently:
2055
+ // `deliveredAssistantText` covers whatever already reached the
2056
+ // client (a turn that showed its answer cannot emit it twice),
2057
+ // and the output-token check keeps the fallback to the
2058
+ // replayed turns it was reported for. `?? 0`: typed non-null,
2059
+ // but third-party backends have been observed omitting usage
2060
+ // token fields (see snapshotFromUsage), and the replay lane
2061
+ // was reported from exactly such a backend — treat a missing
2062
+ // count as the replay signature rather than silently disabling
2063
+ // the fallback there. (Autonomous results never get here —
2064
+ // they exit at the early break above — so no background
2065
+ // prose can be injected into the feed.)
2066
+ if (session.activeTurn?.isLocalOnlyCommand ||
2067
+ (!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
2068
+ for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
2069
+ await sendUpdate(notification);
2070
+ }
1391
2071
  }
1392
2072
  break;
1393
2073
  }
1394
- if (message.is_error) {
1395
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1396
- break;
1397
- }
1398
- if (!isTaskNotification) {
2074
+ case "error_during_execution": {
2075
+ if (message.stop_reason === "max_tokens") {
2076
+ stopReason = "max_tokens";
2077
+ break;
2078
+ }
2079
+ if (message.is_error) {
2080
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2081
+ break;
2082
+ }
1399
2083
  stopReason = "end_turn";
1400
- }
1401
- break;
1402
- }
1403
- case "error_max_budget_usd":
1404
- case "error_max_turns":
1405
- case "error_max_structured_output_retries":
1406
- if (message.is_error) {
1407
- failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
1408
2084
  break;
1409
2085
  }
1410
- if (!isTaskNotification) {
2086
+ case "error_max_budget_usd":
2087
+ case "error_max_turns":
2088
+ case "error_max_structured_output_retries":
2089
+ if (message.is_error) {
2090
+ failActive(RequestError.internalError(errorKindData(lastAssistantError), message.errors.join(", ") || message.subtype));
2091
+ break;
2092
+ }
1411
2093
  stopReason = "max_turn_requests";
1412
- }
1413
- break;
1414
- default:
1415
- unreachable(message, this.logger);
1416
- break;
2094
+ break;
2095
+ default:
2096
+ unreachable(message, this.logger);
2097
+ break;
2098
+ }
2099
+ // Settle the user turn at its terminal result so the client unlocks
2100
+ // as soon as the answer is done, rather than waiting for the SDK's
2101
+ // trailing `idle` (which can lag while background work runs — issue
2102
+ // #773). The consumer keeps draining afterward (absorbing idle and
2103
+ // forwarding any background output).
2104
+ //
2105
+ // One exception: while background subagents this turn spawned are
2106
+ // still live, settling now would strand their remaining work
2107
+ // outside any turn — ACP allows out-of-turn session/update, but
2108
+ // many clients stop consuming at the prompt response, and a
2109
+ // subagent's permission request would block on an RPC nobody
2110
+ // answers (issues #864/#866). Hold the turn open instead: store
2111
+ // the outcome and settle with it once the subagents are done —
2112
+ // at their followup's terminal result (see the deferred-settle
2113
+ // block above the subtype switch) or at an idle with none of
2114
+ // them left — so the subagents' streamed output, their
2115
+ // permission requests, and the model's promised summary all land
2116
+ // inside the turn. `session/cancel` and the next prompt's echo
2117
+ // hand-off still settle a deferred turn early, so a long-running
2118
+ // subagent never holds the prompt hostage.
2119
+ //
2120
+ // is_error/auth already settled via failActive (activeTurn is null
2121
+ // then, so both branches no-op); cancellation is left to the
2122
+ // idle/abort path. settleActive is idempotent, so a duplicate
2123
+ // idle is a no-op.
2124
+ if (!session.cancelled) {
2125
+ settleOrDefer({ stopReason, usage: sessionUsage(session) });
2126
+ }
1417
2127
  }
1418
- // Settle the user turn at its terminal result so the client unlocks
1419
- // as soon as the answer is done, rather than waiting for the SDK's
1420
- // trailing `idle` (which can lag while background work runs — issue
1421
- // #773). The consumer keeps draining afterward (absorbing idle and
1422
- // forwarding any background output). is_error/auth already settled
1423
- // via failActive; cancellation is left to the idle/abort path.
1424
- // settleActive is idempotent, so a duplicate idle is a no-op.
1425
- if (!isTaskNotification && !session.cancelled) {
1426
- settleActive({ stopReason, usage: sessionUsage(session) });
2128
+ finally {
2129
+ if (!isAutonomousResult) {
2130
+ session.emittedAssistantText = false;
2131
+ }
1427
2132
  }
1428
2133
  break;
1429
2134
  }
@@ -1468,7 +2173,7 @@ export class ClaudeAcpAgent {
1468
2173
  // create a block the consolidated handler's `text.length > 0`
1469
2174
  // guard can never consume, stalling the diff cursor and
1470
2175
  // re-emitting the next block as a duplicate.
1471
- if (chunk && chunk.text.length > 0) {
2176
+ if (chunk?.text) {
1472
2177
  const index = message.event.index;
1473
2178
  const last = streamedBlocks[streamedBlocks.length - 1];
1474
2179
  if (last && last.index === index && last.type === chunk.type) {
@@ -1486,11 +2191,10 @@ export class ClaudeAcpAgent {
1486
2191
  const model = message.event.message.model;
1487
2192
  if (model && model !== "<synthetic>") {
1488
2193
  lastAssistantModel = model;
1489
- // Only upgrade from the default — once a `result` has given
1490
- // us an authoritative window, trust it over the heuristic.
1491
- // Model switches invalidate the cached window via
1492
- // `syncSessionConfigState`, which resets us back to the
1493
- // default so this branch runs again for the new model.
2194
+ // Only upgrade from the default — once the SDK has given us
2195
+ // an authoritative window (seeded at session creation,
2196
+ // refreshed on model switches in `applyConfigOptionValue`,
2197
+ // confirmed by each `result`), trust it over the heuristic.
1494
2198
  if (session.contextWindowSize === DEFAULT_CONTEXT_WINDOW) {
1495
2199
  const inferred = inferContextWindowFromModel(model);
1496
2200
  if (inferred !== null) {
@@ -1516,7 +2220,7 @@ export class ClaudeAcpAgent {
1516
2220
  const nextUsage = totalTokens(lastAssistantUsage);
1517
2221
  if (nextUsage !== lastAssistantTotalUsage) {
1518
2222
  lastAssistantTotalUsage = nextUsage;
1519
- await this.client.sessionUpdate({
2223
+ await sendUpdate({
1520
2224
  sessionId: params.sessionId,
1521
2225
  update: {
1522
2226
  sessionUpdate: "usage_update",
@@ -1532,8 +2236,11 @@ export class ClaudeAcpAgent {
1532
2236
  taskState: session.taskState,
1533
2237
  emittedToolCalls: session.emittedToolCalls,
1534
2238
  messageId: currentStreamMessageId,
2239
+ streamedToolInputs,
1535
2240
  })) {
1536
- await this.client.sessionUpdate(notification);
2241
+ // sendUpdate records delivery; a subagent stream's chunks carry
2242
+ // the stamped parentToolUseId meta and are excluded there.
2243
+ await sendUpdate(notification);
1537
2244
  }
1538
2245
  break;
1539
2246
  }
@@ -1554,7 +2261,7 @@ export class ClaudeAcpAgent {
1554
2261
  // is still promoted — activateTurn() clears the flag. The turn's own
1555
2262
  // echo is then dropped from the feed (the client already shows it).
1556
2263
  if (message.type === "user" && "uuid" in message && message.uuid) {
1557
- const queued = (session.turnQueue ?? []).find((t) => t.promptUuid === message.uuid && !t.settled);
2264
+ const queued = findUnsettledTurn(message.uuid);
1558
2265
  if (queued) {
1559
2266
  // Only (re)activate if this isn't already the active turn — a
1560
2267
  // turn promoted early (e.g. by a result that preceded its echo)
@@ -1572,13 +2279,38 @@ export class ClaudeAcpAgent {
1572
2279
  // debt so that lagged idle is absorbed rather than read
1573
2280
  // as the freshly-activated turn ending without a result
1574
2281
  // (which would false-fail a healthy turn — issue #825).
1575
- owedTrailingIdles++;
1576
- settleActive({ stopReason: "cancelled" });
2282
+ // Counted for a DEFERRED turn too, even though its own
2283
+ // result already recorded a debt that may still be
2284
+ // outstanding: the interrupt can produce a trailer of
2285
+ // its own, and over-counting is benign (absorbs one
2286
+ // future idle) while under-counting risks the false
2287
+ // fail this debt exists to prevent.
2288
+ session.owedTrailingIdles++;
2289
+ // Before activateTurn resets the accumulator, so the
2290
+ // usage still belongs to the cancelled turn.
2291
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
2292
+ }
2293
+ else if (isHeldOpen(session.activeTurn)) {
2294
+ // A turn held open for its background subagents (see
2295
+ // Turn.deferredSettle) hands off with the real outcome
2296
+ // its result recorded, not a guessed end_turn — the
2297
+ // user moving on must not block behind a long-running
2298
+ // subagent, but it must not rewrite the stop reason
2299
+ // either. Its trailing-idle debt stands and is absorbed
2300
+ // when the drain idle eventually arrives.
2301
+ settleActive(session.activeTurn.deferredSettle);
1577
2302
  }
1578
2303
  else {
1579
2304
  settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
1580
2305
  }
1581
2306
  }
2307
+ // Unlike the no-result teardown lanes, this hand-off must
2308
+ // NOT clear emittedAssistantText for a NON-held previous
2309
+ // turn (a held one's settleActive above closes its own
2310
+ // stretch): the echo can land
2311
+ // mid-message, so deltas already streamed belong to the turn
2312
+ // being activated — clearing would forget them and let its
2313
+ // result re-emit the answer.
1582
2314
  activateTurn(queued);
1583
2315
  }
1584
2316
  break;
@@ -1631,7 +2363,7 @@ export class ClaudeAcpAgent {
1631
2363
  taskState: session.taskState,
1632
2364
  messageId: messageIdForGrouping(message),
1633
2365
  })) {
1634
- await this.client.sessionUpdate(notification);
2366
+ await sendUpdate(notification);
1635
2367
  }
1636
2368
  }
1637
2369
  else {
@@ -1655,12 +2387,7 @@ export class ClaudeAcpAgent {
1655
2387
  if (message.message.role === "system") {
1656
2388
  break;
1657
2389
  }
1658
- if (message.type === "assistant" &&
1659
- message.message.model === "<synthetic>" &&
1660
- Array.isArray(message.message.content) &&
1661
- message.message.content.length === 1 &&
1662
- message.message.content[0].type === "text" &&
1663
- message.message.content[0].text.includes("Please run /login")) {
2390
+ if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
1664
2391
  failActive(RequestError.authRequired());
1665
2392
  break;
1666
2393
  }
@@ -1743,13 +2470,18 @@ export class ClaudeAcpAgent {
1743
2470
  taskState: session.taskState,
1744
2471
  emittedToolCalls: session.emittedToolCalls,
1745
2472
  messageId: messageIdForGrouping(message),
2473
+ toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
1746
2474
  })) {
1747
- await this.client.sessionUpdate(notification);
2475
+ // sendUpdate records delivery. Subagent text/thinking is
2476
+ // filtered out of `content` above; blocks that do pass through
2477
+ // (e.g. a subagent image) carry the stamped parentToolUseId
2478
+ // meta and are excluded there.
2479
+ await sendUpdate(notification);
1748
2480
  }
1749
2481
  break;
1750
2482
  }
1751
2483
  case "tool_progress": {
1752
- await this.client.sessionUpdate({
2484
+ await sendUpdate({
1753
2485
  sessionId: message.session_id,
1754
2486
  update: {
1755
2487
  sessionUpdate: "tool_call_update",
@@ -1767,7 +2499,7 @@ export class ClaudeAcpAgent {
1767
2499
  }
1768
2500
  case "rate_limit_event": {
1769
2501
  if (lastAssistantTotalUsage !== null) {
1770
- await this.client.sessionUpdate({
2502
+ await sendUpdate({
1771
2503
  sessionId: message.session_id,
1772
2504
  update: {
1773
2505
  sessionUpdate: "usage_update",
@@ -1782,16 +2514,13 @@ export class ClaudeAcpAgent {
1782
2514
  // `conversation_reset` (from `/clear`, plan-mode exit, fresh-session
1783
2515
  // flows) is safe to drop: turn lifecycle here is driven by
1784
2516
  // results/idle, and the client owns its own transcript view.
1785
- // `control_request_progress` only reports on side_question control
1786
- // requests, which this adapter never issues.
1787
2517
  case "tool_use_summary":
1788
2518
  case "auth_status":
1789
2519
  case "prompt_suggestion":
1790
2520
  case "conversation_reset":
1791
- case "control_request_progress":
1792
2521
  break;
1793
2522
  default:
1794
- unreachable(message);
2523
+ unreachable(message, this.logger);
1795
2524
  break;
1796
2525
  }
1797
2526
  }
@@ -1828,6 +2557,30 @@ export class ClaudeAcpAgent {
1828
2557
  }
1829
2558
  }
1830
2559
  }
2560
+ /** Route one orphaned command into the session's orphan-accounting lane:
2561
+ * the per-uuid map on msg_lifecycle_v1 CLIs (drained by the command's own
2562
+ * terminal lifecycle frame and the echo-less-result skip), the plain count
2563
+ * elsewhere (the count lane can't express per-command states, so `state`
2564
+ * only matters on the map lane). Both orphan-producing paths — cancel()'s
2565
+ * queued-turn sweep and the consumer's force-cancel wedge path — must seed
2566
+ * through here so the lane split stays a single mechanism.
2567
+ *
2568
+ * Known window: `msgLifecycleV1` is only learnable from the stream's first
2569
+ * `system`/init (the control-channel initialize carries no capabilities),
2570
+ * so a cancel that beats that drain seeds the COUNT lane on a
2571
+ * lifecycle-capable CLI — where command coalescing can leave the count
2572
+ * stale by N-1 (the pre-map bug, confined to this sub-second window and
2573
+ * still healed by the next activation's reset). Structural until the SDK
2574
+ * exposes capabilities before the stream starts. */
2575
+ trackOrphanCommand(session, uuid, state) {
2576
+ if (session.msgLifecycleV1) {
2577
+ session.orphanCommands ??= new Map();
2578
+ session.orphanCommands.set(uuid, state);
2579
+ }
2580
+ else {
2581
+ session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
2582
+ }
2583
+ }
1831
2584
  async cancel(params) {
1832
2585
  const session = this.sessions[params.sessionId];
1833
2586
  if (!session) {
@@ -1841,26 +2594,117 @@ export class ClaudeAcpAgent {
1841
2594
  return;
1842
2595
  }
1843
2596
  session.cancelled = true;
2597
+ // Capture the orphan-accounting lane before anything can await: the
2598
+ // consumer latches msgLifecycleV1 when it drains the first system/init,
2599
+ // which can happen DURING the awaited interrupt() below — the receipt
2600
+ // reconciliation must act on the same lane the seeding used, or a
2601
+ // count-lane orphan would be left for the map-lane receipt path (which
2602
+ // never decrements the count) to miss.
2603
+ const lifecycleLane = session.msgLifecycleV1 === true;
1844
2604
  // Settle queued turns that haven't started yet (no echo seen) right away —
1845
2605
  // they have no in-flight SDK work to interrupt. The active turn is settled
1846
2606
  // by the consumer when it observes the interrupt's trailing idle (or via the
1847
2607
  // backstop below). Mirrors the old pendingMessages cancellation.
2608
+ const orphanedTurns = [];
1848
2609
  if (session.turnQueue) {
1849
- let orphaned = 0;
1850
2610
  for (const turn of session.turnQueue) {
1851
2611
  if (turn !== session.activeTurn && !turn.settled) {
1852
2612
  turn.settled = true;
2613
+ // Deliberately no `usage`: a queued turn never ran, so the session
2614
+ // accumulator (the active turn's tally) is not its spend.
1853
2615
  turn.resolve({ stopReason: "cancelled" });
1854
- orphaned++;
2616
+ orphanedTurns.push(turn);
1855
2617
  }
1856
2618
  }
1857
2619
  // Each removed queued turn's user message was already pushed to the SDK,
1858
2620
  // which processes input FIFO and will still emit a result for it with no
1859
- // uuid to match. Count those so the consumer skips them (see
2621
+ // uuid to match. Track those so the consumer skips them (see
1860
2622
  // ensureActiveTurn) rather than misattributing them to the head.
1861
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphaned;
2623
+ // msg_lifecycle_v1 CLIs get per-uuid tracking drained by the command's
2624
+ // own terminal lifecycle frame — exact under command coalescing, where
2625
+ // N queued commands fold into ONE turn emitting one result and a plain
2626
+ // count would go stale by N-1 and swallow a later echo-less result.
2627
+ // Older CLIs keep the count and its activation-time self-heal (they
2628
+ // never see lifecycle frames, so commandStarted/commandFinished stay
2629
+ // unset and every turn takes the plain-seed path below).
2630
+ for (const turn of orphanedTurns) {
2631
+ if (turn.commandFinished === "completed" || turn.commandFinished === "discarded") {
2632
+ // The command already finished SDK-side and its terminal frame was
2633
+ // consumed while the turn sat queued — nothing is left to skip, and
2634
+ // a seeded entry would never drain.
2635
+ continue;
2636
+ }
2637
+ if (turn.commandFinished === "cancelled") {
2638
+ // Terminal frame already consumed. Dispatched-then-aborted: the
2639
+ // dead turn's late result may still come — seed the zombie the
2640
+ // frame handler would have made — unless that result already
2641
+ // passed pre-cancel (commandResultSeen: e.g. the command folded
2642
+ // into the active turn and their shared result was attributed
2643
+ // there), in which case a zombie would be a phantom that swallows
2644
+ // an unrelated later result. Never dispatched: dropped, no result
2645
+ // coming, nothing to track.
2646
+ if (turn.commandStarted && !turn.commandResultSeen) {
2647
+ this.trackOrphanCommand(session, turn.promptUuid, "zombie");
2648
+ }
2649
+ continue;
2650
+ }
2651
+ if (turn.commandStarted && turn.commandResultSeen) {
2652
+ // Dispatched and its turn's result already passed; only its
2653
+ // terminal frame is outstanding, which no-ops with no entry.
2654
+ continue;
2655
+ }
2656
+ this.trackOrphanCommand(session, turn.promptUuid, turn.commandStarted ? "started" : "pending");
2657
+ }
1862
2658
  session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
1863
2659
  }
2660
+ // A deferred active turn (see Turn.deferredSettle) already has its
2661
+ // result — it is only held open for its background subagents, which the
2662
+ // interrupt below tears down. Settle it "cancelled" NOW: during the hold
2663
+ // the session is typically already in state idle (the CLI's trailer
2664
+ // fired at the result), so the interrupt may produce no fresh idle for
2665
+ // the consumer's cancelled-settle path to run on, and the cancel would
2666
+ // otherwise stall until the force-cancel backstop. Any outstanding
2667
+ // trailer debt is absorbed by the idle handler when its idle does come.
2668
+ // The turn's own usage snapshot is reported per the cancelled-usage
2669
+ // contract (issue #844).
2670
+ {
2671
+ const active = session.activeTurn;
2672
+ if (isHeldOpen(active)) {
2673
+ active.settled = true;
2674
+ // Mirror settleActive's invariants (it is consumer-scoped and
2675
+ // unreachable from here): disarm the backstop — none should be
2676
+ // armed for a held turn, but a drift here must not leave a timer
2677
+ // firing on a settled turn — and drop the turn from the queue.
2678
+ disarmForceCancel(session);
2679
+ session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== active);
2680
+ session.activeTurn = null;
2681
+ // Settling a held turn closes its delivery stretch: any streamed
2682
+ // text since the last boundary was its followups', and left latched
2683
+ // it would suppress a following replayed turn's issue-#453 fallback.
2684
+ session.emittedAssistantText = false;
2685
+ // When the interrupt below pre-empts a live cycle — running, or
2686
+ // blocked on a permission request (requires_action, the #866 shape
2687
+ // users cancel out of) — it produces a trailer idle with no counted
2688
+ // result; with the hold's own trailer typically already absorbed,
2689
+ // that idle would be un-owed and could lag past the next prompt's
2690
+ // echo — read as the fresh turn ending without a result (issue #825
2691
+ // false-fail). Pre-count it unless the session sits idle: there the
2692
+ // interrupt emits nothing, and a debt that never drains would mask
2693
+ // one future #825 detection. (lastSessionState is last-CONSUMED, so
2694
+ // both stale reads exist and both are accepted one-cycle windows: a
2695
+ // running transition still in the backlog reads as stale idle and
2696
+ // under-counts — that false-fail additionally needs the trailer to
2697
+ // lag past the next echo — while a cycle already completed into the
2698
+ // backlog reads as stale non-idle and over-counts, masking one
2699
+ // future #825 detection. Undefined — no state event consumed —
2700
+ // pre-counts; that only occurs on CLIs whose missing idle events
2701
+ // also disable the detector the debt could mask.)
2702
+ if (session.lastSessionState !== "idle") {
2703
+ session.owedTrailingIdles++;
2704
+ }
2705
+ active.resolve({ stopReason: "cancelled", usage: active.deferredSettle.usage });
2706
+ }
2707
+ }
1864
2708
  // Arm a backstop before interrupting: if a turn is actively consuming the
1865
2709
  // query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
1866
2710
  // block — issue #680), force the consumer to settle the active turn
@@ -1882,7 +2726,48 @@ export class ClaudeAcpAgent {
1882
2726
  cancelController.abort();
1883
2727
  }, this.forceCancelGraceMs);
1884
2728
  }
1885
- await session.query.interrupt();
2729
+ const receipt = await session.query.interrupt();
2730
+ // On CLIs advertising `interrupt_receipt_v1`, the receipt's `still_queued`
2731
+ // lists exactly which queued messages survive the interrupt and will still
2732
+ // run. An orphaned turn whose uuid is absent was dropped by the interrupt
2733
+ // and will never emit a result — uncount it now instead of leaving a stale
2734
+ // skip that activateTurn's reset only clears once a later live ECHO
2735
+ // arrives: an echo-less result in between (a local-only command like
2736
+ // `/context`) would be wrongly swallowed by the leftover count. Subtracting
2737
+ // a count (rather than tracking uuids) stays race-safe against the
2738
+ // consumer draining concurrently: dropped uuids produce no results, so the
2739
+ // consumer's decrements only ever consume the still-queued share. Unknown
2740
+ // uuids in the receipt (internally-enqueued messages) are ignored, per its
2741
+ // contract. Older CLIs resolve `undefined` (guard the FIELD, not just the
2742
+ // receipt, so a bare `{}` success from a gateway can't read as "everything
2743
+ // was dropped") — keep the count-everything behavior and its
2744
+ // activation-time self-heal.
2745
+ if (Array.isArray(receipt?.still_queued) && orphanedTurns.length > 0) {
2746
+ const stillQueued = new Set(receipt.still_queued);
2747
+ if (lifecycleLane) {
2748
+ // Lifecycle lane: forget dropped orphans by uuid. Only entries still
2749
+ // "pending" — an orphan absent from `still_queued` because it was
2750
+ // DISPATCHED before the interrupt (not dropped) has usually been
2751
+ // promoted to "started" by its lifecycle frame by now, and its own
2752
+ // terminal frame must stay in charge of its fate. (If that frame is
2753
+ // still in the consumer's backlog we mis-forget — the same exposure
2754
+ // the count lane has always had for a dropped-then-run command.)
2755
+ // Mostly redundant with the "cancelled"-frame removal, but a receipt
2756
+ // survives paths where that frame was never emitted.
2757
+ for (const turn of orphanedTurns) {
2758
+ if (!stillQueued.has(turn.promptUuid) &&
2759
+ session.orphanCommands?.get(turn.promptUuid) === "pending") {
2760
+ session.orphanCommands.delete(turn.promptUuid);
2761
+ }
2762
+ }
2763
+ }
2764
+ else {
2765
+ const dropped = orphanedTurns.filter((turn) => !stillQueued.has(turn.promptUuid)).length;
2766
+ if (dropped > 0) {
2767
+ session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
2768
+ }
2769
+ }
2770
+ }
1886
2771
  }
1887
2772
  /** Mark a session's SDK query stream as permanently ended and release the
1888
2773
  * resources tied to it: drop the consumer handle, dispose the settings
@@ -1928,10 +2813,7 @@ export class ClaudeAcpAgent {
1928
2813
  // after the floor, and clear the timer so it can't outlive the deleted
1929
2814
  // session (it isn't unref'd and would otherwise keep the event loop alive
1930
2815
  // until it fires).
1931
- if (session.forceCancelTimer) {
1932
- clearTimeout(session.forceCancelTimer);
1933
- session.forceCancelTimer = undefined;
1934
- }
2816
+ disarmForceCancel(session);
1935
2817
  session.cancelController?.abort();
1936
2818
  this.closeQueryStream(session);
1937
2819
  // Abort the SDK abort signal only on explicit destroy. closeQueryStream
@@ -2006,18 +2888,36 @@ export class ClaudeAcpAgent {
2006
2888
  ? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
2007
2889
  : [];
2008
2890
  let validValue = allValues.find((o) => o.value === params.value);
2891
+ // The option's reported currentValue is always a valid target, even when
2892
+ // it has no options entry: a session running an out-of-picker model
2893
+ // (resumed onto an allowlist-excluded model, or a refusal fallback)
2894
+ // reports a currentValue that isn't selectable, and a client
2895
+ // round-tripping it must not get "Invalid value". It flows through the
2896
+ // normal apply path below — re-asserting an already-current value is
2897
+ // harmless and can repair SDK drift.
2898
+ if (!validValue && option.currentValue === params.value) {
2899
+ validValue = { value: params.value, name: params.value };
2900
+ }
2009
2901
  // For model options, fall back to resolveModelPreference when the exact
2010
2902
  // value doesn't match. This lets callers use human-friendly aliases like
2011
2903
  // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
2904
+ // Resolve against session.modelInfos first: those entries carry
2905
+ // `resolvedModel`, so a full model id (in either hint spelling) lands on
2906
+ // the right row via the exact tier instead of a fuzzier one picking a
2907
+ // same-family sibling from a different context lane. The options-derived
2908
+ // list (which never carries `resolvedModel`) remains as a fallback for
2909
+ // resolutions that don't map back onto a selectable option (e.g. a fuzzy
2910
+ // hit on an out-of-picker verbatim entry).
2012
2911
  if (!validValue && params.configId === MODEL_CONFIG_ID) {
2013
- const modelInfos = allValues.map((o) => ({
2014
- value: o.value,
2015
- displayName: o.name,
2016
- description: o.description ?? "",
2017
- }));
2018
- const resolved = resolveModelPreference(modelInfos, params.value);
2019
- if (resolved) {
2020
- validValue = allValues.find((o) => o.value === resolved.value);
2912
+ const toOptionValue = (resolved) => resolved ? allValues.find((o) => o.value === resolved.value) : undefined;
2913
+ validValue = toOptionValue(resolveModelPreference(session.modelInfos, params.value));
2914
+ if (!validValue) {
2915
+ const optionInfos = allValues.map((o) => ({
2916
+ value: o.value,
2917
+ displayName: o.name,
2918
+ description: o.description ?? "",
2919
+ }));
2920
+ validValue = toOptionValue(resolveModelPreference(optionInfos, params.value));
2021
2921
  }
2022
2922
  }
2023
2923
  if (!validValue) {
@@ -2093,6 +2993,12 @@ export class ClaudeAcpAgent {
2093
2993
  if (replaySession && replayMessageId && message.uuid) {
2094
2994
  replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
2095
2995
  }
2996
+ // The live prompt loop converts the synthetic "Please run /login"
2997
+ // assistant message into an authRequired error instead of showing its
2998
+ // TUI-specific text; skip it on replay too (issue #863).
2999
+ if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
3000
+ continue;
3001
+ }
2096
3002
  // @ts-expect-error - untyped in SDK but we handle all of these
2097
3003
  let content = message.message.content;
2098
3004
  // @ts-expect-error - untyped in SDK but we handle all of these
@@ -2131,14 +3037,14 @@ export class ClaudeAcpAgent {
2131
3037
  * outcome or a `requestCancelled` rejection). Either way we surface the same
2132
3038
  * "Tool use aborted" the callers already expect, so a cancelled dialog no
2133
3039
  * longer leaves the `await` hanging. */
2134
- async requestPermissionFromClient(params, toolName, signal) {
3040
+ async requestPermissionFromClient(params, toolName, signal, parentToolUseId) {
2135
3041
  // The SDK may invoke `canUseTool` (and therefore this permission request)
2136
3042
  // before the assistant message's tool_use block streams to us. Some ACP clients
2137
3043
  // expect the `tool_call` a permission request references to already exist,
2138
3044
  // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
2139
3045
  // later refines it with a `tool_call_update` rather than emitting a
2140
3046
  // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
2141
- await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput);
3047
+ await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId);
2142
3048
  try {
2143
3049
  return await this.client.requestPermission(params, signal);
2144
3050
  }
@@ -2155,10 +3061,15 @@ export class ClaudeAcpAgent {
2155
3061
  * instead of emitting a duplicate (see `emittedToolCalls`). Built via the same
2156
3062
  * `toolCallNotification` helper as the streamed path so the two are identical.
2157
3063
  * Tools the stream renders as a plan (TodoWrite) or suppresses (Task*) are
2158
- * skipped so a permission prompt for them never surfaces a stray tool_call. */
2159
- async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput) {
3064
+ * emitted too: a permission request referencing a tool call the client has
3065
+ * never seen can trip strict clients (issue #851), so the reference must
3066
+ * always resolve. Since the streamed path never completes those calls, they
3067
+ * are resolved at tool_result time instead (see `toAcpNotifications`).
3068
+ * `parentToolUseId` attributes a subagent's tool call to the Agent/Task call
3069
+ * that spawned it, matching the streamed path's `_meta`. */
3070
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput, parentToolUseId) {
2160
3071
  const session = this.sessions[sessionId];
2161
- if (!session || !shouldEmitToolCall(toolName)) {
3072
+ if (!session) {
2162
3073
  return;
2163
3074
  }
2164
3075
  if (session.emittedToolCalls.has(toolCallId)) {
@@ -2166,13 +3077,20 @@ export class ClaudeAcpAgent {
2166
3077
  }
2167
3078
  session.emittedToolCalls.add(toolCallId);
2168
3079
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2169
- await this.client.sessionUpdate({
2170
- sessionId,
2171
- update: toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd),
2172
- });
3080
+ const update = toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd);
3081
+ if (parentToolUseId) {
3082
+ update._meta = {
3083
+ ...update._meta,
3084
+ claudeCode: {
3085
+ ...(update._meta?.claudeCode || {}),
3086
+ parentToolUseId,
3087
+ },
3088
+ };
3089
+ }
3090
+ await this.client.sessionUpdate({ sessionId, update });
2173
3091
  }
2174
3092
  canUseTool(sessionId) {
2175
- return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
3093
+ return async (toolName, toolInput, { signal, suggestions, toolUseID, agentID }) => {
2176
3094
  const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
2177
3095
  const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2178
3096
  const session = this.sessions[sessionId];
@@ -2182,6 +3100,23 @@ export class ClaudeAcpAgent {
2182
3100
  message: "Session not found",
2183
3101
  };
2184
3102
  }
3103
+ // When the tool call originates inside a subagent, attribute the eagerly
3104
+ // emitted tool_call (and the permission request itself) to the Agent/Task
3105
+ // tool call that spawned the subagent, mirroring the streamed subagent
3106
+ // path's `_meta.claudeCode.parentToolUseId` (see `liveBackgroundTasks`).
3107
+ const parentToolUseId = agentID
3108
+ ? session.liveBackgroundTasks.get(agentID)?.parentToolUseId
3109
+ : undefined;
3110
+ if (agentID && !parentToolUseId) {
3111
+ // The attribution rests on an undocumented SDK invariant
3112
+ // (task_started.task_id === canUseTool's agentID for subagent tasks;
3113
+ // verified against the bundled CLI). Should an SDK bump break it — or
3114
+ // the consumer lose the race with task_started — the lookup misses and
3115
+ // the request goes out unattributed; log it so the regression is
3116
+ // observable rather than silent.
3117
+ this.logger.log(`[claude-agent-acp] No parent tool_use recorded for subagent ${agentID}; ` +
3118
+ `sending the ${toolName} permission request unattributed`);
3119
+ }
2185
3120
  // AskUserQuestion is surfaced to us as a normal permission check (the SDK
2186
3121
  // routes it through canUseTool whenever a callback is registered, rather
2187
3122
  // than the interactive dialog). Present it as an ACP form elicitation and
@@ -2189,7 +3124,7 @@ export class ClaudeAcpAgent {
2189
3124
  if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
2190
3125
  // Like permission requests, the elicitation references this toolUseID, so
2191
3126
  // make sure the tool_call has surfaced to the client before we send it.
2192
- await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
3127
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput, parentToolUseId);
2193
3128
  return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
2194
3129
  }
2195
3130
  if (toolName === "ExitPlanMode") {
@@ -2223,8 +3158,13 @@ export class ClaudeAcpAgent {
2223
3158
  toolCallId: toolUseID,
2224
3159
  rawInput: toolInput,
2225
3160
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3161
+ // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
3162
+ // so clients can rely on one shape everywhere.
3163
+ ...(parentToolUseId
3164
+ ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
3165
+ : {}),
2226
3166
  },
2227
- }, toolName, signal);
3167
+ }, toolName, signal, parentToolUseId);
2228
3168
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2229
3169
  throw new Error("Tool use aborted");
2230
3170
  }
@@ -2282,8 +3222,13 @@ export class ClaudeAcpAgent {
2282
3222
  toolCallId: toolUseID,
2283
3223
  rawInput: toolInput,
2284
3224
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
3225
+ // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta),
3226
+ // so clients can rely on one shape everywhere.
3227
+ ...(parentToolUseId
3228
+ ? { _meta: { claudeCode: { toolName, parentToolUseId } } }
3229
+ : {}),
2285
3230
  },
2286
- }, toolName, signal);
3231
+ }, toolName, signal, parentToolUseId);
2287
3232
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2288
3233
  throw new Error("Tool use aborted");
2289
3234
  }
@@ -2459,12 +3404,17 @@ export class ClaudeAcpAgent {
2459
3404
  // carries no "1m" token.
2460
3405
  const newModelInfo = session.modelInfos.find((m) => m.value === value);
2461
3406
  if (session.models.currentModelId !== value) {
2462
- // The cached context window was learned for the previous model; reset
2463
- // to the new model's heuristic so mid-stream updates between now and
2464
- // the next `result` reflect the user's selection instead of the old
2465
- // model's window.
3407
+ // The cached context window was learned for the previous model. The
3408
+ // SDK is already running the new model here (user-driven switches call
3409
+ // `query.setModel` before this, and the refusal-fallback sync only
3410
+ // reconciles a switch the SDK already made), so ask it for the new
3411
+ // window; fall back to the text heuristic so mid-stream updates
3412
+ // between now and the next `result` reflect the user's selection
3413
+ // instead of the old model's window.
2466
3414
  session.contextWindowSize =
2467
- inferContextWindowFromModel(value, newModelInfo?.displayName, newModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW;
3415
+ (await fetchContextWindowSize(session.query, this.logger)) ??
3416
+ inferContextWindowFromModel(value, newModelInfo?.resolvedModel, newModelInfo?.displayName, newModelInfo?.description) ??
3417
+ DEFAULT_CONTEXT_WINDOW;
2468
3418
  }
2469
3419
  session.models = { ...session.models, currentModelId: value };
2470
3420
  // Recompute availableModes for the new model and clamp the current
@@ -2966,10 +3916,42 @@ export class ClaudeAcpAgent {
2966
3916
  const allowedModels = Array.isArray(settingsAvailableModels)
2967
3917
  ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
2968
3918
  : initializationResult.models;
2969
- const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger);
3919
+ const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
2970
3920
  // Gate `auto` (and future model-specific modes) on the resolved model's
2971
3921
  // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
2972
- const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
3922
+ // A resumed session can be running a model outside the `availableModels`
3923
+ // allowlist (currentModelId is then the verbatim live id, see
3924
+ // `matchResumedModel`); its capabilities are still known to the SDK's
3925
+ // unfiltered list, so fall back to that before treating the model as
3926
+ // unknown — otherwise auto mode would be spuriously clamped and the
3927
+ // Fast-mode/Effort options hidden for a model that supports them.
3928
+ const allowlistedModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
3929
+ const fallbackModelInfo = allowlistedModelInfo
3930
+ ? undefined
3931
+ : (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined);
3932
+ const currentModelInfo = allowlistedModelInfo ?? fallbackModelInfo;
3933
+ // Register the fallback-resolved capabilities under the verbatim live id
3934
+ // so every modelInfos consumer (buildConfigOptions' effort lookup, later
3935
+ // rebuilds via session.modelInfos) agrees with the gating below. The
3936
+ // picker options themselves come from `models.availableModels`, so this
3937
+ // adds no selectable entry. The spread keeps every capability flag
3938
+ // (current and future); the identity fields are overridden because the
3939
+ // fuzzy-matched sibling's resolvedModel/displayName/description can
3940
+ // describe a different context lane and would poison later resolvedModel
3941
+ // matching (syncModelAfterRefusalFallback) and context-window inference
3942
+ // (applyConfigOptionValue) if they traveled under this id.
3943
+ const modelInfos = fallbackModelInfo
3944
+ ? [
3945
+ ...allowedModels,
3946
+ {
3947
+ ...fallbackModelInfo,
3948
+ value: models.currentModelId,
3949
+ displayName: models.currentModelId,
3950
+ description: "",
3951
+ resolvedModel: undefined,
3952
+ },
3953
+ ]
3954
+ : allowedModels;
2973
3955
  const availableModes = buildAvailableModes(currentModelInfo);
2974
3956
  // Clamp `permissionMode` if the resolved session does not offer it. The
2975
3957
  // common case is `permissions.defaultMode: "auto"` resolving to a model
@@ -3023,7 +4005,7 @@ export class ClaudeAcpAgent {
3023
4005
  enabled: fastModeEnabled,
3024
4006
  useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
3025
4007
  };
3026
- const configOptions = buildConfigOptions(modes, models, allowedModels, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode);
4008
+ const configOptions = buildConfigOptions(modes, models, modelInfos, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode);
3027
4009
  // Apply the initial effort level to the SDK so it matches the UI default
3028
4010
  const initialEffort = configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
3029
4011
  if (initialEffort &&
@@ -3033,6 +4015,22 @@ export class ClaudeAcpAgent {
3033
4015
  effortLevel: initialEffort.currentValue,
3034
4016
  });
3035
4017
  }
4018
+ // Seed the context window from the SDK's authoritative report. Text
4019
+ // inference alone misses aliases that resolve to extended-context models
4020
+ // with no "1m" token anywhere in their id or description (e.g. `sonnet` →
4021
+ // claude-sonnet-5, natively ~1M): those streamed `usage_update.size:
4022
+ // 200000` until the first result's modelUsage corrected it — again on
4023
+ // every process restart or session re-creation, since the learned window
4024
+ // lives only on the Session (issue #596).
4025
+ //
4026
+ // The inference fallback is deliberately keyed to the allowlisted entry: a
4027
+ // fallback-resolved sibling's resolvedModel/displayName/description can
4028
+ // describe a different context lane than the verbatim live id (e.g. an
4029
+ // "opus[1m]" row matched for a bare 200k id), so on the fallback path only
4030
+ // the id itself is a trustworthy window signal.
4031
+ const contextWindowSize = (await fetchContextWindowSize(q, this.logger)) ??
4032
+ inferContextWindowFromModel(models.currentModelId, allowlistedModelInfo?.resolvedModel, allowlistedModelInfo?.displayName, allowlistedModelInfo?.description) ??
4033
+ DEFAULT_CONTEXT_WINDOW;
3036
4034
  this.sessions[sessionId] = {
3037
4035
  query: q,
3038
4036
  input: input,
@@ -3048,17 +4046,20 @@ export class ClaudeAcpAgent {
3048
4046
  },
3049
4047
  modes,
3050
4048
  models,
3051
- modelInfos: allowedModels,
4049
+ modelInfos,
3052
4050
  configOptions,
3053
4051
  agents,
3054
4052
  currentAgent,
3055
4053
  fastModeEnabled,
3056
4054
  abortController,
3057
4055
  emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
3058
- contextWindowSize: inferContextWindowFromModel(models.currentModelId, currentModelInfo?.displayName, currentModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
4056
+ contextWindowSize,
3059
4057
  taskState,
3060
4058
  toolUseCache: {},
3061
4059
  emittedToolCalls: new Set(),
4060
+ liveBackgroundTasks: new Map(),
4061
+ emittedAssistantText: false,
4062
+ owedTrailingIdles: 0,
3062
4063
  messageIdToUuid: new Map(),
3063
4064
  };
3064
4065
  return {
@@ -3391,14 +4392,32 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
3391
4392
  // Claude Code CLI persists display strings like "opus[1m]" in settings,
3392
4393
  // but the SDK model list uses IDs like "claude-opus-4-6-1m".
3393
4394
  const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
4395
+ // The id-suffix spelling of a context hint ("-1m" in "claude-opus-4-6-1m");
4396
+ // shared by the strip and canonicalize helpers below so the two can't drift.
4397
+ const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
4398
+ /** Remove context-window hints — the display form "[1m]" and the SDK id
4399
+ * suffix form "-1m" — from a model string. Those digits describe context
4400
+ * size, not model identity or generation version. */
4401
+ function stripContextHints(s) {
4402
+ return s.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
4403
+ }
4404
+ /** Canonicalize a model id for exact comparison: trimmed, lowercased, with
4405
+ * the id-suffix hint spelling unified to the bracket form ("-1m" → "[1m]").
4406
+ * The hint itself is kept — bare and 1M ids must stay distinct. */
4407
+ function canonicalizeModelId(s) {
4408
+ return s.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
4409
+ }
4410
+ /** The context hint a model string carries ("1m" for either spelling), or
4411
+ * null for a bare id. */
4412
+ function contextHintOf(s) {
4413
+ return canonicalizeModelId(s).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
4414
+ }
3394
4415
  // Captures a model family version: `4-6`/`4.7` for dated generations, or a
3395
4416
  // bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned
3396
4417
  // `claude-opus-4-6` from matching the `opus` alias once it points at 4.7.
3397
4418
  const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
3398
4419
  function extractModelFamilyVersion(s) {
3399
- // Strip "[1m]"-style context hints first — that digit is context window
3400
- // size, not a model generation version.
3401
- const match = s.replace(/\[\d+m\]/gi, "").match(MODEL_FAMILY_VERSION_PATTERN);
4420
+ const match = stripContextHints(s).match(MODEL_FAMILY_VERSION_PATTERN);
3402
4421
  if (!match)
3403
4422
  return null;
3404
4423
  return match[2] ? `${match[1]}.${match[2]}` : match[1];
@@ -3451,25 +4470,38 @@ export function resolveModelPreference(models, preference) {
3451
4470
  if (!trimmed)
3452
4471
  return null;
3453
4472
  const lower = trimmed.toLowerCase();
3454
- // Exact match on value or display name
4473
+ // Exact match on value or display name. Values compare on the canonical
4474
+ // hint spelling so "opus-1m" hits an "opus[1m]" row (and vice versa).
4475
+ const canonicalPreference = canonicalizeModelId(trimmed);
3455
4476
  const directMatch = models.find((model) => model.value === trimmed ||
3456
- model.value.toLowerCase() === lower ||
4477
+ canonicalizeModelId(model.value) === canonicalPreference ||
3457
4478
  model.displayName.toLowerCase() === lower);
3458
4479
  if (directMatch)
3459
4480
  return directMatch;
3460
4481
  // Exact match on the alias's canonical resolved id (e.g. a pinned
3461
4482
  // "claude-sonnet-5" against the "sonnet" row's `resolvedModel`). SDK-
3462
4483
  // reported and unambiguous, so it's tried before the fuzzier tiers below.
3463
- // "default" is skipped first since it shares a resolvedModel with
3464
- // whichever alias the CLI currently recommends — a specific pin should
3465
- // land on that named alias, not "default".
3466
- const resolvedMatch = models.find((model) => model.value !== "default" && model.resolvedModel?.toLowerCase() === lower) ?? models.find((model) => model.resolvedModel?.toLowerCase() === lower);
4484
+ // Compared on the canonical hint spelling so a "-1m"-suffix pin matches a
4485
+ // "[1m]"-spelled resolvedModel instead of falling into the substring tier
4486
+ // (which would land on the bare 200k sibling). "default" is skipped first
4487
+ // since it shares a resolvedModel with whichever alias the CLI currently
4488
+ // recommends — a specific pin should land on that named alias, not
4489
+ // "default".
4490
+ const matchesResolved = (model) => model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
4491
+ const resolvedMatch = models.find((model) => model.value !== "default" && matchesResolved(model)) ??
4492
+ models.find(matchesResolved);
3467
4493
  if (resolvedMatch)
3468
4494
  return resolvedMatch;
3469
- // Substring match
4495
+ // Substring match. Skips candidates whose context hint disagrees with the
4496
+ // preference's — a bare row must not absorb a 1M-hinted preference (nor
4497
+ // vice versa); such pairs fall through to the tokenized tier, which
4498
+ // weighs hints in its scoring and still finds the best same-family row.
4499
+ const preferenceHint = contextHintOf(trimmed);
3470
4500
  const includesMatch = models.find((model) => {
3471
4501
  if (!modelVersionsCompatible(trimmed, model))
3472
4502
  return false;
4503
+ if (contextHintOf(model.value) !== preferenceHint)
4504
+ return false;
3473
4505
  const value = model.value.toLowerCase();
3474
4506
  const display = model.displayName.toLowerCase();
3475
4507
  return value.includes(lower) || display.includes(lower) || lower.includes(value);
@@ -3493,6 +4525,51 @@ export function resolveModelPreference(models, preference) {
3493
4525
  }
3494
4526
  return bestMatch;
3495
4527
  }
4528
+ /** Map the live model reported by a resumed session onto the picker's model
4529
+ * list. The CLI restores a resumed session's model from the transcript's
4530
+ * last assistant message, which records the concrete API id (e.g.
4531
+ * "claude-opus-4-6") with any "[1m]" context hint dropped. Tiers, in order:
4532
+ * 1. Exact match with the Default entry's resolution — when a named alias
4533
+ * shares Default's resolvedModel verbatim, the live id can't tell the
4534
+ * two apart, and a never-customized session should stay on Default.
4535
+ * 2. Exact resolvedModel match on a named row. Checked before the
4536
+ * hint-stripped Default comparison so a live "claude-sonnet-5[1m]" lands
4537
+ * on the "sonnet[1m]" row rather than a Default that resolves to the
4538
+ * bare "claude-sonnet-5" — the two rows differ in context window, which
4539
+ * drives `contextWindowSize` and capability gating downstream.
4540
+ * 3. Hint-stripped match with Default's resolution — a session that never
4541
+ * left the default resumes as the bare transcript id, and shouldn't show
4542
+ * a concrete picker entry.
4543
+ * 4. `resolveModelPreference` over the picker entries.
4544
+ * 5. A model with no picker counterpart (e.g. excluded by an
4545
+ * `availableModels` allowlist) is tracked verbatim, mirroring
4546
+ * `syncModelAfterRefusalFallback`: the picker shows no selection, but the
4547
+ * model-dependent bookkeeping stays truthful to what the SDK is running. */
4548
+ export function matchResumedModel(models, liveModel) {
4549
+ const live = canonicalizeModelId(liveModel);
4550
+ const defaultEntry = models.find((m) => m.value === "default");
4551
+ const defaultResolved = defaultEntry?.resolvedModel
4552
+ ? canonicalizeModelId(defaultEntry.resolvedModel)
4553
+ : undefined;
4554
+ if (defaultEntry && defaultResolved === live) {
4555
+ return defaultEntry;
4556
+ }
4557
+ // No default-row exclusion needed: a default row matching `live` exactly
4558
+ // already returned at the tier above.
4559
+ const exactMatch = models.find((m) => m.resolvedModel && canonicalizeModelId(m.resolvedModel) === live);
4560
+ if (exactMatch)
4561
+ return exactMatch;
4562
+ if (defaultEntry &&
4563
+ defaultResolved &&
4564
+ stripContextHints(defaultResolved) === stripContextHints(live)) {
4565
+ return defaultEntry;
4566
+ }
4567
+ return (resolveModelPreference(models, liveModel) ?? {
4568
+ value: liveModel,
4569
+ displayName: liveModel,
4570
+ description: "",
4571
+ });
4572
+ }
3496
4573
  function resolveSettingsModel(models, settingsModel, logger) {
3497
4574
  if (settingsModel === undefined) {
3498
4575
  return null;
@@ -3572,14 +4649,30 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
3572
4649
  }
3573
4650
  return result;
3574
4651
  }
3575
- async function getAvailableModels(query, models, sdkModels, settingsManager, logger) {
4652
+ /** Read the model a resumed session is actually running (via the
4653
+ * `getContextUsage` control request — the same source `/context` prints) and
4654
+ * map it onto the picker. Best-effort: a control-request failure is logged
4655
+ * and returns null so callers keep their current choice; failing the whole
4656
+ * session/load over an unreadable report would be worse. */
4657
+ async function readResumedLiveModel(query, models, logger) {
4658
+ try {
4659
+ const liveModel = (await query.getContextUsage()).model;
4660
+ return liveModel ? matchResumedModel(models, liveModel) : null;
4661
+ }
4662
+ catch (error) {
4663
+ logger.error("Failed to read the resumed session's live model:", error);
4664
+ return null;
4665
+ }
4666
+ }
4667
+ async function getAvailableModels(query, models, sdkModels, settingsManager, logger, isResumedSession) {
3576
4668
  const settings = settingsManager.getSettings();
3577
4669
  let currentModel = models[0];
3578
4670
  let resolvedFromInput;
3579
4671
  // Model priority (highest to lowest):
3580
4672
  // 1. ANTHROPIC_MODEL environment variable
3581
4673
  // 2. settings.model (user configuration)
3582
- // 3. models[0] (default first model)
4674
+ // 3. the resumed session's live model (resumed sessions only)
4675
+ // 4. models[0] (default first model)
3583
4676
  if (process.env.ANTHROPIC_MODEL) {
3584
4677
  const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
3585
4678
  if (match) {
@@ -3594,21 +4687,49 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
3594
4687
  resolvedFromInput = settings.model;
3595
4688
  }
3596
4689
  }
4690
+ // A resumed session restores the model it was previously running (the CLI
4691
+ // re-reads it from the transcript), so without an env/settings override the
4692
+ // freshly-computed default above can disagree with what the session actually
4693
+ // runs — session/load then reports a model the session isn't using (issue
4694
+ // #845). Ask the CLI for the live model and reflect it. No `setModel` here:
4695
+ // the SDK is already running this model, and pushing a picker alias back
4696
+ // (e.g. "opus[1m]") could change the live model rather than describe it.
4697
+ if (resolvedFromInput === undefined && isResumedSession) {
4698
+ currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
4699
+ }
3597
4700
  // Skip the setModel round-trip when we can prove the SDK has already landed
3598
4701
  // on the same model. Two cases qualify:
3599
- // (a) No override applied — currentModel stayed at models[0]; the SDK is on
3600
- // its own default and we have nothing to sync.
4702
+ // (a) No override applied — currentModel is the SDK's own default (or, on
4703
+ // resume, the live model read back from the SDK above); nothing to sync.
3601
4704
  // (b) The resolver returned the user's input verbatim AND that value exists
3602
4705
  // in the SDK's original model list — meaning no fuzzy match or
3603
4706
  // allowlist rewrite was involved, and the SDK (which reads the same
3604
4707
  // ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
4708
+ // This only holds for fresh sessions: a resumed session lands on the
4709
+ // transcript's model regardless of env/settings, so the override must
4710
+ // be re-asserted to keep the reported model truthful.
3605
4711
  // Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
3606
4712
  // setModel call so we don't drift from the user's intended pin.
3607
4713
  const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
3608
4714
  const skipSetModel = resolvedFromInput === undefined ||
3609
- (currentModel.value === resolvedFromInput && sdkSawSameValue);
4715
+ (!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
3610
4716
  if (!skipSetModel) {
3611
- await query.setModel(currentModel.value);
4717
+ try {
4718
+ await query.setModel(currentModel.value);
4719
+ }
4720
+ catch (error) {
4721
+ // On a fresh session the pin is a defining option — fail loudly. A
4722
+ // resumed session already runs fine on the transcript's model, so
4723
+ // failing the whole session/load over the re-assert would be worse
4724
+ // than loading with the pin unapplied (mirrors the setPermissionMode
4725
+ // containment in createSession). The SDK then stayed on the
4726
+ // transcript's model, so read that back rather than reporting the
4727
+ // pin the session isn't running.
4728
+ if (!isResumedSession)
4729
+ throw error;
4730
+ logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
4731
+ currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
4732
+ }
3612
4733
  }
3613
4734
  return {
3614
4735
  availableModels: models.map((model) => ({
@@ -3790,10 +4911,12 @@ function isTaskTool(toolName) {
3790
4911
  toolName === "TaskList" ||
3791
4912
  toolName === "TaskGet");
3792
4913
  }
3793
- /** Whether a tool's tool_use surfaces to the client as a standalone
4914
+ /** Whether the streamed tool_use path surfaces this tool as a standalone
3794
4915
  * `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
3795
4916
  * suppressed (their plan snapshot is emitted at tool_result time), so neither
3796
- * produces a tool_call. */
4917
+ * produces a streamed tool_call/tool_call_update — which means a
4918
+ * permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
4919
+ * resolved explicitly at tool_result time. */
3797
4920
  function shouldEmitToolCall(toolName) {
3798
4921
  return toolName !== "TodoWrite" && !isTaskTool(toolName);
3799
4922
  }
@@ -3828,6 +4951,28 @@ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, re
3828
4951
  ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
3829
4952
  };
3830
4953
  }
4954
+ /** Refine a pending tool call from the complete top-level fields recovered
4955
+ * from its still-streaming input. Shares `toolInfoFromToolUse` with the
4956
+ * consolidated path but never carries `content`: content built from partial
4957
+ * input is misleading (an Edit missing its `new_string` renders as a pure
4958
+ * deletion) or invalid (a Write diff without `content` lacks the required
4959
+ * `newText`), and the consolidated message supplies it moments later. */
4960
+ function streamedInputRefinement(toolUse, input, supportsTerminalOutput, cwd) {
4961
+ // TodoWrite/Task* never surfaced a tool_call to refine (plan lane).
4962
+ if (!shouldEmitToolCall(toolUse.name)) {
4963
+ return undefined;
4964
+ }
4965
+ const { title, kind, locations } = toolInfoFromToolUse({ ...toolUse, input }, supportsTerminalOutput, cwd);
4966
+ return {
4967
+ _meta: { claudeCode: { toolName: toolUse.name } },
4968
+ toolCallId: toolUse.id,
4969
+ sessionUpdate: "tool_call_update",
4970
+ rawInput: input,
4971
+ title,
4972
+ kind,
4973
+ ...(locations ? { locations } : {}),
4974
+ };
4975
+ }
3831
4976
  /**
3832
4977
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
3833
4978
  * Only handles text, image, and thinking chunks for now.
@@ -3859,14 +5004,23 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3859
5004
  }
3860
5005
  return [{ sessionId, update }];
3861
5006
  }
5007
+ // `tool_use_result` is message-level and carries no tool_use_id of its own:
5008
+ // it describes "the" tool_result block of the message it rode in on. If
5009
+ // several tool_result blocks were ever batched into one message it couldn't
5010
+ // be attributed, so it is only honored when the message carries exactly one.
5011
+ const toolUseResult = options?.toolUseResult !== undefined &&
5012
+ content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result")
5013
+ .length === 1
5014
+ ? options.toolUseResult
5015
+ : undefined;
3862
5016
  const output = [];
3863
5017
  // Only handle the first chunk for streaming; extend as needed for batching
3864
5018
  for (const chunk of content) {
3865
5019
  let update = null;
3866
5020
  switch (chunk.type) {
3867
5021
  case "text":
3868
- case "text_delta":
3869
- if (chunk.text.length > 0) {
5022
+ case "text_delta": {
5023
+ if (chunk.text) {
3870
5024
  update = {
3871
5025
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
3872
5026
  content: {
@@ -3876,6 +5030,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3876
5030
  };
3877
5031
  }
3878
5032
  break;
5033
+ }
3879
5034
  case "image":
3880
5035
  update = {
3881
5036
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
@@ -3888,10 +5043,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3888
5043
  };
3889
5044
  break;
3890
5045
  case "thinking":
3891
- case "thinking_delta":
5046
+ case "thinking_delta": {
3892
5047
  // Recent models default `thinking.display` to "omitted", which streams
3893
5048
  // signature-only thinking blocks whose text is empty.
3894
- if (chunk.thinking.length > 0) {
5049
+ if (chunk.thinking) {
3895
5050
  update = {
3896
5051
  sessionUpdate: "agent_thought_chunk",
3897
5052
  content: {
@@ -3901,6 +5056,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3901
5056
  };
3902
5057
  }
3903
5058
  break;
5059
+ }
3904
5060
  case "tool_use":
3905
5061
  case "server_tool_use":
3906
5062
  case "mcp_tool_use": {
@@ -3998,12 +5154,59 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3998
5154
  case "bash_code_execution_tool_result":
3999
5155
  case "text_editor_code_execution_tool_result":
4000
5156
  case "mcp_tool_result": {
5157
+ const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
4001
5158
  options?.emittedToolCalls?.delete(chunk.tool_use_id);
4002
5159
  const toolUse = toolUseCache[chunk.tool_use_id];
4003
5160
  if (!toolUse) {
5161
+ // The permission flow may have surfaced this tool_call even though
5162
+ // its tool_use never reached the cache (e.g. the assistant message
5163
+ // carrying it was dropped by the cancelled-turn guard and a straggler
5164
+ // result landed later). Resolve the surfaced call anyway so it can't
5165
+ // stay pending in the client forever; without the cache entry the
5166
+ // tool name is unknown, so no claudeCode meta is attached.
5167
+ if (wasEmitted) {
5168
+ output.push({
5169
+ sessionId,
5170
+ update: {
5171
+ toolCallId: chunk.tool_use_id,
5172
+ sessionUpdate: "tool_call_update",
5173
+ status: "is_error" in chunk && chunk.is_error
5174
+ ? "failed"
5175
+ : "completed",
5176
+ rawOutput: chunk.content,
5177
+ },
5178
+ });
5179
+ }
4004
5180
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
4005
5181
  break;
4006
5182
  }
5183
+ // A permission request may have surfaced a plan-rendered (TodoWrite) or
5184
+ // suppressed (Task*) tool as a real tool_call so the request referenced
5185
+ // a tool call the client knows about (see `ensureToolCallEmitted`,
5186
+ // issue #851). The branches below never emit a tool_call_update for
5187
+ // those tools, which would leave the surfaced call pending in the
5188
+ // client forever — resolve it here. `wasEmitted` is only ever true for
5189
+ // these tools via the permission flow: the streamed plan/suppressed
5190
+ // branches don't record emissions.
5191
+ if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
5192
+ output.push({
5193
+ sessionId,
5194
+ update: {
5195
+ _meta: {
5196
+ claudeCode: {
5197
+ toolName: toolUse.name,
5198
+ ...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
5199
+ },
5200
+ },
5201
+ toolCallId: chunk.tool_use_id,
5202
+ sessionUpdate: "tool_call_update",
5203
+ status: "is_error" in chunk && chunk.is_error
5204
+ ? "failed"
5205
+ : "completed",
5206
+ rawOutput: chunk.content,
5207
+ },
5208
+ });
5209
+ }
4007
5210
  if (isTaskTool(toolUse.name)) {
4008
5211
  // Headless/SDK sessions emit Task* tools instead of TodoWrite.
4009
5212
  // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
@@ -4027,7 +5230,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4027
5230
  }
4028
5231
  }
4029
5232
  else if (toolUse.name !== "TodoWrite") {
4030
- const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput);
5233
+ const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(chunk, toolUseCache[chunk.tool_use_id], supportsTerminalOutput, toolUseResult);
4031
5234
  // When terminal output is supported, send terminal_output as a
4032
5235
  // separate notification to match codex-acp's streaming lifecycle:
4033
5236
  // 1. tool_call → _meta.terminal_info (already sent above)
@@ -4104,35 +5307,105 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
4104
5307
  }
4105
5308
  export function streamEventToAcpNotifications(message, sessionId, toolUseCache, client, logger, options) {
4106
5309
  const event = message.event;
5310
+ const streamKey = message.parent_tool_use_id ?? "";
5311
+ const streamedToolInputs = options?.streamedToolInputs;
5312
+ const forwardedOptions = {
5313
+ clientCapabilities: options?.clientCapabilities,
5314
+ parentToolUseId: message.parent_tool_use_id,
5315
+ cwd: options?.cwd,
5316
+ taskState: options?.taskState,
5317
+ emittedToolCalls: options?.emittedToolCalls,
5318
+ messageId: options?.messageId,
5319
+ };
4107
5320
  switch (event.type) {
4108
- case "content_block_start":
4109
- return toAcpNotifications([event.content_block], "assistant", sessionId, toolUseCache, client, logger, {
4110
- clientCapabilities: options?.clientCapabilities,
4111
- parentToolUseId: message.parent_tool_use_id,
4112
- cwd: options?.cwd,
4113
- taskState: options?.taskState,
4114
- emittedToolCalls: options?.emittedToolCalls,
4115
- messageId: options?.messageId,
4116
- });
4117
- case "content_block_delta":
4118
- return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, {
4119
- clientCapabilities: options?.clientCapabilities,
4120
- parentToolUseId: message.parent_tool_use_id,
4121
- cwd: options?.cwd,
4122
- taskState: options?.taskState,
4123
- emittedToolCalls: options?.emittedToolCalls,
4124
- messageId: options?.messageId,
4125
- });
5321
+ case "content_block_start": {
5322
+ const block = event.content_block;
5323
+ if (streamedToolInputs &&
5324
+ (block.type === "tool_use" ||
5325
+ block.type === "server_tool_use" ||
5326
+ block.type === "mcp_tool_use")) {
5327
+ let inputsForMessage = streamedToolInputs.get(streamKey);
5328
+ if (!inputsForMessage) {
5329
+ inputsForMessage = new Map();
5330
+ streamedToolInputs.set(streamKey, inputsForMessage);
5331
+ }
5332
+ inputsForMessage.set(event.index, {
5333
+ id: block.id,
5334
+ name: block.name,
5335
+ partialJson: "",
5336
+ scannedTo: 0,
5337
+ inString: false,
5338
+ escaped: false,
5339
+ objectDepth: 0,
5340
+ arrayDepth: 0,
5341
+ lastTopLevelComma: -1,
5342
+ emittedThroughComma: -1,
5343
+ });
5344
+ }
5345
+ return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
5346
+ }
5347
+ case "content_block_delta": {
5348
+ if (event.delta.type === "input_json_delta") {
5349
+ const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
5350
+ if (!streamedInput)
5351
+ return [];
5352
+ streamedInput.partialJson += event.delta.partial_json;
5353
+ if (scanStreamedToolInput(streamedInput)) {
5354
+ // Input complete: the consolidated assistant message replays the
5355
+ // block with its full input and refines the call there; emitting
5356
+ // here too would send a duplicate identical update.
5357
+ const inputsForMessage = streamedToolInputs?.get(streamKey);
5358
+ inputsForMessage?.delete(event.index);
5359
+ if (inputsForMessage?.size === 0)
5360
+ streamedToolInputs?.delete(streamKey);
5361
+ return [];
5362
+ }
5363
+ if (streamedInput.lastTopLevelComma <= streamedInput.emittedThroughComma) {
5364
+ return [];
5365
+ }
5366
+ streamedInput.emittedThroughComma = streamedInput.lastTopLevelComma;
5367
+ const input = recoveredToolInput(streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma));
5368
+ if (!input)
5369
+ return [];
5370
+ const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
5371
+ const update = streamedInputRefinement(streamedInput, input, supportsTerminalOutput, options?.cwd);
5372
+ if (!update)
5373
+ return [];
5374
+ if (message.parent_tool_use_id) {
5375
+ update._meta = {
5376
+ ...update._meta,
5377
+ claudeCode: {
5378
+ ...(update._meta?.claudeCode || {}),
5379
+ parentToolUseId: message.parent_tool_use_id,
5380
+ },
5381
+ };
5382
+ }
5383
+ applyMessageId(update, options?.messageId);
5384
+ return [{ sessionId, update }];
5385
+ }
5386
+ return toAcpNotifications([event.delta], "assistant", sessionId, toolUseCache, client, logger, forwardedOptions);
5387
+ }
4126
5388
  // No content. `ping` is a Messages-API keep-alive event that the SDK's
4127
5389
  // `BetaRawMessageStreamEvent` union doesn't include even though the
4128
5390
  // wire format emits it; the `as never` cast lets us no-op it here
4129
5391
  // instead of letting it fall through to `unreachable`.
4130
5392
  case "ping":
4131
- case "message_start":
4132
5393
  case "message_delta":
5394
+ return [];
5395
+ // A message boundary ends every input stream on this lane: message_stop is
5396
+ // the normal end, and a message_start clears anything a prior message on
5397
+ // the lane left behind (e.g. a stream cut short mid-block).
5398
+ case "message_start":
4133
5399
  case "message_stop":
4134
- case "content_block_stop":
5400
+ streamedToolInputs?.delete(streamKey);
5401
+ return [];
5402
+ case "content_block_stop": {
5403
+ const inputsForMessage = streamedToolInputs?.get(streamKey);
5404
+ inputsForMessage?.delete(event.index);
5405
+ if (inputsForMessage?.size === 0)
5406
+ streamedToolInputs?.delete(streamKey);
4135
5407
  return [];
5408
+ }
4136
5409
  default:
4137
5410
  unreachable(event, logger);
4138
5411
  return [];
@@ -4211,12 +5484,14 @@ function commonPrefixLength(a, b) {
4211
5484
  * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
4212
5485
  * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
4213
5486
  * matching things like "10m" or embedded substrings. Semantic aliases like
4214
- * `default` carry no such token in the ID, but the SDK's human-facing
4215
- * `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
4216
- * pass those too — the same `\b1m\b` token appears in "1M context". The SDK's
4217
- * `ModelInfo` exposes no structured context-window field, so this text scan is
4218
- * the only pre-`result` signal available. A miss falls back to the default
4219
- * window and is corrected by `result.modelUsage` within one turn. */
5487
+ * `default` carry no such token in the ID, but their `resolvedModel` and the
5488
+ * SDK's human-facing `displayName`/`description` can (e.g.
5489
+ * "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
5490
+ * This text scan can't catch every model — some resolve to extended-context
5491
+ * models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
5492
+ * ~1M) — which is why `fetchContextWindowSize` is preferred wherever a live
5493
+ * query is available. A miss falls back to the default window and is
5494
+ * corrected by `result.modelUsage` within one turn. */
4220
5495
  function inferContextWindowFromModel(...texts) {
4221
5496
  if (texts.some((text) => text != null && /\b1m\b/i.test(text)))
4222
5497
  return 1_000_000;
@@ -4226,12 +5501,7 @@ function inferContextWindowFromModel(...texts) {
4226
5501
  * `getContextUsage` control request. Unlike the per-message API usage numbers
4227
5502
  * (which only count message tokens), this `totalTokens` includes the system
4228
5503
  * prompt, tool schemas, MCP tools, and memory-file overhead — the real
4229
- * occupancy the user sees. Returns `null` on any control-request failure.
4230
- *
4231
- * Note: we deliberately do NOT use this response's window fields for `size`.
4232
- * They have been observed to under-report extended (1M) context windows, so
4233
- * the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
4234
- * which handle the 1M variants correctly. */
5504
+ * occupancy the user sees. Returns `null` on any control-request failure. */
4235
5505
  async function fetchContextUsedTokens(query, logger) {
4236
5506
  try {
4237
5507
  const usage = await query.getContextUsage();
@@ -4242,6 +5512,28 @@ async function fetchContextUsedTokens(query, logger) {
4242
5512
  return null;
4243
5513
  }
4244
5514
  }
5515
+ /** Fetch the current model's full context window (`rawMaxTokens`) via the
5516
+ * `getContextUsage` control request — the same source `/context` prints.
5517
+ * This is the only pre-`result` signal that covers semantic aliases whose
5518
+ * text carries no "1m" token (e.g. `sonnet` → claude-sonnet-5, natively
5519
+ * ~1M), so it's the primary window source at session creation and on model
5520
+ * switches; `inferContextWindowFromModel` remains the fallback. A returned
5521
+ * window is still superseded by each `result.modelUsage.contextWindow`.
5522
+ *
5523
+ * (Older CLIs under-reported extended 1M windows here — commit 20ef663
5524
+ * dropped the field for that reason — but the CLI vendored by the pinned
5525
+ * SDK reports them correctly again.) Returns `null` on any control-request
5526
+ * failure or a nonsensical (non-positive) window. */
5527
+ async function fetchContextWindowSize(query, logger) {
5528
+ try {
5529
+ const usage = await query.getContextUsage();
5530
+ return usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null;
5531
+ }
5532
+ catch (error) {
5533
+ logger.error("Failed to fetch context window size from SDK:", error);
5534
+ return null;
5535
+ }
5536
+ }
4245
5537
  /** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
4246
5538
  * option. The `maxThinkingTokens` option it used to feed is deprecated and
4247
5539
  * reduced to on/off on current models, so map the value to explicit thinking