claude-code-acp-ts 0.53.0-beta.1 → 0.57.0-beta.2

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
@@ -7,7 +7,7 @@ import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { promisify } from "node:util";
9
9
  import packageJson from "../package.json" with { type: "json" };
10
- import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, mcpElicitationToCreateRequest, } from "./elicitation.js";
10
+ import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
11
11
  import { SettingsManager } from "./settings.js";
12
12
  import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
13
13
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
@@ -42,6 +42,12 @@ const DEFAULT_CONTEXT_WINDOW = 200000;
42
42
  * "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
43
43
  * pre-empt a slow-but-healthy interrupt. */
44
44
  const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
45
+ /** Error surfaced when the SDK declares a turn over (`session_state_changed:
46
+ * idle`, its authoritative turn-over signal) without ever emitting the turn's
47
+ * `result` — a model stream that dropped mid-turn, or an async agent that
48
+ * completed/stalled without the host turn resolving (issue #825). */
49
+ const TURN_NO_RESULT_MESSAGE = "The turn ended without a result: the agent went idle while this prompt was still in flight " +
50
+ "(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
45
51
  /** Compute a stable fingerprint of the session-defining params so we can
46
52
  * detect when a loadSession/resumeSession call requires tearing down and
47
53
  * recreating the underlying Query process. MCP servers are sorted by name
@@ -185,6 +191,9 @@ export function isLocalCommandMetadata(content) {
185
191
  const PERMISSION_MODE_ALIASES = {
186
192
  auto: "auto",
187
193
  default: "default",
194
+ // Claude Code 2.1.200 renamed the "default" mode to "Manual" and accepts
195
+ // `"defaultMode": "manual"` in settings.json; honor the same alias here.
196
+ manual: "default",
188
197
  acceptedits: "acceptEdits",
189
198
  dontask: "dontAsk",
190
199
  plan: "plan",
@@ -660,6 +669,20 @@ export class ClaudeAcpAgent {
660
669
  // Stop reason accumulated for the active turn (result subtype, refusal,
661
670
  // max_tokens, …). Reset per turn; read when the turn settles at idle.
662
671
  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;
663
686
  const resetTurnScratch = () => {
664
687
  lastAssistantTotalUsage = null;
665
688
  lastAssistantUsage = null;
@@ -780,9 +803,19 @@ export class ClaudeAcpAgent {
780
803
  // settle "cancelled" even when query.next() is wedged (issue #680). Re-armed
781
804
  // after each fire so the consumer keeps serving later turns.
782
805
  let cancelController = session.cancelController;
806
+ // The in-flight query.next(), kept across abort wake-ups that don't
807
+ // consume a message, so no yielded message is ever dropped — async
808
+ // generators serialize next() calls, so racing a SECOND next() while one
809
+ // is pending would make the abandoned one swallow a message (e.g. a
810
+ // force-cancelled turn's late result, whose orphan accounting below
811
+ // depends on actually seeing it).
812
+ let pendingNext = null;
783
813
  try {
784
814
  while (true) {
785
- const nextMessage = session.query.next();
815
+ pendingNext ??= session.query
816
+ .next()
817
+ .then((result) => ({ kind: "message", result }));
818
+ const nextMessage = pendingNext;
786
819
  // Fresh abort listener per iteration, removed when next() wins, so a
787
820
  // long-lived session doesn't accumulate listeners on one signal.
788
821
  let onAbort;
@@ -790,25 +823,36 @@ export class ClaudeAcpAgent {
790
823
  onAbort = () => resolve("abort");
791
824
  cancelController.signal.addEventListener("abort", onAbort, { once: true });
792
825
  });
793
- const raced = await Promise.race([
794
- nextMessage.then((result) => ({ kind: "message", result })),
795
- abortRace,
796
- ]);
826
+ const raced = await Promise.race([nextMessage, abortRace]);
797
827
  cancelController.signal.removeEventListener("abort", onAbort);
798
828
  if (raced === "abort") {
799
- // cancel()/teardown woke us. Abandon the in-flight next() (swallowing
800
- // any later rejection so it can't surface as unhandled) and settle the
801
- // active turn "cancelled" per the ACP contract. If the session is
802
- // being torn down, stop; otherwise re-arm and keep consuming.
803
- void nextMessage.catch(() => { });
804
- settleActive({ stopReason: "cancelled" });
829
+ // cancel()/teardown woke us: settle the active turn "cancelled" per
830
+ // the ACP contract. The SDK never acknowledged this turn (that's why
831
+ // the force-cancel backstop fired), so if it later recovers from the
832
+ // wedge it will still emit the turn's result — with no live turn to
833
+ // match — followed by its trailing idle. Pre-count it as an orphan
834
+ // so that late result is skipped (not promoted onto the next queued
835
+ // prompt) and its trailer is recorded as owed, not read as the next
836
+ // turn being abandoned. Stale counts self-heal: activation resets
837
+ // them (see activateTurn).
838
+ if (session.activeTurn && !session.activeTurn.settled) {
839
+ session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
840
+ }
841
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
842
+ // If the session is being torn down, abandon the in-flight next()
843
+ // (swallowing any later rejection so it can't surface as unhandled)
844
+ // and stop; otherwise re-arm and keep consuming — `pendingNext`
845
+ // stays in flight so its eventual message is processed, not dropped.
805
846
  if (!this.sessions[params.sessionId]) {
847
+ void nextMessage.catch(() => { });
806
848
  return;
807
849
  }
808
850
  cancelController = new AbortController();
809
851
  session.cancelController = cancelController;
810
852
  continue;
811
853
  }
854
+ // A message arrived: this next() is consumed; arm a fresh one next pass.
855
+ pendingNext = null;
812
856
  const { value: message, done } = raced.result;
813
857
  if (done || !message) {
814
858
  // The stream ended. Settle the in-flight turns FIRST, then release the
@@ -819,9 +863,10 @@ export class ClaudeAcpAgent {
819
863
  //
820
864
  // Settle the turn that was in flight so its prompt() doesn't hang:
821
865
  // cancelled if a cancel is pending, otherwise the accumulated outcome.
822
- settleActive(session.cancelled
823
- ? { stopReason: "cancelled" }
824
- : { stopReason, usage: sessionUsage(session) });
866
+ settleActive({
867
+ stopReason: session.cancelled ? "cancelled" : stopReason,
868
+ usage: sessionUsage(session),
869
+ });
825
870
  // Queued turns the SDK never started never ran, so reject them rather
826
871
  // than reporting a success (end_turn) — or a misleading "cancelled" —
827
872
  // for a prompt that produced no output. (A cancel already settled the
@@ -852,6 +897,10 @@ export class ClaudeAcpAgent {
852
897
  case "system":
853
898
  switch (message.subtype) {
854
899
  case "init":
900
+ // A fresh `system`/init (e.g. after reinitialize) can carry an
901
+ // updated Fast mode state; reconcile it with what we seeded at
902
+ // session creation.
903
+ await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
855
904
  break;
856
905
  case "status": {
857
906
  if (message.status === "compacting") {
@@ -937,19 +986,60 @@ export class ClaudeAcpAgent {
937
986
  }
938
987
  case "session_state_changed": {
939
988
  if (message.state === "idle") {
940
- // A non-cancelled turn already settled at its terminal
941
- // `result` (issue #773), so this trailing `idle` is just
942
- // absorbed. We must NOT settle `activeTurn` here in that case:
943
- // `idle` carries no turn identity, and it can lag (the SDK
944
- // flushes held-back results / drains background agents first),
945
- // so by the time it arrives the SDK may have echoed the NEXT
946
- // turn and activated it — settling now would resolve that new
947
- // turn prematurely with end_turn and ~zero usage, dropping its
948
- // real result. Only a cancelled turn relies on `idle`: its
949
- // `result` is dropped at the `session.cancelled` guard, so it
950
- // never settles at a result and must settle here.
951
- if (session.cancelled) {
952
- settleActive({ stopReason: "cancelled" });
989
+ // A non-cancelled turn normally settled at its terminal
990
+ // `result` already (issue #773), and that result recorded an
991
+ // owed trailing idle — absorbed here via the decrement. We
992
+ // must NOT settle `activeTurn` on an owed idle: `idle`
993
+ // carries no turn identity, and it can lag (the SDK flushes
994
+ // held-back results / drains background agents first), so by
995
+ // the time it arrives the SDK may have echoed the NEXT turn
996
+ // and activated it — settling now would resolve that new
997
+ // turn prematurely with end_turn and ~zero usage, dropping
998
+ // its real result. A cancelled turn relies on `idle`: its
999
+ // `result` is dropped at the `session.cancelled` guard, so
1000
+ // it never settles at a result and must settle here.
1001
+ //
1002
+ // An idle that is NOT owed while the active turn is still
1003
+ // unsettled is the issue #825 signature: `idle` is the SDK's
1004
+ // authoritative turn-over signal (it fires after held-back
1005
+ // results flush and background agents drain), so a turn that
1006
+ // reaches it without a result will never get one — the model
1007
+ // stream dropped mid-turn, or an async agent
1008
+ // completed/stalled without the host turn resolving. Fail
1009
+ // the turn NOW so its session/prompt gets a terminal
1010
+ // response, instead of leaving it hanging until the next
1011
+ // prompt drains the wreckage.
1012
+ // A cancelled turn still consumed tokens: its dropped result
1013
+ // already fed the accumulator (the usage tally at the result
1014
+ // handler runs before the `session.cancelled` guard), so
1015
+ // report it — clients metering spend would otherwise lose
1016
+ // the interrupted turn's tokens entirely (issue #844). Zero
1017
+ // when the cancel pre-empted the result (wedge/force-cancel).
1018
+ if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
1019
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1020
+ }
1021
+ else if (owedTrailingIdles > 0) {
1022
+ // Absorb a settled turn's trailing idle. Also covers a
1023
+ // cancel that landed between a turn's counted result and
1024
+ // this lagged idle (no active turn to settle): the idle
1025
+ // still belongs to that settled turn, and skipping the
1026
+ // decrement would leak the debt permanently.
1027
+ owedTrailingIdles--;
1028
+ }
1029
+ else if (!session.cancelled &&
1030
+ session.activeTurn &&
1031
+ !session.activeTurn.settled) {
1032
+ // Deliberately only the ACTIVE turn: a queued turn that
1033
+ // was never echoed is NOT failed here, because an idle
1034
+ // can legitimately precede the SDK picking up freshly
1035
+ // pushed input (the idle was emitted before the SDK read
1036
+ // it) — failing the queue head on that race would reject
1037
+ // a prompt the SDK is about to run. A turn abandoned
1038
+ // before its echo therefore still hangs until cancel or
1039
+ // the next prompt; only a timer could tell those apart.
1040
+ this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
1041
+ `for the active turn; failing the in-flight prompt (issue #825)`);
1042
+ failActive(RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
953
1043
  }
954
1044
  // The SDK generates the session title in a background task and
955
1045
  // persists it to the session file; `idle` is the turn-over
@@ -1105,9 +1195,65 @@ export class ClaudeAcpAgent {
1105
1195
  case "notification":
1106
1196
  case "api_retry":
1107
1197
  case "thinking_tokens":
1108
- case "model_refusal_fallback":
1109
1198
  // Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
1110
1199
  break;
1200
+ case "model_refusal_fallback": {
1201
+ // The SDK retried a refused turn on the fallback model and made
1202
+ // the swap persistent for the session. Without a notice the
1203
+ // user just sees regenerated output; without the state sync the
1204
+ // client's model picker (and the model-dependent options
1205
+ // rebuilt from it) keeps advertising a model the session is no
1206
+ // longer running.
1207
+ //
1208
+ // Current CLIs only emit direction "retry" (persistent swap).
1209
+ // "revert"/"sticky" are retained in the SDK enum for older
1210
+ // CLIs, where "revert" marked a turn-only fallback — for that
1211
+ // direction the session stays on the original model, so skip
1212
+ // the persistent-swap claim and the state sync.
1213
+ const persistent = message.direction !== "revert";
1214
+ const category = message.api_refusal_category
1215
+ ? ` (${message.api_refusal_category})`
1216
+ : "";
1217
+ const explanation = message.api_refusal_explanation
1218
+ ? `\n\n${message.api_refusal_explanation}`
1219
+ : "";
1220
+ const outcome = persistent
1221
+ ? `The session will continue on ${message.fallback_model}.`
1222
+ : `The session stays on ${message.original_model}.`;
1223
+ await this.client.sessionUpdate({
1224
+ sessionId: message.session_id,
1225
+ update: {
1226
+ sessionUpdate: "agent_message_chunk",
1227
+ content: {
1228
+ type: "text",
1229
+ text: `**Model fallback:** ${message.original_model} declined this request${category}; retried with ${message.fallback_model}. ${outcome}${explanation}`,
1230
+ },
1231
+ },
1232
+ });
1233
+ if (persistent) {
1234
+ await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
1235
+ }
1236
+ break;
1237
+ }
1238
+ case "model_refusal_no_fallback":
1239
+ // The refusal ends the turn as an error; the terminal `result`
1240
+ // handler settles it with ACP's `refusal` stop reason and
1241
+ // streams `lastRefusalExplanation`. The assistant frame's
1242
+ // stop_details is the primary source for that explanation —
1243
+ // this structured banner is the backup source when the frame
1244
+ // carried none (older CLIs, gateways that drop stop_details).
1245
+ //
1246
+ // `refused_user_message_uuid` is explicitly null when the
1247
+ // refused turn was not human-authored (a background
1248
+ // task-notification followup or auto-continuation) — don't
1249
+ // let those pollute the user turn's explanation. `undefined`
1250
+ // (older CLIs that omit the field) can't be attributed either
1251
+ // way, so keep seeding — the same exposure the assistant-frame
1252
+ // capture already has.
1253
+ if (!lastRefusalExplanation && message.refused_user_message_uuid !== null) {
1254
+ lastRefusalExplanation = message.api_refusal_explanation ?? message.content;
1255
+ }
1256
+ break;
1111
1257
  default:
1112
1258
  unreachable(message, this.logger);
1113
1259
  break;
@@ -1119,6 +1265,13 @@ export class ClaudeAcpAgent {
1119
1265
  // They should not influence the user-turn lifecycle (stop reason,
1120
1266
  // slash-command output forwarding) but their cost is real.
1121
1267
  const isTaskNotification = message.origin?.kind === "task-notification";
1268
+ // Reconcile the Fast mode toggle with the SDK's reported state.
1269
+ // Gated to user-driven turns like every other side effect below; a
1270
+ // background followup's state lands on the next user turn's result.
1271
+ // Runs even when the turn errors or was cancelled.
1272
+ if (!isTaskNotification) {
1273
+ await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
1274
+ }
1122
1275
  // A user-turn result needs an active turn so its stop reason is
1123
1276
  // attributed and the turn settles at idle. Local-only commands carry
1124
1277
  // no user-message echo to promote them, so do it here from the head.
@@ -1127,6 +1280,23 @@ export class ClaudeAcpAgent {
1127
1280
  if (!isTaskNotification) {
1128
1281
  ensureActiveTurn();
1129
1282
  }
1283
+ // Every user-turn result terminates a turn (settle, reject, or
1284
+ // orphan skip) and the SDK follows it with a trailing
1285
+ // `session_state_changed: idle` — record the debt so the idle
1286
+ // handler absorbs that idle rather than reading it as a turn the
1287
+ // SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
1288
+ // turn's own result. It is dropped at the `session.cancelled`
1289
+ // guard, and either the idle itself settles the turn (consuming
1290
+ // the trailer) or the next echo's hand-off does (which records
1291
+ // the debt there instead) — counting here too would double it.
1292
+ // Results skipped while cancelled with NO active turn — orphaned
1293
+ // queued turns the SDK still ran, or a force-cancelled turn's
1294
+ // late result after the backstop settled it — get no such settle,
1295
+ // so their trailers must be counted here or they'd later be read
1296
+ // as the next healthy turn being abandoned and false-fail it.
1297
+ if (!isTaskNotification && (!session.cancelled || !session.activeTurn)) {
1298
+ owedTrailingIdles++;
1299
+ }
1130
1300
  // Accumulate usage into the user turn's tally. Skip task-notification
1131
1301
  // followups: their cost is real but is reported separately via the
1132
1302
  // usage_update below, and `session.accumulatedUsage` is only reset on
@@ -1305,7 +1475,7 @@ export class ClaudeAcpAgent {
1305
1475
  // create a block the consolidated handler's `text.length > 0`
1306
1476
  // guard can never consume, stalling the diff cursor and
1307
1477
  // re-emitting the next block as a duplicate.
1308
- if (chunk && chunk.text.length > 0) {
1478
+ if (chunk?.text) {
1309
1479
  const index = message.event.index;
1310
1480
  const last = streamedBlocks[streamedBlocks.length - 1];
1311
1481
  if (last && last.index === index && last.type === chunk.type) {
@@ -1367,6 +1537,7 @@ export class ClaudeAcpAgent {
1367
1537
  clientCapabilities: this.clientCapabilities,
1368
1538
  cwd: session.cwd,
1369
1539
  taskState: session.taskState,
1540
+ emittedToolCalls: session.emittedToolCalls,
1370
1541
  messageId: currentStreamMessageId,
1371
1542
  })) {
1372
1543
  await this.client.sessionUpdate(notification);
@@ -1402,9 +1573,20 @@ export class ClaudeAcpAgent {
1402
1573
  // "cancelled" per the ACP contract rather than "end_turn" —
1403
1574
  // otherwise a cancel followed quickly by the next prompt
1404
1575
  // would report the cancelled turn as a normal completion.
1405
- settleActive(session.cancelled
1406
- ? { stopReason: "cancelled" }
1407
- : { stopReason: "end_turn", usage: sessionUsage(session) });
1576
+ if (session.cancelled) {
1577
+ // The cancelled turn settles here, but the trailing idle
1578
+ // its interrupt produces is still in flight — record the
1579
+ // debt so that lagged idle is absorbed rather than read
1580
+ // as the freshly-activated turn ending without a result
1581
+ // (which would false-fail a healthy turn — issue #825).
1582
+ owedTrailingIdles++;
1583
+ // Before activateTurn resets the accumulator, so the
1584
+ // usage still belongs to the cancelled turn.
1585
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1586
+ }
1587
+ else {
1588
+ settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
1589
+ }
1408
1590
  }
1409
1591
  activateTurn(queued);
1410
1592
  }
@@ -1432,7 +1614,13 @@ export class ClaudeAcpAgent {
1432
1614
  lastAssistantError = message.error;
1433
1615
  }
1434
1616
  if (message.message.stop_reason === "refusal") {
1435
- lastRefusalExplanation = message.message.stop_details?.explanation ?? null;
1617
+ // Keep any explanation already seeded by a
1618
+ // `model_refusal_no_fallback` banner — the banner/frame
1619
+ // ordering is CLI-dependent, and a frame whose stop_details
1620
+ // was dropped (the case the banner backup exists for) must
1621
+ // not clobber the seed back to null.
1622
+ lastRefusalExplanation =
1623
+ message.message.stop_details?.explanation ?? lastRefusalExplanation;
1436
1624
  }
1437
1625
  }
1438
1626
  // Strip <command-*>/<local-command-stdout> markers and render any
@@ -1562,6 +1750,7 @@ export class ClaudeAcpAgent {
1562
1750
  parentToolUseId: message.parent_tool_use_id,
1563
1751
  cwd: session.cwd,
1564
1752
  taskState: session.taskState,
1753
+ emittedToolCalls: session.emittedToolCalls,
1565
1754
  messageId: messageIdForGrouping(message),
1566
1755
  })) {
1567
1756
  await this.client.sessionUpdate(notification);
@@ -1599,9 +1788,16 @@ export class ClaudeAcpAgent {
1599
1788
  }
1600
1789
  break;
1601
1790
  }
1791
+ // `conversation_reset` (from `/clear`, plan-mode exit, fresh-session
1792
+ // flows) is safe to drop: turn lifecycle here is driven by
1793
+ // results/idle, and the client owns its own transcript view.
1794
+ // `control_request_progress` only reports on side_question control
1795
+ // requests, which this adapter never issues.
1602
1796
  case "tool_use_summary":
1603
1797
  case "auth_status":
1604
1798
  case "prompt_suggestion":
1799
+ case "conversation_reset":
1800
+ case "control_request_progress":
1605
1801
  break;
1606
1802
  default:
1607
1803
  unreachable(message);
@@ -1663,6 +1859,8 @@ export class ClaudeAcpAgent {
1663
1859
  for (const turn of session.turnQueue) {
1664
1860
  if (turn !== session.activeTurn && !turn.settled) {
1665
1861
  turn.settled = true;
1862
+ // Deliberately no `usage`: a queued turn never ran, so the session
1863
+ // accumulator (the active turn's tally) is not its spend.
1666
1864
  turn.resolve({ stopReason: "cancelled" });
1667
1865
  orphaned++;
1668
1866
  }
@@ -1786,7 +1984,7 @@ export class ClaudeAcpAgent {
1786
1984
  throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
1787
1985
  }
1788
1986
  await this.applySessionMode(params.sessionId, params.modeId);
1789
- await this.updateConfigOption(params.sessionId, "mode", params.modeId);
1987
+ await this.updateConfigOption(params.sessionId, MODE_CONFIG_ID, params.modeId);
1790
1988
  return {};
1791
1989
  }
1792
1990
  async setSessionConfigOption(params) {
@@ -1801,29 +1999,54 @@ export class ClaudeAcpAgent {
1801
1999
  if (session.queryClosed) {
1802
2000
  throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
1803
2001
  }
1804
- if (typeof params.value !== "string") {
1805
- throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
1806
- }
1807
2002
  const option = session.configOptions.find((o) => o.id === params.configId);
1808
2003
  if (!option) {
1809
2004
  throw new Error(`Unknown config option: ${params.configId}`);
1810
2005
  }
2006
+ // Fast mode carries a boolean value (for Clients that opted into boolean
2007
+ // config options) or the "on"/"off" select fallback, so it bypasses the
2008
+ // string-only validation the select-style options below rely on.
2009
+ if (params.configId === FAST_MODE_CONFIG_ID) {
2010
+ await this.applyFastMode(session, resolveFastModeEnabled(params));
2011
+ return { configOptions: session.configOptions };
2012
+ }
2013
+ if (typeof params.value !== "string") {
2014
+ throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
2015
+ }
1811
2016
  const allValues = "options" in option && Array.isArray(option.options)
1812
2017
  ? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
1813
2018
  : [];
1814
2019
  let validValue = allValues.find((o) => o.value === params.value);
2020
+ // The option's reported currentValue is always a valid target, even when
2021
+ // it has no options entry: a session running an out-of-picker model
2022
+ // (resumed onto an allowlist-excluded model, or a refusal fallback)
2023
+ // reports a currentValue that isn't selectable, and a client
2024
+ // round-tripping it must not get "Invalid value". It flows through the
2025
+ // normal apply path below — re-asserting an already-current value is
2026
+ // harmless and can repair SDK drift.
2027
+ if (!validValue && option.currentValue === params.value) {
2028
+ validValue = { value: params.value, name: params.value };
2029
+ }
1815
2030
  // For model options, fall back to resolveModelPreference when the exact
1816
2031
  // value doesn't match. This lets callers use human-friendly aliases like
1817
2032
  // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
1818
- if (!validValue && params.configId === "model") {
1819
- const modelInfos = allValues.map((o) => ({
1820
- value: o.value,
1821
- displayName: o.name,
1822
- description: o.description ?? "",
1823
- }));
1824
- const resolved = resolveModelPreference(modelInfos, params.value);
1825
- if (resolved) {
1826
- validValue = allValues.find((o) => o.value === resolved.value);
2033
+ // Resolve against session.modelInfos first: those entries carry
2034
+ // `resolvedModel`, so a full model id (in either hint spelling) lands on
2035
+ // the right row via the exact tier instead of a fuzzier one picking a
2036
+ // same-family sibling from a different context lane. The options-derived
2037
+ // list (which never carries `resolvedModel`) remains as a fallback for
2038
+ // resolutions that don't map back onto a selectable option (e.g. a fuzzy
2039
+ // hit on an out-of-picker verbatim entry).
2040
+ if (!validValue && params.configId === MODEL_CONFIG_ID) {
2041
+ const toOptionValue = (resolved) => resolved ? allValues.find((o) => o.value === resolved.value) : undefined;
2042
+ validValue = toOptionValue(resolveModelPreference(session.modelInfos, params.value));
2043
+ if (!validValue) {
2044
+ const optionInfos = allValues.map((o) => ({
2045
+ value: o.value,
2046
+ displayName: o.name,
2047
+ description: o.description ?? "",
2048
+ }));
2049
+ validValue = toOptionValue(resolveModelPreference(optionInfos, params.value));
1827
2050
  }
1828
2051
  }
1829
2052
  if (!validValue) {
@@ -1832,7 +2055,7 @@ export class ClaudeAcpAgent {
1832
2055
  // Use the canonical option value so downstream code always receives the
1833
2056
  // model ID rather than the caller-supplied alias.
1834
2057
  const resolvedValue = validValue.value;
1835
- if (params.configId === "mode") {
2058
+ if (params.configId === MODE_CONFIG_ID) {
1836
2059
  await this.applySessionMode(params.sessionId, resolvedValue);
1837
2060
  await this.client.sessionUpdate({
1838
2061
  sessionId: params.sessionId,
@@ -1842,7 +2065,7 @@ export class ClaudeAcpAgent {
1842
2065
  },
1843
2066
  });
1844
2067
  }
1845
- else if (params.configId === "model") {
2068
+ else if (params.configId === MODEL_CONFIG_ID) {
1846
2069
  await this.sessions[params.sessionId].query.setModel(resolvedValue);
1847
2070
  }
1848
2071
  // Effort SDK sync is handled inside applyConfigOptionValue so that direct
@@ -1937,7 +2160,14 @@ export class ClaudeAcpAgent {
1937
2160
  * outcome or a `requestCancelled` rejection). Either way we surface the same
1938
2161
  * "Tool use aborted" the callers already expect, so a cancelled dialog no
1939
2162
  * longer leaves the `await` hanging. */
