@lucascouts/claude-agent-acp-plus 0.1.1 → 0.3.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
@@ -10,7 +10,10 @@ import packageJson from "../package.json" with { type: "json" };
10
10
  import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
11
11
  import { askUserQuestionFallbackEnabled, handleAskUserQuestionViaPermission, } from "./ask-user-question-fallback.js";
12
12
  import { agentName } from "./agent-name.js";
13
+ import { filterDeprecatedModels } from "./model-deprecation.js";
13
14
  import { SettingsManager } from "./settings.js";
15
+ import { createThinkingConfigOption, effectiveThinkingConfig, resolveThinkingSelection, THINKING_CONFIG_ID, } from "./thinking-option.js";
16
+ import { handleRewindCommand, parseRewindInvocation } from "./rewind-command.js";
14
17
  import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
15
18
  import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
16
19
  export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
@@ -44,6 +47,16 @@ const DEFAULT_CONTEXT_WINDOW = 200000;
44
47
  * "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
45
48
  * pre-empt a slow-but-healthy interrupt. */
46
49
  const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
50
+ /** Ceiling on the lazy query recreate's initialization phase (see
51
+ * `recreateSessionQuery`). A wedged replacement subprocess could hang
52
+ * `initializationResult()` forever (same wedge class as issue #680), and
53
+ * every join on `queryRecreateInFlight` — prompt/cancel/the config setters,
54
+ * and closeSession/deleteSession/dispose via `teardownSession → cancel()` —
55
+ * would inherit that hang. Expiry routes to the recreate's failure path (the
56
+ * replacement is closed, the old query stays live, the pending flag is
57
+ * retained), bounding every join. Same "obviously stuck" rationale as
58
+ * DEFAULT_FORCE_CANCEL_GRACE_MS. */
59
+ const QUERY_RECREATE_INIT_TIMEOUT_MS = 30_000;
47
60
  /** Error surfaced when the SDK declares a turn over (`session_state_changed:
48
61
  * idle`, its authoritative turn-over signal) without ever emitting the turn's
49
62
  * `result` — a model stream that dropped mid-turn, or an async agent that
@@ -581,6 +594,103 @@ export class ClaudeAcpAgent {
581
594
  if (session.queryClosed) {
582
595
  throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
583
596
  }
597
+ // `/rewind` is served fully locally (story 006, R3.2/R3.3): it lists or
598
+ // restores file checkpoints via the SDK file-rewind API and streams its own
599
+ // messages. It must be intercepted BEFORE the lazy query-recreate below (a
600
+ // `/rewind` must not consume this session's pending recreate — that belongs
601
+ // to the next real turn) and must never enqueue a turn. handleRewindCommand
602
+ // emits every user-facing update before it resolves, so returning end_turn
603
+ // here leaves Zed with a complete turn rather than an empty/hung one.
604
+ const commandText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
605
+ const rewindInvocation = parseRewindInvocation(commandText);
606
+ if (rewindInvocation) {
607
+ // A lazy Thinking recreate may be swapping the session's query right
608
+ // now; join it first (mirroring cancel()) so the RewindDeps below
609
+ // capture the live replacement query — not the old one mid-close — and
610
+ // so the active-turn guard next evaluates the post-recreate queue
611
+ // state. Safe: `recreateSessionQuery` never rejects.
612
+ if (session.queryRecreateInFlight) {
613
+ await session.queryRecreateInFlight;
614
+ }
615
+ // Never touch checkpoints while a turn is active or queued (same idleness
616
+ // predicate as the lazy query-recreate trigger below). The turn FIFO
617
+ // exists precisely so work lands in submission order; `/rewind` bypassing
618
+ // it would let `query.rewindFiles()` restore files that the active turn
619
+ // is editing at that very moment — a mixed workspace state — and would
620
+ // interleave the rewind's output with the turn's. Refuse, explain, and
621
+ // leave the checkpoints untouched.
622
+ if (session.turnQueue?.length || session.activeTurn) {
623
+ await this.client.sessionUpdate({
624
+ sessionId: params.sessionId,
625
+ update: {
626
+ sessionUpdate: "agent_message_chunk",
627
+ content: {
628
+ type: "text",
629
+ text: "A turn is still running in this session, so `/rewind` was not executed " +
630
+ "(files may be under active edit). Stop the current turn first, then run " +
631
+ "`/rewind` again.",
632
+ },
633
+ },
634
+ });
635
+ return { stopReason: "end_turn" };
636
+ }
637
+ const deps = {
638
+ sessionId: params.sessionId,
639
+ client: this.client,
640
+ query: session.query,
641
+ // getSessionMessages resolves the SDK's SessionMessage[] (a loosely
642
+ // typed transcript-row shape that listCheckpoints reads structurally);
643
+ // reinterpret it as the RewindDeps SDKMessage[] contract.
644
+ getSessionMessages: async (sessionId) => {
645
+ const messages = await getSessionMessages(sessionId);
646
+ return messages;
647
+ },
648
+ };
649
+ // Flag the restore for its whole duration (see Session.rewindInFlight):
650
+ // a rewind holds no Turn, so without it a concurrent real prompt would
651
+ // pass the recreate trigger's idleness check below and close the query
652
+ // under `rewindFiles`.
653
+ session.rewindInFlight = true;
654
+ try {
655
+ await handleRewindCommand(deps, rewindInvocation);
656
+ }
657
+ catch (error) {
658
+ // handleRewindCommand catches its own dep failures; only a rejection
659
+ // of the client's sessionUpdate channel itself propagates here. Guard
660
+ // it and report locally instead of surfacing a failed turn.
661
+ this.logger.error(`Session ${params.sessionId}: /rewind command failed: ${error}`);
662
+ }
663
+ finally {
664
+ session.rewindInFlight = false;
665
+ }
666
+ return { stopReason: "end_turn" };
667
+ }
668
+ // Lazy Thinking application (R1.3): a pending config change is applied by
669
+ // recreating the SDK query with session resume BEFORE this turn is
670
+ // enqueued — never mid-turn, so only when no other prompt is in flight. A
671
+ // concurrent prompt() joins the in-flight recreate rather than racing a
672
+ // second one (or pushing onto the old query mid-swap). On recreate failure
673
+ // the flag stays set (retried by the next prompt) and this turn proceeds
674
+ // on the old query; `recreateSessionQuery` never rejects.
675
+ if (session.queryRecreateInFlight) {
676
+ await session.queryRecreateInFlight;
677
+ }
678
+ else if (session.pendingQueryRecreate &&
679
+ !session.turnQueue?.length &&
680
+ !session.activeTurn &&
681
+ // An in-flight `/rewind` restore holds no Turn, so the queue checks
682
+ // above can't see it; recreating now would close the query underneath
683
+ // `rewindFiles` (see Session.rewindInFlight).
684
+ !session.rewindInFlight) {
685
+ const recreate = this.recreateSessionQuery(params.sessionId, session);
686
+ session.queryRecreateInFlight = recreate;
687
+ try {
688
+ await recreate;
689
+ }
690
+ finally {
691
+ session.queryRecreateInFlight = undefined;
692
+ }
693
+ }
584
694
  const userMessage = promptToClaude(params);
585
695
  const promptUuid = randomUUID();
586
696
  userMessage.uuid = promptUuid;
@@ -805,6 +915,13 @@ export class ClaudeAcpAgent {
805
915
  // settle "cancelled" even when query.next() is wedged (issue #680). Re-armed
806
916
  // after each fire so the consumer keeps serving later turns.
807
917
  let cancelController = session.cancelController;
918
+ // The query this consumer serves, bound at start: the lazy Thinking
919
+ // recreate (`recreateSessionQuery`) can swap `session.query` for a fresh
920
+ // one while this consumer is parked on the OLD query's next(). A
921
+ // superseded consumer must abdicate — a fresh consumer owns the new
922
+ // query — WITHOUT running its end-of-stream/error cleanup, which would
923
+ // close the session's LIVE (replacement) resources.
924
+ const myQuery = session.query;
808
925
  // The in-flight query.next(), kept across abort wake-ups that don't
809
926
  // consume a message, so no yielded message is ever dropped — async
810
927
  // generators serialize next() calls, so racing a SECOND next() while one
@@ -814,9 +931,15 @@ export class ClaudeAcpAgent {
814
931
  let pendingNext = null;
815
932
  try {
816
933
  while (true) {
817
- pendingNext ??= session.query
818
- .next()
819
- .then((result) => ({ kind: "message", result }));
934
+ // Superseded by a lazy query recreate: a fresh consumer owns
935
+ // `session.query` now. Abandon the stale in-flight next() (swallowing
936
+ // its eventual settlement so it can't surface as unhandled) and exit
937
+ // without touching the session's turns or resources.
938
+ if (session.query !== myQuery) {
939
+ void pendingNext?.catch(() => { });
940
+ return;
941
+ }
942
+ pendingNext ??= myQuery.next().then((result) => ({ kind: "message", result }));
820
943
  const nextMessage = pendingNext;
821
944
  // Fresh abort listener per iteration, removed when next() wins, so a
822
945
  // long-lived session doesn't accumulate listeners on one signal.
@@ -840,12 +963,16 @@ export class ClaudeAcpAgent {
840
963
  if (session.activeTurn && !session.activeTurn.settled) {
841
964
  session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
842
965
  }
843
- settleActive({ stopReason: "cancelled" });
844
- // If the session is being torn down, abandon the in-flight next()
845
- // (swallowing any later rejection so it can't surface as unhandled)
846
- // and stop; otherwise re-arm and keep consuming — `pendingNext`
847
- // stays in flight so its eventual message is processed, not dropped.
848
- if (!this.sessions[params.sessionId]) {
966
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
967
+ // If the session is being torn down — or this consumer was
968
+ // superseded by a lazy query recreate — abandon the in-flight
969
+ // next() (swallowing any later rejection so it can't surface as
970
+ // unhandled) and stop; otherwise re-arm and keep consuming —
971
+ // `pendingNext` stays in flight so its eventual message is
972
+ // processed, not dropped. The supersession check also keeps a
973
+ // superseded consumer from clobbering `session.cancelController`
974
+ // (re-armed below) under the replacement consumer.
975
+ if (!this.sessions[params.sessionId] || session.query !== myQuery) {
849
976
  void nextMessage.catch(() => { });
850
977
  return;
851
978
  }
@@ -857,6 +984,14 @@ export class ClaudeAcpAgent {
857
984
  pendingNext = null;
858
985
  const { value: message, done } = raced.result;
859
986
  if (done || !message) {
987
+ // A superseded consumer (the lazy query recreate closed the OLD
988
+ // query out from under it) must NOT run end-of-stream cleanup: the
989
+ // session's query/input/settings now belong to the replacement and
990
+ // `closeQueryStream` would tear the LIVE ones down. The recreate
991
+ // path owns the whole transition; just exit.
992
+ if (session.query !== myQuery) {
993
+ return;
994
+ }
860
995
  // The stream ended. Settle the in-flight turns FIRST, then release the
861
996
  // stream resources — same order as the error paths (failAllTurns before
862
997
  // closeQueryStream). Settling is the user-facing contract; resource
@@ -865,9 +1000,10 @@ export class ClaudeAcpAgent {
865
1000
  //
866
1001
  // Settle the turn that was in flight so its prompt() doesn't hang:
867
1002
  // cancelled if a cancel is pending, otherwise the accumulated outcome.
868
- settleActive(session.cancelled
869
- ? { stopReason: "cancelled" }
870
- : { stopReason, usage: sessionUsage(session) });
1003
+ settleActive({
1004
+ stopReason: session.cancelled ? "cancelled" : stopReason,
1005
+ usage: sessionUsage(session),
1006
+ });
871
1007
  // Queued turns the SDK never started never ran, so reject them rather
872
1008
  // than reporting a success (end_turn) — or a misleading "cancelled" —
873
1009
  // for a prompt that produced no output. (A cancel already settled the
@@ -1010,8 +1146,14 @@ export class ClaudeAcpAgent {
1010
1146
  // the turn NOW so its session/prompt gets a terminal
1011
1147
  // response, instead of leaving it hanging until the next
1012
1148
  // prompt drains the wreckage.
1149
+ // A cancelled turn still consumed tokens: its dropped result
1150
+ // already fed the accumulator (the usage tally at the result
1151
+ // handler runs before the `session.cancelled` guard), so
1152
+ // report it — clients metering spend would otherwise lose
1153
+ // the interrupted turn's tokens entirely (issue #844). Zero
1154
+ // when the cancel pre-empted the result (wedge/force-cancel).
1013
1155
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
1014
- settleActive({ stopReason: "cancelled" });
1156
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1015
1157
  }
1016
1158
  else if (owedTrailingIdles > 0) {
1017
1159
  // Absorb a settled turn's trailing idle. Also covers a
@@ -1476,7 +1618,7 @@ export class ClaudeAcpAgent {
1476
1618
  // create a block the consolidated handler's `text.length > 0`
1477
1619
  // guard can never consume, stalling the diff cursor and
1478
1620
  // re-emitting the next block as a duplicate.
1479
- if (chunk && chunk.text.length > 0) {
1621
+ if (chunk?.text) {
1480
1622
  const index = message.event.index;
1481
1623
  const last = streamedBlocks[streamedBlocks.length - 1];
1482
1624
  if (last && last.index === index && last.type === chunk.type) {
@@ -1581,7 +1723,9 @@ export class ClaudeAcpAgent {
1581
1723
  // as the freshly-activated turn ending without a result
1582
1724
  // (which would false-fail a healthy turn — issue #825).
1583
1725
  owedTrailingIdles++;
1584
- settleActive({ stopReason: "cancelled" });
1726
+ // Before activateTurn resets the accumulator, so the
1727
+ // usage still belongs to the cancelled turn.
1728
+ settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
1585
1729
  }
1586
1730
  else {
1587
1731
  settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
@@ -1804,6 +1948,13 @@ export class ClaudeAcpAgent {
1804
1948
  // below, so there is no normal fall-through here.
1805
1949
  }
1806
1950
  catch (error) {
1951
+ // A superseded consumer's stale next() may reject when the lazy query
1952
+ // recreate closes the old query; the session's live turns and resources
1953
+ // belong to the replacement consumer, so exit without failing turns or
1954
+ // releasing anything.
1955
+ if (session.query !== myQuery) {
1956
+ return;
1957
+ }
1807
1958
  // The query stream itself died (a transport/process error surfaced from
1808
1959
  // query.next()). Turn-level failures (auth, error results) are handled
1809
1960
  // inline via failActive and never reach here. Reject every in-flight turn;
@@ -1838,6 +1989,15 @@ export class ClaudeAcpAgent {
1838
1989
  if (!session) {
1839
1990
  return;
1840
1991
  }
1992
+ // A lazy Thinking recreate may be swapping the session's query right now;
1993
+ // join it (mirroring prompt()) so `interrupt()` below targets the live
1994
+ // replacement query instead of racing the old one's close — an interrupt
1995
+ // control request killed by that close would reject out of this
1996
+ // fire-and-forget notification. Safe: `recreateSessionQuery` never
1997
+ // rejects.
1998
+ if (session.queryRecreateInFlight) {
1999
+ await session.queryRecreateInFlight;
2000
+ }
1841
2001
  // The stream already ended (see closeQueryStream): every in-flight turn was
1842
2002
  // settled when it closed, and there is no live query to interrupt. Calling
1843
2003
  // query.interrupt() on a finished iterator could reject and surface from
@@ -1850,20 +2010,22 @@ export class ClaudeAcpAgent {
1850
2010
  // they have no in-flight SDK work to interrupt. The active turn is settled
1851
2011
  // by the consumer when it observes the interrupt's trailing idle (or via the
1852
2012
  // backstop below). Mirrors the old pendingMessages cancellation.
2013
+ const orphanedUuids = [];
1853
2014
  if (session.turnQueue) {
1854
- let orphaned = 0;
1855
2015
  for (const turn of session.turnQueue) {
1856
2016
  if (turn !== session.activeTurn && !turn.settled) {
1857
2017
  turn.settled = true;
2018
+ // Deliberately no `usage`: a queued turn never ran, so the session
2019
+ // accumulator (the active turn's tally) is not its spend.
1858
2020
  turn.resolve({ stopReason: "cancelled" });
1859
- orphaned++;
2021
+ orphanedUuids.push(turn.promptUuid);
1860
2022
  }
1861
2023
  }
1862
2024
  // Each removed queued turn's user message was already pushed to the SDK,
1863
2025
  // which processes input FIFO and will still emit a result for it with no
1864
2026
  // uuid to match. Count those so the consumer skips them (see
1865
2027
  // ensureActiveTurn) rather than misattributing them to the head.
1866
- session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphaned;
2028
+ session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphanedUuids.length;
1867
2029
  session.turnQueue = session.turnQueue.filter((turn) => turn === session.activeTurn && !turn.settled);
1868
2030
  }
1869
2031
  // Arm a backstop before interrupting: if a turn is actively consuming the
@@ -1887,7 +2049,29 @@ export class ClaudeAcpAgent {
1887
2049
  cancelController.abort();
1888
2050
  }, this.forceCancelGraceMs);
1889
2051
  }
1890
- await session.query.interrupt();
2052
+ const receipt = await session.query.interrupt();
2053
+ // On CLIs advertising `interrupt_receipt_v1`, the receipt's `still_queued`
2054
+ // lists exactly which queued messages survive the interrupt and will still
2055
+ // run. An orphaned turn whose uuid is absent was dropped by the interrupt
2056
+ // and will never emit a result — uncount it now instead of leaving a stale
2057
+ // skip that activateTurn's reset only clears once a later live ECHO
2058
+ // arrives: an echo-less result in between (a local-only command like
2059
+ // `/context`) would be wrongly swallowed by the leftover count. Subtracting
2060
+ // a count (rather than tracking uuids) stays race-safe against the
2061
+ // consumer draining concurrently: dropped uuids produce no results, so the
2062
+ // consumer's decrements only ever consume the still-queued share. Unknown
2063
+ // uuids in the receipt (internally-enqueued messages) are ignored, per its
2064
+ // contract. Older CLIs resolve `undefined` (guard the FIELD, not just the
2065
+ // receipt, so a bare `{}` success from a gateway can't read as "everything
2066
+ // was dropped") — keep the count-everything behavior and its
2067
+ // activation-time self-heal.
2068
+ if (Array.isArray(receipt?.still_queued) && orphanedUuids.length > 0) {
2069
+ const stillQueued = new Set(receipt.still_queued);
2070
+ const dropped = orphanedUuids.filter((uuid) => !stillQueued.has(uuid)).length;
2071
+ if (dropped > 0) {
2072
+ session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
2073
+ }
2074
+ }
1891
2075
  }
1892
2076
  /** Mark a session's SDK query stream as permanently ended and release the
1893
2077
  * resources tied to it: drop the consumer handle, dispose the settings
@@ -1971,6 +2155,14 @@ export class ClaudeAcpAgent {
1971
2155
  if (!session) {
1972
2156
  throw new Error("Session not found");
1973
2157
  }
2158
+ // A lazy Thinking recreate may be swapping the session's query right now;
2159
+ // join it (mirroring prompt()) so `setPermissionMode` below lands on the
2160
+ // replacement query after cutover instead of an about-to-close one — and
2161
+ // so the mode isn't silently dropped from a query built from the
2162
+ // pre-await state snapshot. Safe: `recreateSessionQuery` never rejects.
2163
+ if (session.queryRecreateInFlight) {
2164
+ await session.queryRecreateInFlight;
2165
+ }
1974
2166
  // The SDK query stream already ended (see closeQueryStream); the session is
1975
2167
  // a husk and `query.setPermissionMode` below would act on a closed query.
1976
2168
  // Fail with the same clear message prompt()/cancel() give for a dead stream.
@@ -1986,6 +2178,16 @@ export class ClaudeAcpAgent {
1986
2178
  if (!session) {
1987
2179
  throw new Error("Session not found");
1988
2180
  }
2181
+ // A lazy Thinking recreate may be swapping the session's query right now;
2182
+ // join it (mirroring prompt()) so the model/mode/effort/fast SDK calls
2183
+ // below land on the replacement query after cutover instead of an
2184
+ // about-to-close one — and so their state updates aren't silently
2185
+ // dropped from a query built from the pre-await state snapshot. The
2186
+ // THINKING branch only arms the pending flag, so joining first is
2187
+ // harmless for it. Safe: `recreateSessionQuery` never rejects.
2188
+ if (session.queryRecreateInFlight) {
2189
+ await session.queryRecreateInFlight;
2190
+ }
1989
2191
  // The SDK query stream already ended (see closeQueryStream); the session is
1990
2192
  // a husk and the `query.setModel`/`setPermissionMode`/`applyFlagSettings`
1991
2193
  // calls this triggers would act on a closed query. Fail with the same clear
@@ -1997,13 +2199,29 @@ export class ClaudeAcpAgent {
1997
2199
  if (!option) {
1998
2200
  throw new Error(`Unknown config option: ${params.configId}`);
1999
2201
  }
2000
- // Fast mode carries a boolean value (for Clients that opted into boolean
2001
- // config options) or the "on"/"off" select fallback, so it bypasses the
2002
- // string-only validation the select-style options below rely on.
2202
+ // Fast mode is always emitted as an "on"/"off" select, but a native
2203
+ // boolean set value is still accepted for compatibility (R2.3), so it
2204
+ // bypasses the string-only validation the select-style options below
2205
+ // rely on.
2003
2206
  if (params.configId === FAST_MODE_CONFIG_ID) {
2004
2207
  await this.applyFastMode(session, resolveFastModeEnabled(params));
2005
2208
  return { configOptions: session.configOptions };
2006
2209
  }
2210
+ // The Thinking toggle only records the session's intent — the SDK query
2211
+ // swap is deferred to the next prompt() start (lazy recreate, sub-task
2212
+ // 1.3), so there is no SDK call to fail here and the config option is
2213
+ // refreshed and acked immediately, mirroring Fast mode. An unrecognized
2214
+ // value deliberately falls through to the shared invalid-option-value
2215
+ // validation below rather than growing a bespoke error shape.
2216
+ if (params.configId === THINKING_CONFIG_ID) {
2217
+ const enabled = resolveThinkingSelection(params.value);
2218
+ if (enabled !== null) {
2219
+ session.thinkingEnabled = enabled;
2220
+ session.pendingQueryRecreate = true;
2221
+ session.configOptions = session.configOptions.map((o) => o.id === THINKING_CONFIG_ID ? createThinkingConfigOption(enabled) : o);
2222
+ return { configOptions: session.configOptions };
2223
+ }
2224
+ }
2007
2225
  if (typeof params.value !== "string") {
2008
2226
  throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
2009
2227
  }
@@ -2011,18 +2229,40 @@ export class ClaudeAcpAgent {
2011
2229
  ? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
2012
2230
  : [];
2013
2231
  let validValue = allValues.find((o) => o.value === params.value);
2232
+ // The option's reported currentValue is always a valid target, even when
2233
+ // it has no options entry: a session running an out-of-picker model
2234
+ // (resumed onto an allowlist-excluded model, or a refusal fallback)
2235
+ // reports a currentValue that isn't selectable, and a client
2236
+ // round-tripping it must not get "Invalid value". It flows through the
2237
+ // normal apply path below — re-asserting an already-current value is
2238
+ // harmless and can repair SDK drift.
2239
+ if (!validValue && option.currentValue === params.value) {
2240
+ validValue = { value: params.value, name: params.value };
2241
+ }
2014
2242
  // For model options, fall back to resolveModelPreference when the exact
2015
2243
  // value doesn't match. This lets callers use human-friendly aliases like
2016
2244
  // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
2245
+ // Resolve against session.modelInfos first: those entries carry
2246
+ // `resolvedModel`, so a full model id (in either hint spelling) lands on
2247
+ // the right row via the exact tier instead of a fuzzier one picking a
2248
+ // same-family sibling from a different context lane. The options-derived
2249
+ // list (which never carries `resolvedModel`) remains as a fallback for
2250
+ // resolutions that don't map back onto a selectable option (e.g. a fuzzy
2251
+ // hit on an out-of-picker verbatim entry).
2252
+ // No deprecation filter here (R4.2, no double work): this list is rebuilt
2253
+ // from the RENDERED option rows, which flow from the already-filtered
2254
+ // picker list (`hideDeprecatedModels` at every session-creation branch) —
2255
+ // so hidden rows are not selectable via aliases either.
2017
2256
  if (!validValue && params.configId === MODEL_CONFIG_ID) {
2018
- const modelInfos = allValues.map((o) => ({
2019
- value: o.value,
2020
- displayName: o.name,
2021
- description: o.description ?? "",
2022
- }));
2023
- const resolved = resolveModelPreference(modelInfos, params.value);
2024
- if (resolved) {
2025
- validValue = allValues.find((o) => o.value === resolved.value);
2257
+ const toOptionValue = (resolved) => resolved ? allValues.find((o) => o.value === resolved.value) : undefined;
2258
+ validValue = toOptionValue(resolveModelPreference(session.modelInfos, params.value));
2259
+ if (!validValue) {
2260
+ const optionInfos = allValues.map((o) => ({
2261
+ value: o.value,
2262
+ displayName: o.name,
2263
+ description: o.description ?? "",
2264
+ }));
2265
+ validValue = toOptionValue(resolveModelPreference(optionInfos, params.value));
2026
2266
  }
2027
2267
  }
2028
2268
  if (!validValue) {
@@ -2551,8 +2791,15 @@ export class ClaudeAcpAgent {
2551
2791
  // intent) when a supporting model is selected again.
2552
2792
  supported: newModelInfo?.supportsFastMode ?? false,
2553
2793
  enabled: session.fastModeEnabled,
2554
- useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
2555
- });
2794
+ },
2795
+ // Thinking is model-independent: re-render the retained tri-state
2796
+ // intent so the row survives this rebuild (an option not threaded
2797
+ // through here silently drops from the picker on every model switch,
2798
+ // R1.7). Untouched sessions keep displaying the env-driven state
2799
+ // (R1.6).
2800
+ session.thinkingEnabled ??
2801
+ effectiveThinkingConfig(undefined, process.env.MAX_THINKING_TOKENS, this.logger) !==
2802
+ undefined);
2556
2803
  // Sync effort with the SDK if it changed after the model switch
2557
2804
  const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
2558
2805
  const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
@@ -2629,11 +2876,12 @@ export class ClaudeAcpAgent {
2629
2876
  }
2630
2877
  }
2631
2878
  /** Replace the Fast mode option in `session.configOptions` so it reflects
2632
- * `enabled` (and the client's current boolean-capability). A no-op when the
2633
- * option isn't present, so callers must confirm the current model surfaces
2634
- * it first. */
2879
+ * `enabled`. A no-op when the option isn't present, so callers must confirm
2880
+ * the current model surfaces it first. Rebuilds through the single-parameter
2881
+ * {@link createFastModeConfigOption} — the one source of the option's shape —
2882
+ * so the shape can't drift from what `buildConfigOptions` first emitted. */
2635
2883
  refreshFastModeOption(session, enabled) {
2636
- const refreshed = createFastModeConfigOption(enabled, clientSupportsBooleanConfigOptions(this.clientCapabilities));
2884
+ const refreshed = createFastModeConfigOption(enabled);
2637
2885
  session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
2638
2886
  }