1940
- async requestPermissionFromClient(params, signal) {
2163
+ async requestPermissionFromClient(params, toolName, signal) {
2164
+ // The SDK may invoke `canUseTool` (and therefore this permission request)
2165
+ // before the assistant message's tool_use block streams to us. Some ACP clients
2166
+ // expect the `tool_call` a permission request references to already exist,
2167
+ // so emit it now if it hasn't been sent yet. The streamed tool_use chunk
2168
+ // later refines it with a `tool_call_update` rather than emitting a
2169
+ // duplicate (see `emittedToolCalls` in `toAcpNotifications`).
2170
+ await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput);
1941
2171
  try {
1942
2172
  return await this.client.requestPermission(params, signal);
1943
2173
  }
@@ -1948,6 +2178,28 @@ export class ClaudeAcpAgent {
1948
2178
  throw error;
1949
2179
  }
1950
2180
  }
2181
+ /** Emit the `tool_call` a permission request references if it hasn't been sent
2182
+ * yet, so the client has the tool call before being asked to approve it. The
2183
+ * matching streamed tool_use chunk later refines it with a `tool_call_update`
2184
+ * instead of emitting a duplicate (see `emittedToolCalls`). Built via the same
2185
+ * `toolCallNotification` helper as the streamed path so the two are identical.
2186
+ * Tools the stream renders as a plan (TodoWrite) or suppresses (Task*) are
2187
+ * skipped so a permission prompt for them never surfaces a stray tool_call. */
2188
+ async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput) {
2189
+ const session = this.sessions[sessionId];
2190
+ if (!session || !shouldEmitToolCall(toolName)) {
2191
+ return;
2192
+ }
2193
+ if (session.emittedToolCalls.has(toolCallId)) {
2194
+ return;
2195
+ }
2196
+ session.emittedToolCalls.add(toolCallId);
2197
+ const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
2198
+ await this.client.sessionUpdate({
2199
+ sessionId,
2200
+ update: toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd),
2201
+ });
2202
+ }
1951
2203
  canUseTool(sessionId) {
1952
2204
  return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
1953
2205
  const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
@@ -1964,6 +2216,9 @@ export class ClaudeAcpAgent {
1964
2216
  // than the interactive dialog). Present it as an ACP form elicitation and
1965
2217
  // feed the answers back as updatedInput for the tool's own call() to read.
1966
2218
  if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
2219
+ // Like permission requests, the elicitation references this toolUseID, so
2220
+ // make sure the tool_call has surfaced to the client before we send it.
2221
+ await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
1967
2222
  return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
1968
2223
  }
1969
2224
  if (toolName === "ExitPlanMode") {
@@ -1998,7 +2253,7 @@ export class ClaudeAcpAgent {
1998
2253
  rawInput: toolInput,
1999
2254
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
2000
2255
  },
2001
- }, signal);
2256
+ }, toolName, signal);
2002
2257
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2003
2258
  throw new Error("Tool use aborted");