2639
2887
  /** Toggle Fast mode for a session: push the SDK flag, record the user's
@@ -2647,6 +2895,261 @@ export class ClaudeAcpAgent {
2647
2895
  session.fastModeEnabled = enabled;
2648
2896
  this.refreshFastModeOption(session, enabled);
2649
2897
  }
2898
+ /** Lazily swap a session's SDK query for a fresh one so a Thinking change
2899
+ * takes effect on the next turn (R1.3): thinking has no live flag-settings
2900
+ * path in the SDK, so mid-session application goes through query recreation
2901
+ * with session resume. The replacement resumes the SAME conversation
2902
+ * (`{ resume: sessionId }` — the adapter's session ids ARE the SDK's, see
2903
+ * `getOrCreateSession`) and is assembled from the creation-time
2904
+ * `queryOptions` plus the live-tracked session state (model, permission
2905
+ * mode, agent, effort, Fast mode), with thinking routed through
2906
+ * `effectiveThinkingConfig` — the same single code path query creation
2907
+ * uses (R1.5/R1.6).
2908
+ *
2909
+ * Deliberately NOT `teardownSession`: that aborts `session.abortController`
2910
+ * (possibly client-supplied and reused) and evicts the session from the
2911
+ * map. This path keeps the Session object registered and every controller
2912
+ * alive; only the query subprocess is replaced.
2913
+ *
2914
+ * Ordering is what makes the swap safe:
2915
+ * 1. Build + initialize the NEW query first, time-bounded
2916
+ * (QUERY_RECREATE_INIT_TIMEOUT_MS) so a wedged replacement cannot hang
2917
+ * the joins on `queryRecreateInFlight`. Any failure leaves the old
2918
+ * query untouched and usable: the user is told via agent_message_chunk,
2919
+ * `pendingQueryRecreate` stays set (retried on the next prompt), and the
2920
+ * caller's turn proceeds on the OLD query. Never rejects.
2921
+ * 2. Re-check, synchronously, session liveness and model/mode drift: if
2922
+ * the OLD stream died (or the session was evicted) while step 1
2923
+ * awaited — or a straggler setter moved the model/mode again after the
2924
+ * delta re-apply — abandon the replacement instead of installing it.
2925
+ * 3. Cut over synchronously — swap `session.query`/`input`, drop the
2926
+ * consumer handle (so `ensureConsumer` starts a fresh consumer for the
2927
+ * new query), clear the pending flag — THEN end the old stream. Swapping
2928
+ * before closing is load-bearing: the superseded consumer only wakes
2929
+ * after this microtask completes, sees `session.query !== myQuery`, and
2930
+ * exits via its supersession guards instead of running end-of-stream
2931
+ * cleanup (which would close the NEW query and dispose live resources).
2932
+ *
2933
+ * Only called from `prompt()` while the turn queue is idle (concurrent
2934
+ * RPC entry points join `queryRecreateInFlight`), so there are no in-flight
2935
+ * turns to migrate and the old query has nothing left to say. */
2936
+ async recreateSessionQuery(sessionId, session) {
2937
+ const newInput = new Pushable();
2938
+ let newQuery;
2939
+ try {
2940
+ if (!session.queryOptions) {
2941
+ // Only reachable for hand-built sessions (tests): createSession always
2942
+ // records the options. Routed through the failure path below so the
2943
+ // old query stays usable.
2944
+ throw new Error("session has no recorded query options to rebuild from");
2945
+ }
2946
+ // ONE thinking code path for creation and recreation: the session's
2947
+ // tri-state intent beats the env var once set ("off" wins even with
2948
+ // MAX_THINKING_TOKENS present, R1.5); untouched sessions keep the
2949
+ // env-driven behavior (R1.6).
2950
+ const thinking = effectiveThinkingConfig(session.thinkingEnabled, process.env.MAX_THINKING_TOKENS, this.logger);
2951
+ // Snapshot of the live-tracked model/mode the replacement is built
2952
+ // with. A setter that was ALREADY awaiting its control request on the
2953
+ // old query when this recreate started (i.e. past its
2954
+ // `queryRecreateInFlight` join) can land its state update after this
2955
+ // snapshot; the post-init delta re-apply below reconciles the NEW
2956
+ // query, and the synchronous pre-swap check abandons the recreate if
2957
+ // the state moves yet again.
2958
+ let appliedModelId = session.models.currentModelId;
2959
+ let appliedModeId = session.modes.currentModeId;
2960
+ const options = {
2961
+ ...session.queryOptions,
2962
+ // Live-tracked state that may have drifted from creation time via
2963
+ // setSessionMode/setModel. `availableModes` ids are the SDK's own
2964
+ // permission modes (see `applySessionMode`'s validation), so the cast
2965
+ // is sound.
2966
+ permissionMode: appliedModeId,
2967
+ model: appliedModelId,
2968
+ // Resume THIS conversation in the replacement subprocess.
2969
+ resume: sessionId,
2970
+ // Same controller: a client-supplied abort must keep governing the
2971
+ // session across the swap.
2972
+ abortController: session.abortController,
2973
+ };
2974
+ // `sessionId` is mutually exclusive with `resume` (and this is never a
2975
+ // fork); stale creation-time values would make the SDK reject or fork.
2976
+ delete options.sessionId;
2977
+ delete options.forkSession;
2978
+ // Route the fresh resolution in (a stale creation-time `thinking` must
2979
+ // not leak through when the fresh resolution is `undefined`).
2980
+ delete options.thinking;
2981
+ if (thinking !== undefined) {
2982
+ options.thinking = thinking;
2983
+ }
2984
+ if (session.currentAgent === DEFAULT_AGENT_ID) {
2985
+ delete options.agent;
2986
+ }
2987
+ else {
2988
+ options.agent = session.currentAgent;
2989
+ }
2990
+ newQuery = query({ prompt: newInput, options });
2991
+ const replacement = newQuery;
2992
+ // Everything the replacement needs before it may serve a turn, grouped
2993
+ // into one phase so it can be time-bounded below.
2994
+ const initPhase = (async () => {
2995
+ // Fail fast while the OLD query is still intact: a spawn/resume
2996
+ // problem surfaces here, not after the cutover.
2997
+ await replacement.initializationResult();
2998
+ // Flag-layer settings don't survive into a new subprocess; re-apply
2999
+ // the session's current effort and Fast mode before any turn runs on
3000
+ // it (mirrors createSession's initial-effort application). Both are
3001
+ // read post-init, so a setter update that landed during the init
3002
+ // await is already included.
3003
+ const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
3004
+ const effortLevel = typeof effortOpt?.currentValue === "string"
3005
+ ? toSdkEffortLevel(effortOpt.currentValue)
3006
+ : null;
3007
+ if (effortLevel !== null) {
3008
+ await replacement.applyFlagSettings({ effortLevel });
3009
+ }
3010
+ const supportsFastMode = session.modelInfos.find((m) => m.value === session.models.currentModelId)
3011
+ ?.supportsFastMode ?? false;
3012
+ if (session.fastModeEnabled && supportsFastMode) {
3013
+ await replacement.applyFlagSettings({ fastMode: true });
3014
+ }
3015
+ // Delta re-apply: a model/mode setter that was already in flight on
3016
+ // the OLD query when this recreate snapshotted the session state may
3017
+ // have landed its update during the awaits above — the client was
3018
+ // acked for a value the replacement wasn't built with. Reconcile via
3019
+ // the new query's live controls (the same calls the setters use), and
3020
+ // keep `options` truthful for the stored `queryOptions` snapshot. (A
3021
+ // setter that instead resolves AFTER the swap applies to the closed
3022
+ // old query and surfaces as a visible rejection to the client — not a
3023
+ // silent desync — which is acceptable.)
3024
+ if (session.models.currentModelId !== appliedModelId) {
3025
+ appliedModelId = session.models.currentModelId;
3026
+ options.model = appliedModelId;
3027
+ await replacement.setModel(appliedModelId);
3028
+ }
3029
+ if (session.modes.currentModeId !== appliedModeId) {
3030
+ appliedModeId = session.modes.currentModeId;
3031
+ options.permissionMode = appliedModeId;
3032
+ await replacement.setPermissionMode(appliedModeId);
3033
+ }
3034
+ })();
3035
+ // Bound the whole phase (a wedged replacement subprocess can hang
3036
+ // initializationResult() forever — the issue #680 wedge class). Every
3037
+ // join on `queryRecreateInFlight` — prompt/cancel/the setters, and
3038
+ // teardown/dispose via cancel() — would inherit such a hang, so expiry
3039
+ // rejects into the failure path below (replacement closed, old query
3040
+ // untouched, pending flag retained). The timer is cleared on every
3041
+ // path; if the timeout wins, the losing initPhase's later settlement is
3042
+ // absorbed by Promise.race's own subscription, so no rejection can
3043
+ // surface as unhandled.
3044
+ let initTimer;
3045
+ try {
3046
+ await Promise.race([
3047
+ initPhase,
3048
+ new Promise((_, reject) => {
3049
+ initTimer = setTimeout(() => {
3050
+ reject(new Error(`recreate timed out: the replacement query did not finish initializing ` +
3051
+ `within ${QUERY_RECREATE_INIT_TIMEOUT_MS}ms`));
3052
+ }, QUERY_RECREATE_INIT_TIMEOUT_MS);
3053
+ }),
3054
+ ]);
3055
+ }
3056
+ finally {
3057
+ clearTimeout(initTimer);
3058
+ }
3059
+ // Liveness re-check: the awaits above are a long window in which the
3060
+ // OLD query's stream can die on its own — its (still-bound, not yet
3061
+ // superseded) consumer then runs closeQueryStream (queryClosed = true,
3062
+ // settings disposed) and, for a dead process, evicts the session.
3063
+ // Installing the new query anyway would brick the session (queryClosed
3064
+ // blocks prompt/cancel/setSessionMode/setSessionConfigOption) or, in
3065
+ // the eviction case, leak the fresh subprocess where no teardown could
3066
+ // ever reach it. Treat it as a failed recreate: release the new
3067
+ // resources and keep the pending flag. Deliberately NO client note
3068
+ // here — "retried on the next prompt" would be false for a dead
3069
+ // session (and meaningless for an evicted one); the authoritative
3070
+ // signal stays the existing SESSION_ENDED flow.
3071
+ if (session.queryClosed || this.sessions[sessionId] !== session) {
3072
+ const reason = this.sessions[sessionId] !== session
3073
+ ? "the session was evicted while the replacement query initialized"
3074
+ : "the session's query stream ended while the replacement query initialized";
3075
+ this.logger.error(`Session ${sessionId}: abandoning Thinking query recreate: ${reason}.`);
3076
+ newInput.end();
3077
+ try {
3078
+ newQuery.close();
3079
+ }
3080
+ catch (closeError) {
3081
+ this.logger.error(`Session ${sessionId}: failed to close abandoned replacement query:`, closeError);
3082
+ }
3083
+ return;
3084
+ }
3085
+ // Reverse-interleaving re-check (synchronous — nothing may await
3086
+ // between here and the swap): if a straggler setter moved the model or
3087
+ // permission mode yet again after the delta re-apply in `initPhase`,
3088
+ // abandon the replacement rather than install it stale. The pending
3089
+ // flag is retained, so the next prompt rebuilds from fresh state.
3090
+ if (session.models.currentModelId !== appliedModelId ||
3091
+ session.modes.currentModeId !== appliedModeId) {
3092
+ this.logger.error(`Session ${sessionId}: abandoning Thinking query recreate: the session's model ` +
3093
+ `or permission mode changed while the replacement initialized; retrying on ` +
3094
+ `the next prompt.`);
3095
+ newInput.end();
3096
+ try {
3097
+ newQuery.close();
3098
+ }
3099
+ catch (closeError) {
3100
+ this.logger.error(`Session ${sessionId}: failed to close abandoned replacement query:`, closeError);
3101
+ }
3102
+ return;
3103
+ }
3104
+ // Cutover. Synchronous from here through the old-stream close: the
3105
+ // superseded consumer can only wake in a later microtask, so it never
3106
+ // observes an intermediate state.
3107
+ const oldQuery = session.query;
3108
+ const oldInput = session.input;
3109
+ session.query = newQuery;
3110
+ session.input = newInput;
3111
+ session.queryOptions = options;
3112
+ // The superseded consumer exits via its guards without touching the
3113
+ // session; drop its handle so the caller's ensureConsumer starts a
3114
+ // fresh consumer for the new query.
3115
+ session.consumer = undefined;
3116
+ session.pendingQueryRecreate = false;
3117
+ // End the old stream last; its consumer wakes on the final next() only
3118
+ // after the swap above is complete.
3119
+ oldInput.end();
3120
+ oldQuery.close();
3121
+ }
3122
+ catch (error) {
3123
+ // Keep the OLD query fully usable and the pending flag set (the next
3124
+ // prompt retries); the current turn proceeds with the previous thinking
3125
+ // config. Everything is contained here — never an unhandled rejection.
3126
+ this.logger.error(`Session ${sessionId}: failed to recreate query for Thinking change:`, error);
3127
+ newInput.end();
3128
+ try {
3129
+ newQuery?.close();
3130
+ }
3131
+ catch (closeError) {
3132
+ this.logger.error(`Session ${sessionId}: failed to close abandoned replacement query:`, closeError);
3133
+ }
3134
+ try {
3135
+ await this.client.sessionUpdate({
3136
+ sessionId,
3137
+ update: {
3138
+ sessionUpdate: "agent_message_chunk",
3139
+ content: {
3140
+ type: "text",
3141
+ text: "Note: the updated Thinking setting could not be applied to this turn " +
3142
+ "(recreating the session's query failed), so it continues with the previous " +
3143
+ "setting. The change will be retried on the next prompt.",
3144
+ },
3145
+ },
3146
+ });
3147
+ }
3148
+ catch (notifyError) {
3149
+ this.logger.error(`Session ${sessionId}: failed to notify the client about the Thinking recreate failure:`, notifyError);
3150
+ }
3151
+ }
3152
+ }
2650
3153
  /** Reconcile the session's Fast mode toggle with an SDK-reported
2651
3154
  * `fast_mode_state` (delivered on `system`/init and on user-turn `result`s).
2652
3155
  * The SDK can flip fast mode independently of the user — e.g. back to `on`
@@ -2810,8 +3313,11 @@ export class ClaudeAcpAgent {
2810
3313
  // Extract options from _meta if provided
2811
3314
  const sessionMeta = params._meta;
2812
3315
  const userProvidedOptions = sessionMeta?.claudeCode?.options;
2813
- // Configure thinking behavior from environment variable
2814
- const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
3316
+ // Configure thinking behavior through the same single code path query
3317
+ // recreation uses (`recreateSessionQuery`). A fresh session's Thinking
3318
+ // intent is untouched (`undefined`), so this resolves to exactly the
3319
+ // legacy env-driven MAX_THINKING_TOKENS behavior (R1.6).
3320
+ const thinking = effectiveThinkingConfig(undefined, process.env.MAX_THINKING_TOKENS, this.logger);
2815
3321
  // Parse model configuration from environment (e.g. Bedrock model overrides)
2816
3322
  const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
2817
3323
  // Elicitation modes the connected client advertised. We only forward
@@ -2844,6 +3350,10 @@ export class ClaudeAcpAgent {
2844
3350
  systemPrompt,
2845
3351
  settingSources: ["user", "project", "local"],
2846
3352
  ...(thinking !== undefined && { thinking }),
3353
+ // File checkpointing on by default so `/rewind N` can restore files
3354
+ // (story 006, R3.3). Placed before the user spread so an explicit user
3355
+ // `enableFileCheckpointing` still wins.
3356
+ enableFileCheckpointing: true,
2847
3357
  ...userProvidedOptions,
2848
3358
  // CLAUDE_MODEL_CONFIG env var is a fallback for model
2849
3359
  // configuration (e.g. Bedrock model ID overrides). When the caller
@@ -3007,13 +3517,68 @@ export class ClaudeAcpAgent {
3007
3517
  // consistent with what the user configured.
3008
3518
  const settingsAvailableModels = settingsManager.getSettings().availableModels;
3009
3519
  const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
3010
- const allowedModels = Array.isArray(settingsAvailableModels)
3011
- ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
3520
+ // The CATALOG: allowlist-applied (when configured) but deprecation-
3521
+ // UNfiltered. Preference resolution (`resolveModelPreference`) and
3522
+ // capability lookups read this list, so a persisted preference pointing at
3523
+ // a deprecated model keeps resolving with full capabilities (R4.3 — the
3524
+ // deprecation filter is visibility-only, never a forced migration).
3525
+ const catalogModels = Array.isArray(settingsAvailableModels)
3526
+ ? buildAllowlistedModels(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
3012
3527
  : initializationResult.models;
3013
- const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger);
3528
+ // The PICKER list: same pipeline plus the deprecation visibility filter
3529
+ // (R4.2). With an allowlist this goes through the exported boundary
3530
+ // `applyAvailableModelsAllowlist` (the filter lives inside it — pinned by
3531
+ // model-picker-filter.test.ts); the small allowlist recompute vs.
3532
+ // `catalogModels` is deliberate so the boundary stays the single place
3533
+ // filter and allowlist compose. Without an allowlist the raw SDK list is
3534
+ // the one list-building site that never crosses that boundary, so it
3535
+ // applies the SAME `hideDeprecatedModels` helper directly.
3536
+ const allowedModels = Array.isArray(settingsAvailableModels)
3537
+ ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, this.logger)
3538
+ : hideDeprecatedModels(initializationResult.models, this.logger);
3539
+ const models = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
3014
3540
  // Gate `auto` (and future model-specific modes) on the resolved model's
3015
3541
  // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
3016
- const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
3542
+ // Looked up in the UNfiltered catalog: a session honoring a persisted
3543
+ // deprecated preference must keep that model's real capabilities (R4.3).
3544
+ // A resumed session can also be running a model outside the
3545
+ // `availableModels` allowlist (currentModelId is then the verbatim live
3546
+ // id, see `matchResumedModel`); its capabilities are still known to the
3547
+ // SDK's unfiltered list, so fall back to that before treating the model
3548
+ // as unknown — otherwise auto mode would be spuriously clamped and the
3549
+ // Fast-mode/Effort options hidden for a model that supports them.
3550
+ const catalogModelInfo = catalogModels.find((m) => m.value === models.currentModelId);
3551
+ const fallbackModelInfo = catalogModelInfo
3552
+ ? undefined
3553
+ : (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined);
3554
+ const currentModelInfo = catalogModelInfo ?? fallbackModelInfo;
3555
+ // Register the fallback-resolved capabilities under the verbatim live id
3556
+ // so every modelInfos consumer (buildConfigOptions' effort lookup, later
3557
+ // rebuilds via session.modelInfos) agrees with the gating below. The
3558
+ // picker options themselves come from `models.availableModels`, so this
3559
+ // adds no selectable entry. The spread keeps every capability flag
3560
+ // (current and future); the identity fields are overridden because the
3561
+ // fuzzy-matched sibling's resolvedModel/displayName/description can
3562
+ // describe a different context lane and would poison later resolvedModel
3563
+ // matching (syncModelAfterRefusalFallback) and context-window inference
3564
+ // (applyConfigOptionValue) if they traveled under this id.
3565
+ // Built on the UNfiltered catalog, NOT the picker list: `modelInfos` is
3566
+ // never rendered (picker rows come from `models.availableModels`) — it
3567
+ // feeds capability lookups and `resolveModelPreference` (refusal
3568
+ // fallback), which must keep seeing deprecated rows (R4.3,
3569
+ // visibility-only filter).
3570
+ const modelInfos = fallbackModelInfo
3571
+ ? [
3572
+ ...catalogModels,
3573
+ {
3574
+ ...fallbackModelInfo,
3575
+ value: models.currentModelId,
3576
+ displayName: models.currentModelId,
3577
+ description: "",
3578
+ resolvedModel: undefined,
3579
+ },
3580
+ ]
3581
+ : catalogModels;
3017
3582
  const availableModes = buildAvailableModes(currentModelInfo);
3018
3583
  // Clamp `permissionMode` if the resolved session does not offer it. The
3019
3584
  // common case is `permissions.defaultMode: "auto"` resolving to a model
@@ -3065,9 +3630,21 @@ export class ClaudeAcpAgent {
3065
3630
  const fastMode = {
3066
3631
  supported: currentModelInfo?.supportsFastMode ?? false,
3067
3632
  enabled: fastModeEnabled,
3068
- useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
3069
3633
  };
3070
- const configOptions = buildConfigOptions(modes, models, allowedModels, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode);
3634
+ const configOptions = buildConfigOptions(modes, models,
3635
+ // Catalog-based (see `modelInfos` above), matching the model-switch
3636
+ // rebuild (which passes `session.modelInfos`): `buildConfigOptions`
3637
+ // reads this argument only for the current model's effort capabilities
3638
+ // — picker rows come from `models.availableModels` — so a deprecated
3639
+ // current model keeps its effort option without leaking hidden rows
3640
+ // (R4.3).
3641
+ modelInfos, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode,
3642
+ // A fresh session's Thinking intent is untouched (undefined), so the
3643
+ // display follows the env-driven state. `thinking` already holds
3644
+ // effectiveThinkingConfig(undefined, MAX_THINKING_TOKENS) from above, so
3645
+ // reusing it avoids re-parsing (and re-logging an invalid) env var —
3646
+ // exactly one error log per query creation.
3647
+ thinking !== undefined);
3071
3648
  // Apply the initial effort level to the SDK so it matches the UI default
3072
3649
  const initialEffort = configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
3073
3650
  if (initialEffort &&
@@ -3082,6 +3659,9 @@ export class ClaudeAcpAgent {
3082
3659
  input: input,
3083
3660
  cancelled: false,
3084
3661
  cwd: params.cwd,
3662
+ // Recorded so `recreateSessionQuery` (lazy Thinking application, R1.3)
3663
+ // can rebuild an equivalent query without re-running this assembly.
3664
+ queryOptions: options,
3085
3665
  sessionFingerprint: computeSessionFingerprint(params),
3086
3666
  settingsManager,
3087
3667
  accumulatedUsage: {
@@ -3092,14 +3672,24 @@ export class ClaudeAcpAgent {
3092
3672
  },
3093
3673
  modes,
3094
3674
  models,
3095
- modelInfos: allowedModels,
3675
+ // Catalog-based, NOT the picker list: `modelInfos` is never rendered
3676
+ // (picker rows come from `models.availableModels`) — it feeds
3677
+ // capability lookups and `resolveModelPreference` (refusal fallback),
3678
+ // which must keep seeing deprecated rows (R4.3, visibility-only filter).
3679
+ modelInfos,
3096
3680
  configOptions,
3097
3681
  agents,
3098
3682
  currentAgent,
3099
3683
  fastModeEnabled,
3100
3684
  abortController,
3101
3685
  emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
3102
- contextWindowSize: inferContextWindowFromModel(models.currentModelId, currentModelInfo?.displayName, currentModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
3686
+ contextWindowSize:
3687
+ // Deliberately keyed to the catalog entry: a fallback-resolved
3688
+ // sibling's displayName/description can describe a different context
3689
+ // lane than the verbatim live id (e.g. an "opus[1m]" row matched for
3690
+ // a bare 200k id), so on the fallback path only the id itself is a
3691
+ // trustworthy window signal.
3692
+ inferContextWindowFromModel(models.currentModelId, catalogModelInfo?.displayName, catalogModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
3103
3693
  taskState,
3104
3694
  toolUseCache: {},
3105
3695
  emittedToolCalls: new Set(),
@@ -3255,6 +3845,7 @@ function toSdkEffortLevel(value) {
3255
3845
  export const BUILTIN_AGENT_NAMES = new Set([
3256
3846
  "claude",
3257
3847
  "general-purpose",
3848
+ "claude-code-guide",
3258
3849
  "Explore",
3259
3850
  "Plan",
3260
3851
  "statusline-setup",
@@ -3287,8 +3878,8 @@ export const MODEL_CONFIG_ID = "model";
3287
3878
  export const EFFORT_CONFIG_ID = "effort";
3288
3879
  export const AGENT_CONFIG_ID = "agent";
3289
3880
  export const FAST_MODE_CONFIG_ID = "fast";
3290
- /** Select-fallback values used when the client has not opted into boolean
3291
- * config options (see {@link createFastModeConfigOption}). */
3881
+ /** Select values for the Fast mode on/off option
3882
+ * (see {@link createFastModeConfigOption}). */
3292
3883
  export const FAST_MODE_ON = "on";