2004
2259
  }
@@ -2016,7 +2271,7 @@ export class ClaudeAcpAgent {
2016
2271
  currentModeId: selectedMode,
2017
2272
  },
2018
2273
  });
2019
- await this.updateConfigOption(sessionId, "mode", selectedMode);
2274
+ await this.updateConfigOption(sessionId, MODE_CONFIG_ID, selectedMode);
2020
2275
  return {
2021
2276
  behavior: "allow",
2022
2277
  updatedInput: toolInput,
@@ -2057,7 +2312,7 @@ export class ClaudeAcpAgent {
2057
2312
  rawInput: toolInput,
2058
2313
  ...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
2059
2314
  },
2060
- }, signal);
2315
+ }, toolName, signal);
2061
2316
  if (signal.aborted || response.outcome?.outcome === "cancelled") {
2062
2317
  throw new Error("Tool use aborted");
2063
2318
  }
@@ -2157,6 +2412,44 @@ export class ClaudeAcpAgent {
2157
2412
  }
2158
2413
  return { behavior: "allow", updatedInput: outcome.updatedInput };
2159
2414
  }
2415
+ /**
2416
+ * Handle `request_user_dialog` control requests — blocking dialogs the CLI
2417
+ * asks the host to render. Only kinds declared in `supportedDialogKinds`
2418
+ * are ever emitted; everything unexpected is answered `cancelled` (the
2419
+ * required answer for unrecognized kinds), which applies the dialog's
2420
+ * default behavior CLI-side. Today the only declared kind is the
2421
+ * refusal-fallback consent prompt, rendered as an ACP form elicitation.
2422
+ */
2423
+ handleUserDialog(sessionId) {
2424
+ return async (request, { signal }) => {
2425
+ if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) {
2426
+ return { behavior: "cancelled" };
2427
+ }
2428
+ const prompt = extractRefusalFallbackPrompt(request.payload);
2429
+ if (!prompt) {
2430
+ this.logger.error(`refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`);
2431
+ return { behavior: "cancelled" };
2432
+ }
2433
+ let response;
2434
+ try {
2435
+ response = await this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal);
2436
+ }
2437
+ catch (error) {
2438
+ // A cancellation we requested (signal aborted) is expected teardown;
2439
+ // anything else is a client failure. Either way the safe answer is
2440
+ // `cancelled` — the CLI applies the dialog's default (keep the
2441
+ // refusal) rather than switching models without consent.
2442
+ if (!signal.aborted) {
2443
+ this.logger.error(`Failed to present refusal fallback elicitation: ${error}`);
2444
+ }
2445
+ return { behavior: "cancelled" };
2446
+ }
2447
+ if (signal.aborted) {
2448
+ return { behavior: "cancelled" };
2449
+ }
2450
+ return { behavior: "completed", result: refusalFallbackResultFromResponse(response) };
2451
+ };
2452
+ }
2160
2453
  async sendAvailableCommandsUpdate(sessionId) {
2161
2454
  const session = this.sessions[sessionId];
2162
2455
  if (!session)
@@ -2184,11 +2477,11 @@ export class ClaudeAcpAgent {
2184
2477
  });
2185
2478
  }
2186
2479
  async applyConfigOptionValue(sessionId, session, configId, value) {
2187
- if (configId === "mode") {
2480
+ if (configId === MODE_CONFIG_ID) {
2188
2481
  session.modes = { ...session.modes, currentModeId: value };
2189
2482
  session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
2190
2483
  }
2191
- else if (configId === "model") {
2484
+ else if (configId === MODEL_CONFIG_ID) {
2192
2485
  // `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
2193
2486
  // modes below; its `displayName`/`description` also let us infer the
2194
2487
  // context window for semantic aliases (e.g. `default`) whose ID alone
@@ -2204,8 +2497,16 @@ export class ClaudeAcpAgent {
2204
2497
  }
2205
2498
  session.models = { ...session.models, currentModelId: value };
2206
2499
  // Recompute availableModes for the new model and clamp the current
2207
- // mode if the SDK no longer offers it (today: "auto" on Haiku).
2208
- const newAvailableModes = buildAvailableModes(newModelInfo);
2500
+ // mode if the SDK no longer offers it (today: "auto" on Haiku). An
2501
+ // unknown model (an SDK-initiated refusal fallback to a model outside
2502
+ // the user's `availableModels` allowlist — user-driven switches are
2503
+ // validated against the options first) tells us nothing about its
2504
+ // capabilities, so keep the current modes rather than spuriously
2505
+ // downgrading (e.g. kicking the user out of "auto" for a model that
2506
+ // does support it).
2507
+ const newAvailableModes = newModelInfo
2508
+ ? buildAvailableModes(newModelInfo)
2509
+ : session.modes.availableModes;
2209
2510
  // Capture BEFORE mutating session.modes so the log message reflects
2210
2511
  // the invalidated mode rather than "default".
2211
2512
  const previousModeId = session.modes.currentModeId;
@@ -2231,11 +2532,18 @@ export class ClaudeAcpAgent {
2231
2532
  session.modes = { ...session.modes, availableModes: newAvailableModes };
2232
2533
  }
2233
2534
  // Rebuild config options since effort levels depend on the selected model
2234
- const effortOpt = session.configOptions.find((o) => o.id === "effort");
2535
+ const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
2235
2536
  const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
2236
- session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort, session.agents, session.currentAgent);
2537
+ session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort, session.agents, session.currentAgent, {
2538
+ // The toggle follows the newly selected model: it disappears when the
2539
+ // model lacks fast support and reappears (with the retained user
2540
+ // intent) when a supporting model is selected again.
2541
+ supported: newModelInfo?.supportsFastMode ?? false,
2542
+ enabled: session.fastModeEnabled,
2543
+ useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
2544
+ });
2237
2545
  // Sync effort with the SDK if it changed after the model switch
2238
- const newEffortOpt = session.configOptions.find((o) => o.id === "effort");
2546
+ const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
2239
2547
  const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
2240
2548
  if (newEffort !== currentEffort) {
2241
2549
  await session.query.applyFlagSettings({
@@ -2258,7 +2566,7 @@ export class ClaudeAcpAgent {
2258
2566
  });
2259
2567
  }
2260
2568
  }
2261
- else if (configId === "agent") {
2569
+ else if (configId === AGENT_CONFIG_ID) {
2262
2570
  // Live agent switch — no subprocess restart needed. Apply the SDK flag
2263
2571
  // first so a rejected control request leaves both `currentAgent` and the
2264
2572
  // config option untouched (no UI/SDK desync). Passing `null` clears the
@@ -2272,13 +2580,102 @@ export class ClaudeAcpAgent {
2272
2580
  }
2273
2581
  else {
2274
2582
  session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
2275
- if (configId === "effort") {
2583
+ if (configId === EFFORT_CONFIG_ID) {
2276
2584
  await session.query.applyFlagSettings({
2277
2585
  effortLevel: toSdkEffortLevel(value),
2278
2586
  });
2279
2587
  }
2280
2588
  }
2281
2589
  }
2590
+ /** Reconcile adapter model state after the SDK persistently swapped the
2591
+ * session's model out from under us (refusal fallback). The SDK already
2592
+ * made the switch, so this must NOT call `query.setModel` — it only
2593
+ * updates our bookkeeping (currentModelId, context window, mode clamping,
2594
+ * effort/Fast-mode options) via the same `applyConfigOptionValue` path a
2595
+ * user-driven model change takes, then notifies the client. */
2596
+ async syncModelAfterRefusalFallback(sessionId, session, fallbackModel) {
2597
+ // Map the SDK-reported model onto one of the session's model options
2598
+ // (handles display names and `resolvedModel` ids). The fallback model may
2599
+ // not be among the options — e.g. excluded by the user's
2600
+ // `availableModels` allowlist — in which case we track the raw id: the
2601
+ // picker shows no selection, but the model-dependent bookkeeping and any
2602
+ // later `setModel` round-trip stay truthful to what the SDK is running.
2603
+ const resolved = resolveModelPreference(session.modelInfos, fallbackModel);
2604
+ const value = resolved?.value ?? fallbackModel;
2605
+ if (session.models.currentModelId === value)
2606
+ return;
2607
+ try {
2608
+ await this.updateConfigOption(sessionId, MODEL_CONFIG_ID, value);
2609
+ }
2610
+ catch (err) {
2611
+ // This runs on the consumer loop: a throw here tears down the query
2612
+ // stream (failAllTurns + closeQueryStream) and bricks the session —
2613
+ // far worse than stale bookkeeping. The user-driven RPC path lets the
2614
+ // same errors propagate to fail just that request; here we log and
2615
+ // move on, matching the setPermissionMode containment inside
2616
+ // applyConfigOptionValue.
2617
+ this.logger.error(`Failed to reconcile model state after refusal fallback to "${fallbackModel}":`, err);
2618
+ }
2619
+ }
2620
+ /** Replace the Fast mode option in `session.configOptions` so it reflects
2621
+ * `enabled` (and the client's current boolean-capability). A no-op when the
2622
+ * option isn't present, so callers must confirm the current model surfaces
2623
+ * it first. */
2624
+ refreshFastModeOption(session, enabled) {
2625
+ const refreshed = createFastModeConfigOption(enabled, clientSupportsBooleanConfigOptions(this.clientCapabilities));
2626
+ session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
2627
+ }
2628
+ /** Toggle Fast mode for a session: push the SDK flag, record the user's
2629
+ * intent, and refresh the Fast mode config option in place. Only reached
2630
+ * once the option exists (i.e. the current model supports fast mode), so the
2631
+ * option is guaranteed to be present in `configOptions`. */
2632
+ async applyFastMode(session, enabled) {
2633
+ // Apply the SDK flag first so a rejected control request leaves both the
2634
+ // session state and the config option untouched (no UI/SDK desync).
2635
+ await session.query.applyFlagSettings({ fastMode: enabled });
2636
+ session.fastModeEnabled = enabled;
2637
+ this.refreshFastModeOption(session, enabled);
2638
+ }
2639
+ /** Reconcile the session's Fast mode toggle with an SDK-reported
2640
+ * `fast_mode_state` (delivered on `system`/init and on user-turn `result`s).
2641
+ * The SDK can flip fast mode independently of the user — e.g. back to `on`
2642
+ * once a rate-limit `cooldown` clears — so we mirror definitive on/off
2643
+ * changes into the config option and notify the client.
2644
+ *
2645
+ * Guards, in order:
2646
+ * - absent state: nothing to reconcile.
2647
+ * - no Fast mode option: the current model doesn't support fast mode, so the
2648
+ * reported state reflects capability, not the user's intent. Leave the
2649
+ * retained setting untouched so it's correct when a supporting model is
2650
+ * reselected (the source of the earlier intent-clobber bug was mutating it
2651
+ * here).
2652
+ * - `cooldown`: a transient suspension of an already-enabled fast mode.
2653
+ * Leave the toggle as-is rather than flapping it — and never let a stray
2654
+ * cooldown spuriously enable a toggle the user has off. */
2655
+ async syncFastModeState(sessionId, session, state) {
2656
+ if (state === undefined) {
2657
+ return;
2658
+ }
2659
+ if (!session.configOptions.some((o) => o.id === FAST_MODE_CONFIG_ID)) {
2660
+ return;
2661
+ }
2662
+ if (state === "cooldown") {
2663
+ return;
2664
+ }
2665
+ const enabled = state === "on";
2666
+ if (enabled === session.fastModeEnabled) {
2667
+ return;
2668
+ }
2669
+ session.fastModeEnabled = enabled;
2670
+ this.refreshFastModeOption(session, enabled);
2671
+ await this.client.sessionUpdate({
2672
+ sessionId,
2673
+ update: {
2674
+ sessionUpdate: "config_option_update",
2675
+ configOptions: session.configOptions,
2676
+ },
2677
+ });
2678
+ }
2282
2679
  async getOrCreateSession(params) {
2283
2680
  const existingSession = this.sessions[params.sessionId];
2284
2681
  if (existingSession) {
@@ -2464,6 +2861,18 @@ export class ClaudeAcpAgent {
2464
2861
  ...(elicitationSupport.form || elicitationSupport.url
2465
2862
  ? { onElicitation: this.handleMcpElicitation(sessionId, elicitationSupport) }
2466
2863
  : {}),
2864
+ // Render the CLI's refusal-fallback consent prompt ("<model> declined —
2865
+ // retry with <fallback>?") as an ACP form elicitation. Declaring the
2866
+ // kind is the opt-in: the CLI never emits an undeclared dialog, and the
2867
+ // flow instead degrades to the classic refusal error ending the turn.
2868
+ // Gated on form elicitation since that's the only ACP surface that can
2869
+ // present a choice outside a tool call.
2870
+ ...(elicitationSupport.form
2871
+ ? {
2872
+ onUserDialog: this.handleUserDialog(sessionId),
2873
+ supportedDialogKinds: [REFUSAL_FALLBACK_DIALOG_KIND],
2874
+ }
2875
+ : {}),
2467
2876
  pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
2468
2877
  extraArgs: {
2469
2878
  ...userProvidedOptions?.extraArgs,
@@ -2486,7 +2895,7 @@ export class ClaudeAcpAgent {
2486
2895
  currentModeId: "plan",
2487
2896
  },
2488
2897
  });
2489
- await this.updateConfigOption(sessionId, "mode", "plan");
2898
+ await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
2490
2899
  },
2491
2900
  }),
2492
2901
  ],
@@ -2579,13 +2988,46 @@ export class ClaudeAcpAgent {
2579
2988
  // configOptions, the current-model resolver, and the stored modelInfos
2580
2989
  // consistent with what the user configured.
2581
2990
  const settingsAvailableModels = settingsManager.getSettings().availableModels;
2991
+ const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
2582
2992
  const allowedModels = Array.isArray(settingsAvailableModels)
2583
- ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels)
2993
+ ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
2584
2994
  : initializationResult.models;
2585
- const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger);
2995
+ const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
2586
2996
  // Gate `auto` (and future model-specific modes) on the resolved model's
2587
2997
  // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
2588
- const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
2998
+ // A resumed session can be running a model outside the `availableModels`
2999
+ // allowlist (currentModelId is then the verbatim live id, see
3000
+ // `matchResumedModel`); its capabilities are still known to the SDK's
3001
+ // unfiltered list, so fall back to that before treating the model as
3002
+ // unknown — otherwise auto mode would be spuriously clamped and the
3003
+ // Fast-mode/Effort options hidden for a model that supports them.
3004
+ const allowlistedModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
3005
+ const fallbackModelInfo = allowlistedModelInfo
3006
+ ? undefined
3007
+ : (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined);
3008
+ const currentModelInfo = allowlistedModelInfo ?? fallbackModelInfo;
3009
+ // Register the fallback-resolved capabilities under the verbatim live id
3010
+ // so every modelInfos consumer (buildConfigOptions' effort lookup, later
3011
+ // rebuilds via session.modelInfos) agrees with the gating below. The
3012
+ // picker options themselves come from `models.availableModels`, so this
3013
+ // adds no selectable entry. The spread keeps every capability flag
3014
+ // (current and future); the identity fields are overridden because the
3015
+ // fuzzy-matched sibling's resolvedModel/displayName/description can
3016
+ // describe a different context lane and would poison later resolvedModel
3017
+ // matching (syncModelAfterRefusalFallback) and context-window inference
3018
+ // (applyConfigOptionValue) if they traveled under this id.
3019
+ const modelInfos = fallbackModelInfo
3020
+ ? [
3021
+ ...allowedModels,
3022
+ {
3023
+ ...fallbackModelInfo,
3024
+ value: models.currentModelId,
3025
+ displayName: models.currentModelId,
3026
+ description: "",
3027
+ resolvedModel: undefined,
3028
+ },
3029
+ ]
3030
+ : allowedModels;
2589
3031
  const availableModes = buildAvailableModes(currentModelInfo);
2590
3032
  // Clamp `permissionMode` if the resolved session does not offer it. The
2591
3033
  // common case is `permissions.defaultMode: "auto"` resolving to a model
@@ -2628,9 +3070,20 @@ export class ClaudeAcpAgent {
2628
3070
  const currentAgent = requestedAgent && agents.some((a) => a.name === requestedAgent)
2629
3071
  ? requestedAgent
2630
3072
  : DEFAULT_AGENT_ID;
2631
- const configOptions = buildConfigOptions(modes, models, allowedModels, settingsManager.getSettings().effortLevel, agents, currentAgent);
3073
+ // Seed Fast mode from the SDK's reported state so the UI reflects reality
3074
+ // (the CLI may start a session with fast mode already on, or force it off
3075
+ // when `fastModePerSessionOptIn` is set). The toggle is only surfaced while
3076
+ // the resolved model advertises `supportsFastMode`.
3077
+ const fastModeEnabled = initializationResult.fast_mode_state !== undefined &&
3078
+ fastModeStateEnabled(initializationResult.fast_mode_state);
3079
+ const fastMode = {
3080
+ supported: currentModelInfo?.supportsFastMode ?? false,
3081
+ enabled: fastModeEnabled,
3082
+ useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
3083
+ };
3084
+ const configOptions = buildConfigOptions(modes, models, modelInfos, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode);
2632
3085
  // Apply the initial effort level to the SDK so it matches the UI default
2633
- const initialEffort = configOptions.find((o) => o.id === "effort");
3086
+ const initialEffort = configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
2634
3087
  if (initialEffort &&
2635
3088
  typeof initialEffort.currentValue === "string" &&
2636
3089
  initialEffort.currentValue !== "default") {
@@ -2653,15 +3106,23 @@ export class ClaudeAcpAgent {
2653
3106
  },
2654
3107
  modes,
2655
3108
  models,
2656
- modelInfos: allowedModels,
3109
+ modelInfos,
2657
3110
  configOptions,
2658
3111
  agents,
2659
3112
  currentAgent,
3113
+ fastModeEnabled,
2660
3114
  abortController,
2661
3115
  emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
2662
- contextWindowSize: inferContextWindowFromModel(models.currentModelId, currentModelInfo?.displayName, currentModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
3116
+ contextWindowSize:
3117
+ // Deliberately keyed to the allowlisted entry: a fallback-resolved
3118
+ // sibling's displayName/description can describe a different context
3119
+ // lane than the verbatim live id (e.g. an "opus[1m]" row matched for
3120
+ // a bare 200k id), so on the fallback path only the id itself is a
3121
+ // trustworthy window signal.
3122
+ inferContextWindowFromModel(models.currentModelId, allowlistedModelInfo?.displayName, allowlistedModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
2663
3123
  taskState,
2664
3124
  toolUseCache: {},
3125
+ emittedToolCalls: new Set(),
2665
3126
  messageIdToUuid: new Map(),
2666
3127
  };
2667
3128
  return {
@@ -2704,9 +3165,9 @@ function totalTokens(usage) {
2704
3165
  }
2705
3166
  /**
2706
3167
  * Build the `data` payload attached to a `RequestError.internalError` when we
2707
- * have a categorical error from the Claude SDK. Returns `undefined` when no
2708
- * categorical error is available, matching the previous behavior of passing
2709
- * `undefined` to `RequestError.internalError`.
3168
+ * have a categorical error — from the Claude SDK, or one of the adapter's own
3169
+ * kinds. Returns `undefined` when no categorical error is available, matching
3170
+ * the previous behavior of passing `undefined` to `RequestError.internalError`.
2710
3171
  *
2711
3172
  * The `errorKind` field is a convention for ACP clients to dispatch on
2712
3173
  * without having to pattern-match the human-readable message text. Clients
@@ -2769,8 +3230,10 @@ function buildAvailableModes(modelInfo) {
2769
3230
  });
2770
3231
  }
2771
3232
  modes.push({
3233
+ // Claude Code 2.1.200 renamed this mode to "Manual" across its surfaces;
3234
+ // the wire id stays "default" ("manual" is only an accepted input alias).
2772
3235
  id: "default",
2773
- name: "Default",
3236
+ name: "Manual",
2774
3237
  description: "Standard behavior, prompts for dangerous operations",
2775
3238
  }, {
2776
3239
  id: "acceptEdits",
@@ -2835,10 +3298,78 @@ export async function discoverCustomAgents(q) {
2835
3298
  return [];
2836
3299
  }
2837
3300
  }
2838
- export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID) {
3301
+ /** Stable ids for the session config options surfaced via `configOptions`.
3302
+ * Centralized so the option declarations in `buildConfigOptions` and the
3303
+ * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
3304
+ * same identifiers and can't drift apart. */
3305
+ export const MODE_CONFIG_ID = "mode";
3306
+ export const MODEL_CONFIG_ID = "model";
3307
+ export const EFFORT_CONFIG_ID = "effort";
3308
+ export const AGENT_CONFIG_ID = "agent";
3309
+ export const FAST_MODE_CONFIG_ID = "fast";
3310
+ /** Select-fallback values used when the client has not opted into boolean
3311
+ * config options (see {@link createFastModeConfigOption}). */
3312
+ export const FAST_MODE_ON = "on";
3313
+ export const FAST_MODE_OFF = "off";
3314
+ const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
3315
+ /** Map the SDK's tri-state `fast_mode_state` onto the boolean config toggle.
3316
+ * `cooldown` (fast mode temporarily suspended after a rate limit, per the SDK
3317
+ * docs) keeps the toggle on so it reflects the user's intent — only an
3318
+ * explicit `off` clears it. */
3319
+ export function fastModeStateEnabled(state) {
3320
+ return state !== "off";
3321
+ }
3322
+ /** Whether the Client advertised support for boolean session config options
3323
+ * (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
3324
+ * config options to Clients that opt in; otherwise we fall back to a `select`.
3325
+ * See https://agentclientprotocol.com/rfds/boolean-config-option. */
3326
+ export function clientSupportsBooleanConfigOptions(clientCapabilities) {
3327
+ return clientCapabilities?.session?.configOptions?.boolean != null;
3328
+ }
3329
+ /** Build the Fast mode config option. When the Client supports boolean config
3330
+ * options we expose a native `type: "boolean"` toggle; otherwise we degrade to
3331
+ * a two-value `select` ("on"/"off") so older Clients still get a usable
3332
+ * control. */
3333
+ export function createFastModeConfigOption(enabled, useBooleanOption) {
3334
+ const base = {
3335
+ id: FAST_MODE_CONFIG_ID,
3336
+ name: "Fast mode",
3337
+ description: FAST_MODE_DESCRIPTION,
3338
+ category: "model_config",
3339
+ };
3340
+ if (useBooleanOption) {
3341
+ return { ...base, type: "boolean", currentValue: enabled };
3342
+ }
3343
+ return {
3344
+ ...base,
3345
+ type: "select",
3346
+ currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
3347
+ options: [
3348
+ { value: FAST_MODE_ON, name: "On" },
3349
+ { value: FAST_MODE_OFF, name: "Off" },
3350
+ ],
3351
+ };
3352
+ }
3353
+ /** Resolve the requested Fast mode value from a `session/set_config_option`
3354
+ * request. Accepts a native boolean (boolean-capable Clients) or the
3355
+ * "on"/"off" select-fallback strings. */
3356
+ export function resolveFastModeEnabled(params) {
3357
+ const value = params.value;
3358
+ if (typeof value === "boolean") {
3359
+ return value;
3360
+ }
3361
+ if (value === FAST_MODE_ON) {
3362
+ return true;
3363
+ }
3364
+ if (value === FAST_MODE_OFF) {
3365
+ return false;
3366
+ }
3367
+ throw new Error(`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`);
3368
+ }
3369
+ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode) {
2839
3370
  const options = [
2840
3371
  {
2841
- id: "mode",
3372
+ id: MODE_CONFIG_ID,
2842
3373
  name: "Mode",
2843
3374
  description: "Session permission mode",
2844
3375
  category: "mode",
@@ -2851,7 +3382,7 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
2851
3382
  })),
2852
3383
  },
2853
3384
  {
2854
- id: "model",
3385
+ id: MODEL_CONFIG_ID,
2855
3386
  name: "Model",
2856
3387
  description: "AI model to use",
2857
3388
  category: "model",
@@ -2883,7 +3414,7 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
2883
3414
  const includes = (l) => l === "default" || supportedLevels.includes(l);
2884
3415
  const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
2885
3416
  options.push({
2886
- id: "effort",
3417
+ id: EFFORT_CONFIG_ID,
2887
3418
  name: "Effort",
2888
3419
  description: "Available effort levels for this model",
2889
3420
  category: "thought_level",
@@ -2892,13 +3423,19 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
2892
3423
  options: effortOptions,
2893
3424
  });
2894
3425
  }
3426
+ // Surface the Fast mode toggle only when the current model supports it. The
3427
+ // option renders as a native boolean toggle for Clients that opted in, and a
3428
+ // two-value select otherwise.
3429
+ if (fastMode?.supported) {
3430
+ options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption));
3431
+ }
2895
3432
  // Only surface the Agent picker when there's a real choice — i.e. the user
2896
3433
  // has configured at least one custom agent (built-ins are filtered out in
2897
3434
  // discoverCustomAgents). With none configured, "Default" would be the only
2898
3435
  // entry, so we omit the option entirely.
2899
3436
  if (agents.length > 0) {
2900
3437
  options.push({
2901
- id: "agent",
3438
+ id: AGENT_CONFIG_ID,
2902
3439
  name: "Agent",
2903
3440
  description: "Main-thread agent persona",
2904
3441
  type: "select",
@@ -2918,13 +3455,35 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
2918
3455
  // Claude Code CLI persists display strings like "opus[1m]" in settings,
2919
3456
  // but the SDK model list uses IDs like "claude-opus-4-6-1m".
2920
3457
  const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
2921
- // Captures a model family version such as `4-6` or `4.7` so we can keep
2922
- // `claude-opus-4-6` from being copied onto the SDK's `opus` alias when that
2923
- // alias currently resolves to a different family version (e.g. Opus 4.7).
2924
- const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/;
3458
+ // The id-suffix spelling of a context hint ("-1m" in "claude-opus-4-6-1m");
3459
+ // shared by the strip and canonicalize helpers below so the two can't drift.
3460
+ const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
3461
+ /** Remove context-window hints — the display form "[1m]" and the SDK id
3462
+ * suffix form "-1m" — from a model string. Those digits describe context
3463
+ * size, not model identity or generation version. */
3464
+ function stripContextHints(s) {
3465
+ return s.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
3466
+ }
3467
+ /** Canonicalize a model id for exact comparison: trimmed, lowercased, with
3468
+ * the id-suffix hint spelling unified to the bracket form ("-1m" → "[1m]").
3469
+ * The hint itself is kept — bare and 1M ids must stay distinct. */
3470
+ function canonicalizeModelId(s) {
3471
+ return s.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
3472
+ }
3473
+ /** The context hint a model string carries ("1m" for either spelling), or
3474
+ * null for a bare id. */
3475
+ function contextHintOf(s) {
3476
+ return canonicalizeModelId(s).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
3477
+ }
3478
+ // Captures a model family version: `4-6`/`4.7` for dated generations, or a
3479
+ // bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned
3480
+ // `claude-opus-4-6` from matching the `opus` alias once it points at 4.7.
3481
+ const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
2925
3482
  function extractModelFamilyVersion(s) {
2926
- const match = s.match(MODEL_FAMILY_VERSION_PATTERN);
2927
- return match ? `${match[1]}.${match[2]}` : null;
3483
+ const match = stripContextHints(s).match(MODEL_FAMILY_VERSION_PATTERN);
3484
+ if (!match)
3485
+ return null;
3486
+ return match[2] ? `${match[1]}.${match[2]}` : match[1];
2928
3487
  }
2929
3488
  function modelVersionsCompatible(preference, candidate) {
2930
3489
  const preferred = extractModelFamilyVersion(preference);
@@ -2969,21 +3528,43 @@ function scoreModelMatch(model, tokens, contextHint) {
2969
3528
  return 0;
2970
3529
  return score;
2971
3530
  }
2972
- function resolveModelPreference(models, preference) {
3531
+ export function resolveModelPreference(models, preference) {
2973
3532
  const trimmed = preference.trim();
2974
3533
  if (!trimmed)
2975
3534
  return null;
2976
3535
  const lower = trimmed.toLowerCase();
2977
- // Exact match on value or display name
3536
+ // Exact match on value or display name. Values compare on the canonical
3537
+ // hint spelling so "opus-1m" hits an "opus[1m]" row (and vice versa).
3538
+ const canonicalPreference = canonicalizeModelId(trimmed);
2978
3539
  const directMatch = models.find((model) => model.value === trimmed ||
2979
- model.value.toLowerCase() === lower ||
3540
+ canonicalizeModelId(model.value) === canonicalPreference ||
2980
3541
  model.displayName.toLowerCase() === lower);
2981
3542
  if (directMatch)
2982
3543
  return directMatch;
2983
- // Substring match
3544
+ // Exact match on the alias's canonical resolved id (e.g. a pinned
3545
+ // "claude-sonnet-5" against the "sonnet" row's `resolvedModel`). SDK-
3546
+ // reported and unambiguous, so it's tried before the fuzzier tiers below.
3547
+ // Compared on the canonical hint spelling so a "-1m"-suffix pin matches a
3548
+ // "[1m]"-spelled resolvedModel instead of falling into the substring tier
3549
+ // (which would land on the bare 200k sibling). "default" is skipped first
3550
+ // since it shares a resolvedModel with whichever alias the CLI currently
3551
+ // recommends — a specific pin should land on that named alias, not
3552
+ // "default".
3553
+ const matchesResolved = (model) => model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
3554
+ const resolvedMatch = models.find((model) => model.value !== "default" && matchesResolved(model)) ??
3555
+ models.find(matchesResolved);
3556
+ if (resolvedMatch)
3557
+ return resolvedMatch;
3558
+ // Substring match. Skips candidates whose context hint disagrees with the
3559
+ // preference's — a bare row must not absorb a 1M-hinted preference (nor
3560
+ // vice versa); such pairs fall through to the tokenized tier, which
3561
+ // weighs hints in its scoring and still finds the best same-family row.
3562
+ const preferenceHint = contextHintOf(trimmed);
2984
3563
  const includesMatch = models.find((model) => {
2985
3564
  if (!modelVersionsCompatible(trimmed, model))
2986
3565
  return false;
3566
+ if (contextHintOf(model.value) !== preferenceHint)
3567
+ return false;
2987
3568
  const value = model.value.toLowerCase();
2988
3569
  const display = model.displayName.toLowerCase();
2989
3570
  return value.includes(lower) || display.includes(lower) || lower.includes(value);
@@ -3007,6 +3588,51 @@ function resolveModelPreference(models, preference) {
3007
3588
  }
3008
3589
  return bestMatch;
3009
3590
  }
3591
+ /** Map the live model reported by a resumed session onto the picker's model
3592
+ * list. The CLI restores a resumed session's model from the transcript's
3593
+ * last assistant message, which records the concrete API id (e.g.
3594
+ * "claude-opus-4-6") with any "[1m]" context hint dropped. Tiers, in order:
3595
+ * 1. Exact match with the Default entry's resolution — when a named alias
3596
+ * shares Default's resolvedModel verbatim, the live id can't tell the
3597
+ * two apart, and a never-customized session should stay on Default.
3598
+ * 2. Exact resolvedModel match on a named row. Checked before the
3599
+ * hint-stripped Default comparison so a live "claude-sonnet-5[1m]" lands
3600
+ * on the "sonnet[1m]" row rather than a Default that resolves to the
3601
+ * bare "claude-sonnet-5" — the two rows differ in context window, which
3602
+ * drives `contextWindowSize` and capability gating downstream.
3603
+ * 3. Hint-stripped match with Default's resolution — a session that never
3604
+ * left the default resumes as the bare transcript id, and shouldn't show
3605
+ * a concrete picker entry.
3606
+ * 4. `resolveModelPreference` over the picker entries.
3607
+ * 5. A model with no picker counterpart (e.g. excluded by an
3608
+ * `availableModels` allowlist) is tracked verbatim, mirroring
3609
+ * `syncModelAfterRefusalFallback`: the picker shows no selection, but the
3610
+ * model-dependent bookkeeping stays truthful to what the SDK is running. */
3611
+ export function matchResumedModel(models, liveModel) {
3612
+ const live = canonicalizeModelId(liveModel);
3613
+ const defaultEntry = models.find((m) => m.value === "default");
3614
+ const defaultResolved = defaultEntry?.resolvedModel
3615
+ ? canonicalizeModelId(defaultEntry.resolvedModel)
3616
+ : undefined;
3617
+ if (defaultEntry && defaultResolved === live) {
3618
+ return defaultEntry;
3619
+ }
3620
+ // No default-row exclusion needed: a default row matching `live` exactly
3621
+ // already returned at the tier above.
3622
+ const exactMatch = models.find((m) => m.resolvedModel && canonicalizeModelId(m.resolvedModel) === live);
3623
+ if (exactMatch)
3624
+ return exactMatch;
3625
+ if (defaultEntry &&
3626
+ defaultResolved &&
3627
+ stripContextHints(defaultResolved) === stripContextHints(live)) {
3628
+ return defaultEntry;
3629
+ }
3630
+ return (resolveModelPreference(models, liveModel) ?? {
3631
+ value: liveModel,
3632
+ displayName: liveModel,
3633
+ description: "",
3634
+ });
3635
+ }
3010
3636
  function resolveSettingsModel(models, settingsModel, logger) {
3011
3637
  if (settingsModel === undefined) {
3012
3638
  return null;
@@ -3034,7 +3660,7 @@ function resolveSettingsModel(models, settingsModel, logger) {
3034
3660
  * - The Default option is unaffected by `availableModels` — it always remains
3035
3661
  * available, even when the allowlist is `[]`.
3036
3662
  */
3037
- function applyAvailableModelsAllowlist(sdkModels, allowlist) {
3663
+ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsModelOverrides) {
3038
3664
  // Default is always preserved per the docs. Synthesize one if the SDK
3039
3665
  // didn't surface it so downstream code (e.g. `getAvailableModels` picking
3040
3666
  // `models[0]` as a fallback) still has something to work with.
@@ -3046,18 +3672,29 @@ function applyAvailableModelsAllowlist(sdkModels, allowlist) {
3046
3672
  const result = [defaultModel];
3047
3673
  const seen = new Set([defaultModel.value]);
3048
3674
  const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
3675
+ // Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6")
3676
+ // in availableModels but require provider-specific IDs at the API. We still
3677
+ // resolve `sdkMatch` against the alias (`trimmed`) — that's what the
3678
+ // matching heuristics above are built for, and override targets (ARNs,
3679
+ // opaque provider IDs) often won't textually resemble anything in
3680
+ // `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the
3681
+ // override target, so it's what `setModel` ends up passing to the API.
3049
3682
  for (const entry of allowlist) {
3050
3683
  const trimmed = entry.trim();
3051
3684
  if (!trimmed || seen.has(trimmed))
3052
3685
  continue;
3686
+ const overridden = settingsModelOverrides?.[trimmed];
3687
+ const effective = overridden ?? trimmed;
3688
+ if (seen.has(effective))
3689
+ continue;
3053
3690
  const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
3054
3691
  if (sdkMatch) {
3055
- result.push({ ...sdkMatch, value: trimmed });
3692
+ result.push({ ...sdkMatch, value: effective });
3056
3693
  }
3057
3694
  else {
3058
- result.push({ value: trimmed, displayName: trimmed, description: "" });
3695
+ result.push({ value: effective, displayName: trimmed, description: "" });
3059
3696
  }
3060
- seen.add(trimmed);
3697
+ seen.add(effective);
3061
3698
  }
3062
3699
  // The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
3063
3700
  // allowlist, the same way Default is. Per the model-config docs it adds an
@@ -3075,14 +3712,30 @@ function applyAvailableModelsAllowlist(sdkModels, allowlist) {
3075
3712
  }
3076
3713
  return result;
3077
3714
  }
3078
- async function getAvailableModels(query, models, sdkModels, settingsManager, logger) {
3715
+ /** Read the model a resumed session is actually running (via the
3716
+ * `getContextUsage` control request — the same source `/context` prints) and
3717
+ * map it onto the picker. Best-effort: a control-request failure is logged
3718
+ * and returns null so callers keep their current choice; failing the whole
3719
+ * session/load over an unreadable report would be worse. */
3720
+ async function readResumedLiveModel(query, models, logger) {
3721
+ try {
3722
+ const liveModel = (await query.getContextUsage()).model;
3723
+ return liveModel ? matchResumedModel(models, liveModel) : null;
3724
+ }
3725
+ catch (error) {
3726
+ logger.error("Failed to read the resumed session's live model:", error);
3727
+ return null;
3728
+ }
3729
+ }
3730
+ async function getAvailableModels(query, models, sdkModels, settingsManager, logger, isResumedSession) {
3079
3731
  const settings = settingsManager.getSettings();
3080
3732
  let currentModel = models[0];
3081
3733
  let resolvedFromInput;
3082
3734
  // Model priority (highest to lowest):
3083
3735
  // 1. ANTHROPIC_MODEL environment variable
3084
3736
  // 2. settings.model (user configuration)
3085
- // 3. models[0] (default first model)
3737
+ // 3. the resumed session's live model (resumed sessions only)
3738
+ // 4. models[0] (default first model)
3086
3739
  if (process.env.ANTHROPIC_MODEL) {
3087
3740
  const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
3088
3741
  if (match) {
@@ -3097,21 +3750,49 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
3097
3750
  resolvedFromInput = settings.model;
3098
3751
  }
3099
3752
  }
3753
+ // A resumed session restores the model it was previously running (the CLI
3754
+ // re-reads it from the transcript), so without an env/settings override the
3755
+ // freshly-computed default above can disagree with what the session actually
3756
+ // runs — session/load then reports a model the session isn't using (issue
3757
+ // #845). Ask the CLI for the live model and reflect it. No `setModel` here:
3758
+ // the SDK is already running this model, and pushing a picker alias back
3759
+ // (e.g. "opus[1m]") could change the live model rather than describe it.
3760
+ if (resolvedFromInput === undefined && isResumedSession) {
3761
+ currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
3762
+ }
3100
3763
  // Skip the setModel round-trip when we can prove the SDK has already landed
3101
3764
  // on the same model. Two cases qualify:
3102
- // (a) No override applied — currentModel stayed at models[0]; the SDK is on
3103
- // its own default and we have nothing to sync.
3765
+ // (a) No override applied — currentModel is the SDK's own default (or, on
3766
+ // resume, the live model read back from the SDK above); nothing to sync.
3104
3767
  // (b) The resolver returned the user's input verbatim AND that value exists
3105
3768
  // in the SDK's original model list — meaning no fuzzy match or
3106
3769
  // allowlist rewrite was involved, and the SDK (which reads the same
3107
3770
  // ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
3771
+ // This only holds for fresh sessions: a resumed session lands on the
3772
+ // transcript's model regardless of env/settings, so the override must
3773
+ // be re-asserted to keep the reported model truthful.
3108
3774
  // Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
3109
3775
  // setModel call so we don't drift from the user's intended pin.
3110
3776
  const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
3111
3777
  const skipSetModel = resolvedFromInput === undefined ||
3112
- (currentModel.value === resolvedFromInput && sdkSawSameValue);
3778
+ (!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
3113
3779
  if (!skipSetModel) {
3114
- await query.setModel(currentModel.value);
3780
+ try {
3781
+ await query.setModel(currentModel.value);
3782
+ }
3783
+ catch (error) {
3784
+ // On a fresh session the pin is a defining option — fail loudly. A
3785
+ // resumed session already runs fine on the transcript's model, so
3786
+ // failing the whole session/load over the re-assert would be worse
3787
+ // than loading with the pin unapplied (mirrors the setPermissionMode
3788
+ // containment in createSession). The SDK then stayed on the
3789
+ // transcript's model, so read that back rather than reporting the
3790
+ // pin the session isn't running.
3791
+ if (!isResumedSession)
3792
+ throw error;
3793
+ logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
3794
+ currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
3795
+ }
3115
3796
  }
3116
3797
  return {
3117
3798
  availableModels: models.map((model) => ({
@@ -3284,6 +3965,53 @@ function applyMessageId(update, messageId) {
3284
3965
  update.messageId = messageId;
3285
3966
  }
3286
3967
  }
3968
+ /** Built-in tools that drive the task list (headless/SDK sessions use these
3969
+ * instead of TodoWrite). Their tool_use/tool_result are surfaced as `plan`
3970
+ * snapshots rather than as tool_calls. */
3971
+ function isTaskTool(toolName) {
3972
+ return (toolName === "TaskCreate" ||
3973
+ toolName === "TaskUpdate" ||
3974
+ toolName === "TaskList" ||
3975
+ toolName === "TaskGet");
3976
+ }
3977
+ /** Whether a tool's tool_use surfaces to the client as a standalone
3978
+ * `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
3979
+ * suppressed (their plan snapshot is emitted at tool_result time), so neither
3980
+ * produces a tool_call. */
3981
+ function shouldEmitToolCall(toolName) {
3982
+ return toolName !== "TodoWrite" && !isTaskTool(toolName);
3983
+ }
3984
+ /** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
3985
+ * notification for a tool_use. Shared by every site that surfaces a tool call:
3986
+ * the streamed tool_use path (first encounter → tool_call, later encounter →
3987
+ * refine) and the permission flow (`ensureToolCallEmitted`), so they can't
3988
+ * drift. The initial `tool_call` carries `status: "pending"` and, for Bash, the
3989
+ * `terminal_info` _meta that the later `terminal_output`/`terminal_exit`
3990
+ * updates key off of; a refining `tool_call_update` carries neither. */
3991
+ function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
3992
+ if (refine) {
3993
+ return {
3994
+ _meta: { claudeCode: { toolName: toolUse.name } },
3995
+ toolCallId: toolUse.id,
3996
+ sessionUpdate: "tool_call_update",
3997
+ rawInput,
3998
+ ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
3999
+ };
4000
+ }
4001
+ return {
4002
+ _meta: {
4003
+ claudeCode: { toolName: toolUse.name },
4004
+ ...(toolUse.name === "Bash" && supportsTerminalOutput
4005
+ ? { terminal_info: { terminal_id: toolUse.id } }
4006
+ : {}),
4007
+ },
4008
+ toolCallId: toolUse.id,
4009
+ sessionUpdate: "tool_call",
4010
+ rawInput,
4011
+ status: "pending",
4012
+ ...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
4013
+ };
4014
+ }
3287
4015
  /**
3288
4016
  * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
3289
4017
  * Only handles text, image, and thinking chunks for now.
@@ -3293,6 +4021,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3293
4021
  const registerHooks = options?.registerHooks !== false;
3294
4022
  const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
3295
4023
  if (typeof content === "string") {
4024
+ if (content.length === 0) {
4025
+ return [];
4026
+ }
3296
4027
  const update = {
3297
4028
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
3298
4029
  content: {
@@ -3318,15 +4049,18 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3318
4049
  let update = null;
3319
4050
  switch (chunk.type) {
3320
4051
  case "text":
3321
- case "text_delta":
3322
- update = {
3323
- sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
3324
- content: {
3325
- type: "text",
3326
- text: chunk.text,
3327
- },
3328
- };
4052
+ case "text_delta": {
4053
+ if (chunk.text) {
4054
+ update = {
4055
+ sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
4056
+ content: {
4057
+ type: "text",
4058
+ text: chunk.text,
4059
+ },
4060
+ };
4061
+ }
3329
4062
  break;
4063
+ }
3330
4064
  case "image":
3331
4065
  update = {
3332
4066
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
@@ -3339,10 +4073,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3339
4073
  };
3340
4074
  break;
3341
4075
  case "thinking":
3342
- case "thinking_delta":
4076
+ case "thinking_delta": {
3343
4077
  // Recent models default `thinking.display` to "omitted", which streams
3344
4078
  // signature-only thinking blocks whose text is empty.
3345
- if (chunk.thinking.length > 0) {
4079
+ if (chunk.thinking) {
3346
4080
  update = {
3347
4081
  sessionUpdate: "agent_thought_chunk",
3348
4082
  content: {
@@ -3352,6 +4086,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3352
4086
  };
3353
4087
  }
3354
4088
  break;
4089
+ }
3355
4090
  case "tool_use":
3356
4091
  case "server_tool_use":
3357
4092
  case "mcp_tool_use": {
@@ -3366,10 +4101,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3366
4101
  };
3367
4102
  }
3368
4103
  }
3369
- else if (chunk.name === "TaskCreate" ||
3370
- chunk.name === "TaskUpdate" ||
3371
- chunk.name === "TaskList" ||
3372
- chunk.name === "TaskGet") {
4104
+ else if (isTaskTool(chunk.name)) {
3373
4105
  // Task* tool_use is suppressed; the plan update is emitted at
3374
4106
  // tool_result time once we have the task ID (for TaskCreate) and
3375
4107
  // confirmation that the change took effect.
@@ -3421,40 +4153,25 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3421
4153
  catch {
3422
4154
  // ignore if we can't turn it to JSON
3423
4155
  }
3424
- if (alreadyCached) {
3425
- // Second encounter (full assistant message after streaming) —
3426
- // send as tool_call_update to refine the existing tool_call
3427
- // rather than emitting a duplicate tool_call.
3428
- update = {
3429
- _meta: {
3430
- claudeCode: {
3431
- toolName: chunk.name,
3432
- },
3433
- },
3434
- toolCallId: chunk.id,
3435
- sessionUpdate: "tool_call_update",
3436
- rawInput,
3437
- ...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
3438
- };
4156
+ // Emit a `tool_call` only the first time this id surfaces to the
4157
+ // client; afterwards refine it with a `tool_call_update`. The first
4158
+ // surface may be this stream chunk OR an earlier permission request
4159
+ // (see `ensureToolCallEmitted`), so emission is tracked separately
4160
+ // from `toolUseCache`. Without an `emittedToolCalls` set we fall back
4161
+ // to cache presence — the historical streaming-only behavior.
4162
+ const emittedToolCalls = options?.emittedToolCalls;
4163
+ const alreadyEmitted = emittedToolCalls ? emittedToolCalls.has(chunk.id) : alreadyCached;
4164
+ emittedToolCalls?.add(chunk.id);
4165
+ if (alreadyEmitted) {
4166
+ // Already surfaced (full assistant message after streaming, or a
4167
+ // permission request emitted it first) — refine with a
4168
+ // tool_call_update rather than emitting a duplicate tool_call.
4169
+ update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd, true);
3439
4170
  }
3440
4171
  else {
3441
- // First encounter (streaming content_block_start or replay) —
3442
- // send as tool_call with terminal_info for Bash tools.
3443
- update = {
3444
- _meta: {
3445
- claudeCode: {
3446
- toolName: chunk.name,
3447
- },
3448
- ...(chunk.name === "Bash" && supportsTerminalOutput
3449
- ? { terminal_info: { terminal_id: chunk.id } }
3450
- : {}),
3451
- },
3452
- toolCallId: chunk.id,
3453
- sessionUpdate: "tool_call",
3454
- rawInput,
3455
- status: "pending",
3456
- ...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
3457
- };
4172
+ // First surface (streaming content_block_start or replay) — send as
4173
+ // tool_call (with terminal_info for Bash).
4174
+ update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd);
3458
4175
  }
3459
4176
  }
3460
4177
  break;
@@ -3467,15 +4184,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3467
4184
  case "bash_code_execution_tool_result":
3468
4185
  case "text_editor_code_execution_tool_result":
3469
4186
  case "mcp_tool_result": {
4187
+ options?.emittedToolCalls?.delete(chunk.tool_use_id);
3470
4188
  const toolUse = toolUseCache[chunk.tool_use_id];
3471
4189
  if (!toolUse) {
3472
4190
  logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
3473
4191
  break;
3474
4192
  }
3475
- if (toolUse.name === "TaskCreate" ||
3476
- toolUse.name === "TaskUpdate" ||
3477
- toolUse.name === "TaskList" ||
3478
- toolUse.name === "TaskGet") {
4193
+ if (isTaskTool(toolUse.name)) {
3479
4194
  // Headless/SDK sessions emit Task* tools instead of TodoWrite.
3480
4195
  // TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
3481
4196
  // and TaskGet are read-only so we just suppress their tool_call /
@@ -3582,6 +4297,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
3582
4297
  parentToolUseId: message.parent_tool_use_id,
3583
4298
  cwd: options?.cwd,
3584
4299
  taskState: options?.taskState,
4300
+ emittedToolCalls: options?.emittedToolCalls,
3585
4301
  messageId: options?.messageId,
3586
4302
  });
3587
4303
  case "content_block_delta":
@@ -3590,6 +4306,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
3590
4306
  parentToolUseId: message.parent_tool_use_id,
3591
4307
  cwd: options?.cwd,
3592
4308
  taskState: options?.taskState,
4309
+ emittedToolCalls: options?.emittedToolCalls,
3593
4310
  messageId: options?.messageId,
3594
4311
  });
3595
4312
  // No content. `ping` is a Messages-API keep-alive event that the SDK's