3293
3884
  export const FAST_MODE_OFF = "off";
3294
3885
  const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
@@ -3299,29 +3890,18 @@ const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
3299
3890
  export function fastModeStateEnabled(state) {
3300
3891
  return state !== "off";
3301
3892
  }
3302
- /** Whether the Client advertised support for boolean session config options
3303
- * (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
3304
- * config options to Clients that opt in; otherwise we fall back to a `select`.
3305
- * See https://agentclientprotocol.com/rfds/boolean-config-option. */
3306
- export function clientSupportsBooleanConfigOptions(clientCapabilities) {
3307
- return clientCapabilities?.session?.configOptions?.boolean != null;
3308
- }
3309
- /** Build the Fast mode config option. When the Client supports boolean config
3310
- * options we expose a native `type: "boolean"` toggle; otherwise we degrade to
3311
- * a two-value `select` ("on"/"off") so older Clients still get a usable
3312
- * control. */
3313
- export function createFastModeConfigOption(enabled, useBooleanOption) {
3314
- const base = {
3893
+ /** Build the Fast mode config option as a two-value on/off `select`. Emitted
3894
+ * for EVERY Client — the boolean option shape is gone (story 006, R2.1). Only
3895
+ * the emitted SHAPE is fixed to a select; boolean VALUES are still honored on
3896
+ * set (see {@link resolveFastModeEnabled}). This factory is the single source
3897
+ * of the option's shape, re-rendered by `refreshFastModeOption` /
3898
+ * `syncFastModeState` so the shape can never desync. */
3899
+ export function createFastModeConfigOption(enabled) {
3900
+ return {
3315
3901
  id: FAST_MODE_CONFIG_ID,
3316
3902
  name: "Fast mode",
3317
3903
  description: FAST_MODE_DESCRIPTION,
3318
3904
  category: "model_config",
3319
- };
3320
- if (useBooleanOption) {
3321
- return { ...base, type: "boolean", currentValue: enabled };
3322
- }
3323
- return {
3324
- ...base,
3325
3905
  type: "select",
3326
3906
  currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
3327
3907
  options: [
@@ -3331,8 +3911,8 @@ export function createFastModeConfigOption(enabled, useBooleanOption) {
3331
3911
  };
3332
3912
  }
3333
3913
  /** Resolve the requested Fast mode value from a `session/set_config_option`
3334
- * request. Accepts a native boolean (boolean-capable Clients) or the
3335
- * "on"/"off" select-fallback strings. */
3914
+ * request. Accepts the select's "on"/"off" strings or a native boolean,
3915
+ * kept for backward compatibility (R2.3). */
3336
3916
  export function resolveFastModeEnabled(params) {
3337
3917
  const value = params.value;
3338
3918
  if (typeof value === "boolean") {
@@ -3346,7 +3926,13 @@ export function resolveFastModeEnabled(params) {
3346
3926
  }
3347
3927
  throw new Error(`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`);
3348
3928
  }
3349
- export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode) {
3929
+ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode,
3930
+ /** Display state for the Thinking toggle: the session's tri-state intent
3931
+ * already collapsed to a boolean by the caller
3932
+ * (`intent ?? (effectiveThinkingConfig(undefined, env, logger) !== undefined)`).
3933
+ * Both session call sites always supply it (R1.1); `undefined` (direct
3934
+ * callers/tests) omits the row. */
3935
+ thinkingEnabled) {
3350
3936
  const options = [
3351
3937
  {
3352
3938
  id: MODE_CONFIG_ID,
@@ -3404,10 +3990,16 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
3404
3990
  });
3405
3991
  }
3406
3992
  // Surface the Fast mode toggle only when the current model supports it. The
3407
- // option renders as a native boolean toggle for Clients that opted in, and a
3408
- // two-value select otherwise.
3993
+ // option is always emitted as a two-value on/off select for every Client
3994
+ // (R2.1); boolean values remain accepted on set for boolean-era clients.
3409
3995
  if (fastMode?.supported) {
3410
- options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption));
3996
+ options.push(createFastModeConfigOption(fastMode.enabled));
3997
+ }
3998
+ // Surface the Thinking toggle whenever the caller supplies its display
3999
+ // state. Unlike Fast mode it is model-independent — no `supported` gate —
4000
+ // and select-only ("on"/"off"), so every session shows it (R1.1).
4001
+ if (thinkingEnabled !== undefined) {
4002
+ options.push(createThinkingConfigOption(thinkingEnabled));
3411
4003
  }
3412
4004
  // Only surface the Agent picker when there's a real choice — i.e. the user
3413
4005
  // has configured at least one custom agent (built-ins are filtered out in
@@ -3435,14 +4027,32 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
3435
4027
  // Claude Code CLI persists display strings like "opus[1m]" in settings,
3436
4028
  // but the SDK model list uses IDs like "claude-opus-4-6-1m".
3437
4029
  const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
4030
+ // The id-suffix spelling of a context hint ("-1m" in "claude-opus-4-6-1m");
4031
+ // shared by the strip and canonicalize helpers below so the two can't drift.
4032
+ const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
4033
+ /** Remove context-window hints — the display form "[1m]" and the SDK id
4034
+ * suffix form "-1m" — from a model string. Those digits describe context
4035
+ * size, not model identity or generation version. */
4036
+ function stripContextHints(s) {
4037
+ return s.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
4038
+ }
4039
+ /** Canonicalize a model id for exact comparison: trimmed, lowercased, with
4040
+ * the id-suffix hint spelling unified to the bracket form ("-1m" → "[1m]").
4041
+ * The hint itself is kept — bare and 1M ids must stay distinct. */
4042
+ function canonicalizeModelId(s) {
4043
+ return s.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
4044
+ }
4045
+ /** The context hint a model string carries ("1m" for either spelling), or
4046
+ * null for a bare id. */
4047
+ function contextHintOf(s) {
4048
+ return canonicalizeModelId(s).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
4049
+ }
3438
4050
  // Captures a model family version: `4-6`/`4.7` for dated generations, or a
3439
4051
  // bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned
3440
4052
  // `claude-opus-4-6` from matching the `opus` alias once it points at 4.7.
3441
4053
  const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
3442
4054
  function extractModelFamilyVersion(s) {
3443
- // Strip "[1m]"-style context hints first — that digit is context window
3444
- // size, not a model generation version.
3445
- const match = s.replace(/\[\d+m\]/gi, "").match(MODEL_FAMILY_VERSION_PATTERN);
4055
+ const match = stripContextHints(s).match(MODEL_FAMILY_VERSION_PATTERN);
3446
4056
  if (!match)
3447
4057
  return null;
3448
4058
  return match[2] ? `${match[1]}.${match[2]}` : match[1];
@@ -3495,25 +4105,38 @@ export function resolveModelPreference(models, preference) {
3495
4105
  if (!trimmed)
3496
4106
  return null;
3497
4107
  const lower = trimmed.toLowerCase();
3498
- // Exact match on value or display name
4108
+ // Exact match on value or display name. Values compare on the canonical
4109
+ // hint spelling so "opus-1m" hits an "opus[1m]" row (and vice versa).
4110
+ const canonicalPreference = canonicalizeModelId(trimmed);
3499
4111
  const directMatch = models.find((model) => model.value === trimmed ||
3500
- model.value.toLowerCase() === lower ||
4112
+ canonicalizeModelId(model.value) === canonicalPreference ||
3501
4113
  model.displayName.toLowerCase() === lower);
3502
4114
  if (directMatch)
3503
4115
  return directMatch;
3504
4116
  // Exact match on the alias's canonical resolved id (e.g. a pinned
3505
4117
  // "claude-sonnet-5" against the "sonnet" row's `resolvedModel`). SDK-
3506
4118
  // reported and unambiguous, so it's tried before the fuzzier tiers below.
3507
- // "default" is skipped first since it shares a resolvedModel with
3508
- // whichever alias the CLI currently recommends — a specific pin should
3509
- // land on that named alias, not "default".
3510
- const resolvedMatch = models.find((model) => model.value !== "default" && model.resolvedModel?.toLowerCase() === lower) ?? models.find((model) => model.resolvedModel?.toLowerCase() === lower);
4119
+ // Compared on the canonical hint spelling so a "-1m"-suffix pin matches a
4120
+ // "[1m]"-spelled resolvedModel instead of falling into the substring tier
4121
+ // (which would land on the bare 200k sibling). "default" is skipped first
4122
+ // since it shares a resolvedModel with whichever alias the CLI currently
4123
+ // recommends — a specific pin should land on that named alias, not
4124
+ // "default".
4125
+ const matchesResolved = (model) => model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
4126
+ const resolvedMatch = models.find((model) => model.value !== "default" && matchesResolved(model)) ??
4127
+ models.find(matchesResolved);
3511
4128
  if (resolvedMatch)
3512
4129
  return resolvedMatch;
3513
- // Substring match
4130
+ // Substring match. Skips candidates whose context hint disagrees with the
4131
+ // preference's — a bare row must not absorb a 1M-hinted preference (nor
4132
+ // vice versa); such pairs fall through to the tokenized tier, which
4133
+ // weighs hints in its scoring and still finds the best same-family row.
4134
+ const preferenceHint = contextHintOf(trimmed);
3514
4135
  const includesMatch = models.find((model) => {
3515
4136
  if (!modelVersionsCompatible(trimmed, model))
3516
4137
  return false;
4138
+ if (contextHintOf(model.value) !== preferenceHint)
4139
+ return false;
3517
4140
  const value = model.value.toLowerCase();
3518
4141
  const display = model.displayName.toLowerCase();
3519
4142
  return value.includes(lower) || display.includes(lower) || lower.includes(value);
@@ -3537,6 +4160,51 @@ export function resolveModelPreference(models, preference) {
3537
4160
  }
3538
4161
  return bestMatch;
3539
4162
  }
4163
+ /** Map the live model reported by a resumed session onto the picker's model
4164
+ * list. The CLI restores a resumed session's model from the transcript's
4165
+ * last assistant message, which records the concrete API id (e.g.
4166
+ * "claude-opus-4-6") with any "[1m]" context hint dropped. Tiers, in order:
4167
+ * 1. Exact match with the Default entry's resolution — when a named alias
4168
+ * shares Default's resolvedModel verbatim, the live id can't tell the
4169
+ * two apart, and a never-customized session should stay on Default.
4170
+ * 2. Exact resolvedModel match on a named row. Checked before the
4171
+ * hint-stripped Default comparison so a live "claude-sonnet-5[1m]" lands
4172
+ * on the "sonnet[1m]" row rather than a Default that resolves to the
4173
+ * bare "claude-sonnet-5" — the two rows differ in context window, which
4174
+ * drives `contextWindowSize` and capability gating downstream.
4175
+ * 3. Hint-stripped match with Default's resolution — a session that never
4176
+ * left the default resumes as the bare transcript id, and shouldn't show
4177
+ * a concrete picker entry.
4178
+ * 4. `resolveModelPreference` over the picker entries.
4179
+ * 5. A model with no picker counterpart (e.g. excluded by an
4180
+ * `availableModels` allowlist) is tracked verbatim, mirroring
4181
+ * `syncModelAfterRefusalFallback`: the picker shows no selection, but the
4182
+ * model-dependent bookkeeping stays truthful to what the SDK is running. */
4183
+ export function matchResumedModel(models, liveModel) {
4184
+ const live = canonicalizeModelId(liveModel);
4185
+ const defaultEntry = models.find((m) => m.value === "default");
4186
+ const defaultResolved = defaultEntry?.resolvedModel
4187
+ ? canonicalizeModelId(defaultEntry.resolvedModel)
4188
+ : undefined;
4189
+ if (defaultEntry && defaultResolved === live) {
4190
+ return defaultEntry;
4191
+ }
4192
+ // No default-row exclusion needed: a default row matching `live` exactly
4193
+ // already returned at the tier above.
4194
+ const exactMatch = models.find((m) => m.resolvedModel && canonicalizeModelId(m.resolvedModel) === live);
4195
+ if (exactMatch)
4196
+ return exactMatch;
4197
+ if (defaultEntry &&
4198
+ defaultResolved &&
4199
+ stripContextHints(defaultResolved) === stripContextHints(live)) {
4200
+ return defaultEntry;
4201
+ }
4202
+ return (resolveModelPreference(models, liveModel) ?? {
4203
+ value: liveModel,
4204
+ displayName: liveModel,
4205
+ description: "",
4206
+ });
4207
+ }
3540
4208
  function resolveSettingsModel(models, settingsModel, logger) {
3541
4209
  if (settingsModel === undefined) {
3542
4210
  return null;
@@ -3549,7 +4217,8 @@ function resolveSettingsModel(models, settingsModel, logger) {
3549
4217
  return resolveModelPreference(models, settingsModel);
3550
4218
  }
3551
4219
  /**
3552
- * Restrict the SDK's model list to the user's `availableModels` allowlist
4220
+ * Deprecation-UNfiltered core of {@link applyAvailableModelsAllowlist}:
4221
+ * restrict the SDK's model list to the user's `availableModels` allowlist
3553
4222
  * (already merged-and-deduped across settings sources by `SettingsManager`).
3554
4223
  * The user's exact entries become the model IDs surfaced via configOptions
3555
4224
  * and passed to `setModel`, which prevents Claude Code from silently
@@ -3559,12 +4228,16 @@ function resolveSettingsModel(models, settingsModel, logger) {
3559
4228
  * Display info and capability flags are copied from the closest SDK match so
3560
4229
  * the UI still renders sensible names and effort levels.
3561
4230
  *
4231
+ * Kept separate from the exported boundary so session creation can obtain the
4232
+ * allowlist-applied-but-unfiltered CATALOG that preference resolution and
4233
+ * capability lookups read (R4.3 — the deprecation filter is visibility-only).
4234
+ *
3562
4235
  * Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
3563
4236
  * - `undefined` is handled by the caller (no allowlist applied).
3564
4237
  * - The Default option is unaffected by `availableModels` — it always remains
3565
4238
  * available, even when the allowlist is `[]`.
3566
4239
  */
3567
- export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsModelOverrides) {
4240
+ function buildAllowlistedModels(sdkModels, allowlist, settingsModelOverrides) {
3568
4241
  // Default is always preserved per the docs. Synthesize one if the SDK
3569
4242
  // didn't surface it so downstream code (e.g. `getAvailableModels` picking
3570
4243
  // `models[0]` as a fallback) still has something to work with.
@@ -3616,14 +4289,82 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
3616
4289
  }
3617
4290
  return result;
3618
4291
  }
3619
- async function getAvailableModels(query, models, sdkModels, settingsManager, logger) {
4292
+ /**
4293
+ * Visibility filter for PICKER-bound model lists (R4.2): drop rows
4294
+ * {@link filterDeprecatedModels} flags as deprecated/legacy. Every
4295
+ * list-building site applies this same helper — via
4296
+ * {@link applyAvailableModelsAllowlist} when an allowlist is configured, or
4297
+ * directly on the raw SDK list otherwise — so the picker never renders a
4298
+ * deprecated row regardless of configuration.
4299
+ *
4300
+ * This is visibility-only: preference resolution and capability lookups keep
4301
+ * reading the UNfiltered catalog (R4.3), so a persisted preference pointing at
4302
+ * a deprecated model is still honored.
4303
+ *
4304
+ * Edge: if the filter would hide EVERY row, fall back to the unfiltered input
4305
+ * so config-option building never receives an empty model list, and log it.
4306
+ * The logger is optional because this is also reached from the free exported
4307
+ * boundary (tests, library callers) where no agent logger exists.
4308
+ */
4309
+ function hideDeprecatedModels(models, logger) {
4310
+ const visible = filterDeprecatedModels(models);
4311
+ if (visible.length === 0 && models.length > 0) {
4312
+ logger?.error("Deprecation filter would hide every available model; showing the unfiltered list instead.");
4313
+ return models;
4314
+ }
4315
+ return visible;
4316
+ }
4317
+ /**
4318
+ * The exported picker-list boundary: {@link buildAllowlistedModels} (the
4319
+ * user's `availableModels` allowlist semantics — see its doc) composed with
4320
+ * {@link hideDeprecatedModels} (the deprecation visibility filter, R4.2).
4321
+ *
4322
+ * The filter runs on the allowlist RESULT, not on `sdkModels`: allowlisted
4323
+ * entries copy `displayName`/`description` from their closest SDK match, so a
4324
+ * deprecated row is hidden even when the allowlist names it explicitly.
4325
+ * Entries with no SDK match copy the entry text itself into `displayName`
4326
+ * ({@link buildAllowlistedModels}), so the heuristic DOES read user-authored
4327
+ * text: a custom entry containing "legacy"/"deprecated" is hidden from the
4328
+ * picker (rare, accepted false positive — the model stays reachable via
4329
+ * `settings.model` / `ANTHROPIC_MODEL`, which resolve over the unfiltered
4330
+ * catalog).
4331
+ */
4332
+ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsModelOverrides, logger) {
4333
+ return hideDeprecatedModels(buildAllowlistedModels(sdkModels, allowlist, settingsModelOverrides), logger);
4334
+ }
4335
+ /** Read the model a resumed session is actually running (via the
4336
+ * `getContextUsage` control request — the same source `/context` prints) and
4337
+ * map it onto the picker. Best-effort: a control-request failure is logged
4338
+ * and returns null so callers keep their current choice; failing the whole
4339
+ * session/load over an unreadable report would be worse. */
4340
+ async function readResumedLiveModel(query, models, logger) {
4341
+ try {
4342
+ const liveModel = (await query.getContextUsage()).model;
4343
+ return liveModel ? matchResumedModel(models, liveModel) : null;
4344
+ }
4345
+ catch (error) {
4346
+ logger.error("Failed to read the resumed session's live model:", error);
4347
+ return null;
4348
+ }
4349
+ }
4350
+ async function getAvailableModels(query,
4351
+ /** Deprecation-UNfiltered catalog (allowlist-applied when configured):
4352
+ * preference resolution and the default pick read this list so a persisted
4353
+ * deprecated preference still resolves (R4.3). */
4354
+ models,
4355
+ /** Deprecation-filtered picker list: becomes the rendered `availableModels`
4356
+ * rows (R4.2). The resolved `currentModelId` may legitimately be absent
4357
+ * from these rows (deprecated persisted preference) — the picker then
4358
+ * shows no selection, mirroring the refusal-fallback bookkeeping. */
4359
+ pickerModels, sdkModels, settingsManager, logger, isResumedSession) {
3620
4360
  const settings = settingsManager.getSettings();
3621
4361
  let currentModel = models[0];
3622
4362
  let resolvedFromInput;
3623
4363
  // Model priority (highest to lowest):
3624
4364
  // 1. ANTHROPIC_MODEL environment variable
3625
4365
  // 2. settings.model (user configuration)
3626
- // 3. models[0] (default first model)
4366
+ // 3. the resumed session's live model (resumed sessions only)
4367
+ // 4. models[0] (default first model)
3627
4368
  if (process.env.ANTHROPIC_MODEL) {
3628
4369
  const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
3629
4370
  if (match) {
@@ -3638,24 +4379,52 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
3638
4379
  resolvedFromInput = settings.model;
3639
4380
  }
3640
4381
  }
4382
+ // A resumed session restores the model it was previously running (the CLI
4383
+ // re-reads it from the transcript), so without an env/settings override the
4384
+ // freshly-computed default above can disagree with what the session actually
4385
+ // runs — session/load then reports a model the session isn't using (issue
4386
+ // #845). Ask the CLI for the live model and reflect it. No `setModel` here:
4387
+ // the SDK is already running this model, and pushing a picker alias back
4388
+ // (e.g. "opus[1m]") could change the live model rather than describe it.
4389
+ if (resolvedFromInput === undefined && isResumedSession) {
4390
+ currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
4391
+ }
3641
4392
  // Skip the setModel round-trip when we can prove the SDK has already landed
3642
4393
  // on the same model. Two cases qualify:
3643
- // (a) No override applied — currentModel stayed at models[0]; the SDK is on
3644
- // its own default and we have nothing to sync.
4394
+ // (a) No override applied — currentModel is the SDK's own default (or, on
4395
+ // resume, the live model read back from the SDK above); nothing to sync.
3645
4396
  // (b) The resolver returned the user's input verbatim AND that value exists
3646
4397
  // in the SDK's original model list — meaning no fuzzy match or
3647
4398
  // allowlist rewrite was involved, and the SDK (which reads the same
3648
4399
  // ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
4400
+ // This only holds for fresh sessions: a resumed session lands on the
4401
+ // transcript's model regardless of env/settings, so the override must
4402
+ // be re-asserted to keep the reported model truthful.
3649
4403
  // Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
3650
4404
  // setModel call so we don't drift from the user's intended pin.
3651
4405
  const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
3652
4406
  const skipSetModel = resolvedFromInput === undefined ||
3653
- (currentModel.value === resolvedFromInput && sdkSawSameValue);
4407
+ (!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
3654
4408
  if (!skipSetModel) {
3655
- await query.setModel(currentModel.value);
4409
+ try {
4410
+ await query.setModel(currentModel.value);
4411
+ }
4412
+ catch (error) {
4413
+ // On a fresh session the pin is a defining option — fail loudly. A
4414
+ // resumed session already runs fine on the transcript's model, so
4415
+ // failing the whole session/load over the re-assert would be worse
4416
+ // than loading with the pin unapplied (mirrors the setPermissionMode
4417
+ // containment in createSession). The SDK then stayed on the
4418
+ // transcript's model, so read that back rather than reporting the
4419
+ // pin the session isn't running.
4420
+ if (!isResumedSession)
4421
+ throw error;
4422
+ logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
4423
+ currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
4424
+ }
3656
4425
  }
3657
4426
  return {
3658
- availableModels: models.map((model) => ({
4427
+ availableModels: pickerModels.map((model) => ({
3659
4428
  modelId: model.value,
3660
4429
  name: model.displayName,
3661
4430
  description: model.description,
@@ -3674,7 +4443,7 @@ function getAvailableSlashCommands(commands) {
3674
4443
  "release-notes",
3675
4444
  "todos",
3676
4445
  ];
3677
- return commands
4446
+ const advertised = commands
3678
4447
  .map((command) => {
3679
4448
  const input = command.argumentHint
3680
4449
  ? {
@@ -3694,6 +4463,17 @@ function getAvailableSlashCommands(commands) {
3694
4463
  };
3695
4464
  })
3696
4465
  .filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
4466
+ // `/rewind` is handled fully locally by the adapter (story 006, R3.1), so it
4467
+ // is absent from the SDK command list; advertise it here with an input hint.
4468
+ // Deduped by name so a same-named SDK command (should one ever appear) wins.
4469
+ if (!advertised.some((command) => command.name === "rewind")) {
4470
+ advertised.push({
4471
+ name: "rewind",
4472
+ description: "List file checkpoints, or restore files to one with `/rewind <n>`.",
4473
+ input: { hint: "checkpoint number (omit to list)" },
4474
+ });
4475
+ }
4476
+ return advertised;
3697
4477
  }
3698
4478
  function formatUriAsLink(uri) {
3699
4479
  try {
@@ -3909,8 +4689,8 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3909
4689
  let update = null;
3910
4690
  switch (chunk.type) {
3911
4691
  case "text":
3912
- case "text_delta":
3913
- if (chunk.text.length > 0) {
4692
+ case "text_delta": {
4693
+ if (chunk.text) {
3914
4694
  update = {
3915
4695
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
3916
4696
  content: {
@@ -3920,6 +4700,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3920
4700
  };
3921
4701
  }
3922
4702
  break;
4703
+ }
3923
4704
  case "image":
3924
4705
  update = {
3925
4706
  sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
@@ -3932,10 +4713,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3932
4713
  };
3933
4714
  break;
3934
4715
  case "thinking":
3935
- case "thinking_delta":
4716
+ case "thinking_delta": {
3936
4717
  // Recent models default `thinking.display` to "omitted", which streams
3937
4718
  // signature-only thinking blocks whose text is empty.
3938
- if (chunk.thinking.length > 0) {
4719
+ if (chunk.thinking) {
3939
4720
  update = {
3940
4721
  sessionUpdate: "agent_thought_chunk",
3941
4722
  content: {
@@ -3945,6 +4726,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
3945
4726
  };
3946
4727
  }
3947
4728
  break;
4729
+ }
3948
4730
  case "tool_use":
3949
4731
  case "server_tool_use":
3950
4732
  case "mcp_tool_use": {
@@ -4286,22 +5068,6 @@ async function fetchContextUsedTokens(query, logger) {
4286
5068
  return null;
4287
5069
  }
4288
5070
  }
4289
- /** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
4290
- * option. The `maxThinkingTokens` option it used to feed is deprecated and
4291
- * reduced to on/off on current models, so map the value to explicit thinking
4292
- * config instead: unset → `undefined` (SDK default, adaptive on models that
4293
- * support it); `0` → disabled; a positive integer → a fixed token budget.
4294
- * Anything else is ignored with a warning. */
4295
- function resolveThinkingConfig(raw, logger) {
4296
- if (raw === undefined)
4297
- return undefined;
4298
- const parsed = Number.parseInt(raw, 10);
4299
- if (Number.isNaN(parsed) || parsed < 0) {
4300
- logger.error(`Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${raw}'.`);
4301
- return undefined;
4302
- }
4303
- return parsed === 0 ? { type: "disabled" } : { type: "enabled", budgetTokens: parsed };
4304
- }
4305
5071
  function parseModelConfig(raw) {
4306
5072
  if (!raw)
4307
5073
  return undefined;