@lucascouts/claude-agent-acp-plus 0.1.0 → 0.2.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.
@@ -841,11 +964,15 @@ export class ClaudeAcpAgent {
841
964
  session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
842
965
  }
843
966
  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]) {
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
@@ -1804,6 +1939,13 @@ export class ClaudeAcpAgent {
1804
1939
  // below, so there is no normal fall-through here.
1805
1940
  }
1806
1941
  catch (error) {
1942
+ // A superseded consumer's stale next() may reject when the lazy query
1943
+ // recreate closes the old query; the session's live turns and resources
1944
+ // belong to the replacement consumer, so exit without failing turns or
1945
+ // releasing anything.
1946
+ if (session.query !== myQuery) {
1947
+ return;
1948
+ }
1807
1949
  // The query stream itself died (a transport/process error surfaced from
1808
1950
  // query.next()). Turn-level failures (auth, error results) are handled
1809
1951
  // inline via failActive and never reach here. Reject every in-flight turn;
@@ -1838,6 +1980,15 @@ export class ClaudeAcpAgent {
1838
1980
  if (!session) {
1839
1981
  return;
1840
1982
  }
1983
+ // A lazy Thinking recreate may be swapping the session's query right now;
1984
+ // join it (mirroring prompt()) so `interrupt()` below targets the live
1985
+ // replacement query instead of racing the old one's close — an interrupt
1986
+ // control request killed by that close would reject out of this
1987
+ // fire-and-forget notification. Safe: `recreateSessionQuery` never
1988
+ // rejects.
1989
+ if (session.queryRecreateInFlight) {
1990
+ await session.queryRecreateInFlight;
1991
+ }
1841
1992
  // The stream already ended (see closeQueryStream): every in-flight turn was
1842
1993
  // settled when it closed, and there is no live query to interrupt. Calling
1843
1994
  // query.interrupt() on a finished iterator could reject and surface from
@@ -1971,6 +2122,14 @@ export class ClaudeAcpAgent {
1971
2122
  if (!session) {
1972
2123
  throw new Error("Session not found");
1973
2124
  }
2125
+ // A lazy Thinking recreate may be swapping the session's query right now;
2126
+ // join it (mirroring prompt()) so `setPermissionMode` below lands on the
2127
+ // replacement query after cutover instead of an about-to-close one — and
2128
+ // so the mode isn't silently dropped from a query built from the
2129
+ // pre-await state snapshot. Safe: `recreateSessionQuery` never rejects.
2130
+ if (session.queryRecreateInFlight) {
2131
+ await session.queryRecreateInFlight;
2132
+ }
1974
2133
  // The SDK query stream already ended (see closeQueryStream); the session is
1975
2134
  // a husk and `query.setPermissionMode` below would act on a closed query.
1976
2135
  // Fail with the same clear message prompt()/cancel() give for a dead stream.
@@ -1986,6 +2145,16 @@ export class ClaudeAcpAgent {
1986
2145
  if (!session) {
1987
2146
  throw new Error("Session not found");
1988
2147
  }
2148
+ // A lazy Thinking recreate may be swapping the session's query right now;
2149
+ // join it (mirroring prompt()) so the model/mode/effort/fast SDK calls
2150
+ // below land on the replacement query after cutover instead of an
2151
+ // about-to-close one — and so their state updates aren't silently
2152
+ // dropped from a query built from the pre-await state snapshot. The
2153
+ // THINKING branch only arms the pending flag, so joining first is
2154
+ // harmless for it. Safe: `recreateSessionQuery` never rejects.
2155
+ if (session.queryRecreateInFlight) {
2156
+ await session.queryRecreateInFlight;
2157
+ }
1989
2158
  // The SDK query stream already ended (see closeQueryStream); the session is
1990
2159
  // a husk and the `query.setModel`/`setPermissionMode`/`applyFlagSettings`
1991
2160
  // calls this triggers would act on a closed query. Fail with the same clear
@@ -1997,13 +2166,29 @@ export class ClaudeAcpAgent {
1997
2166
  if (!option) {
1998
2167
  throw new Error(`Unknown config option: ${params.configId}`);
1999
2168
  }
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.
2169
+ // Fast mode is always emitted as an "on"/"off" select, but a native
2170
+ // boolean set value is still accepted for compatibility (R2.3), so it
2171
+ // bypasses the string-only validation the select-style options below
2172
+ // rely on.
2003
2173
  if (params.configId === FAST_MODE_CONFIG_ID) {
2004
2174
  await this.applyFastMode(session, resolveFastModeEnabled(params));
2005
2175
  return { configOptions: session.configOptions };
2006
2176
  }
2177
+ // The Thinking toggle only records the session's intent — the SDK query
2178
+ // swap is deferred to the next prompt() start (lazy recreate, sub-task
2179
+ // 1.3), so there is no SDK call to fail here and the config option is
2180
+ // refreshed and acked immediately, mirroring Fast mode. An unrecognized
2181
+ // value deliberately falls through to the shared invalid-option-value
2182
+ // validation below rather than growing a bespoke error shape.
2183
+ if (params.configId === THINKING_CONFIG_ID) {
2184
+ const enabled = resolveThinkingSelection(params.value);
2185
+ if (enabled !== null) {
2186
+ session.thinkingEnabled = enabled;
2187
+ session.pendingQueryRecreate = true;
2188
+ session.configOptions = session.configOptions.map((o) => o.id === THINKING_CONFIG_ID ? createThinkingConfigOption(enabled) : o);
2189
+ return { configOptions: session.configOptions };
2190
+ }
2191
+ }
2007
2192
  if (typeof params.value !== "string") {
2008
2193
  throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
2009
2194
  }
@@ -2014,6 +2199,10 @@ export class ClaudeAcpAgent {
2014
2199
  // For model options, fall back to resolveModelPreference when the exact
2015
2200
  // value doesn't match. This lets callers use human-friendly aliases like
2016
2201
  // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
2202
+ // No deprecation filter here (R4.2, no double work): this list is rebuilt
2203
+ // from the RENDERED option rows, which flow from the already-filtered
2204
+ // picker list (`hideDeprecatedModels` at every session-creation branch) —
2205
+ // so hidden rows are not selectable via aliases either.
2017
2206
  if (!validValue && params.configId === MODEL_CONFIG_ID) {
2018
2207
  const modelInfos = allValues.map((o) => ({
2019
2208
  value: o.value,
@@ -2551,8 +2740,15 @@ export class ClaudeAcpAgent {
2551
2740
  // intent) when a supporting model is selected again.
2552
2741
  supported: newModelInfo?.supportsFastMode ?? false,
2553
2742
  enabled: session.fastModeEnabled,
2554
- useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
2555
- });
2743
+ },
2744
+ // Thinking is model-independent: re-render the retained tri-state
2745
+ // intent so the row survives this rebuild (an option not threaded
2746
+ // through here silently drops from the picker on every model switch,
2747
+ // R1.7). Untouched sessions keep displaying the env-driven state
2748
+ // (R1.6).
2749
+ session.thinkingEnabled ??
2750
+ effectiveThinkingConfig(undefined, process.env.MAX_THINKING_TOKENS, this.logger) !==
2751
+ undefined);
2556
2752
  // Sync effort with the SDK if it changed after the model switch
2557
2753
  const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
2558
2754
  const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
@@ -2629,11 +2825,12 @@ export class ClaudeAcpAgent {
2629
2825
  }
2630
2826
  }
2631
2827
  /** 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. */
2828
+ * `enabled`. A no-op when the option isn't present, so callers must confirm
2829
+ * the current model surfaces it first. Rebuilds through the single-parameter
2830
+ * {@link createFastModeConfigOption} — the one source of the option's shape —
2831
+ * so the shape can't drift from what `buildConfigOptions` first emitted. */
2635
2832
  refreshFastModeOption(session, enabled) {
2636
- const refreshed = createFastModeConfigOption(enabled, clientSupportsBooleanConfigOptions(this.clientCapabilities));
2833
+ const refreshed = createFastModeConfigOption(enabled);
2637
2834
  session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
2638
2835
  }
2639
2836
  /** Toggle Fast mode for a session: push the SDK flag, record the user's
@@ -2647,6 +2844,261 @@ export class ClaudeAcpAgent {
2647
2844
  session.fastModeEnabled = enabled;
2648
2845
  this.refreshFastModeOption(session, enabled);
2649
2846
  }
2847
+ /** Lazily swap a session's SDK query for a fresh one so a Thinking change
2848
+ * takes effect on the next turn (R1.3): thinking has no live flag-settings
2849
+ * path in the SDK, so mid-session application goes through query recreation
2850
+ * with session resume. The replacement resumes the SAME conversation
2851
+ * (`{ resume: sessionId }` — the adapter's session ids ARE the SDK's, see
2852
+ * `getOrCreateSession`) and is assembled from the creation-time
2853
+ * `queryOptions` plus the live-tracked session state (model, permission
2854
+ * mode, agent, effort, Fast mode), with thinking routed through
2855
+ * `effectiveThinkingConfig` — the same single code path query creation
2856
+ * uses (R1.5/R1.6).
2857
+ *
2858
+ * Deliberately NOT `teardownSession`: that aborts `session.abortController`
2859
+ * (possibly client-supplied and reused) and evicts the session from the
2860
+ * map. This path keeps the Session object registered and every controller
2861
+ * alive; only the query subprocess is replaced.
2862
+ *
2863
+ * Ordering is what makes the swap safe:
2864
+ * 1. Build + initialize the NEW query first, time-bounded
2865
+ * (QUERY_RECREATE_INIT_TIMEOUT_MS) so a wedged replacement cannot hang
2866
+ * the joins on `queryRecreateInFlight`. Any failure leaves the old
2867
+ * query untouched and usable: the user is told via agent_message_chunk,
2868
+ * `pendingQueryRecreate` stays set (retried on the next prompt), and the
2869
+ * caller's turn proceeds on the OLD query. Never rejects.
2870
+ * 2. Re-check, synchronously, session liveness and model/mode drift: if
2871
+ * the OLD stream died (or the session was evicted) while step 1
2872
+ * awaited — or a straggler setter moved the model/mode again after the
2873
+ * delta re-apply — abandon the replacement instead of installing it.
2874
+ * 3. Cut over synchronously — swap `session.query`/`input`, drop the
2875
+ * consumer handle (so `ensureConsumer` starts a fresh consumer for the
2876
+ * new query), clear the pending flag — THEN end the old stream. Swapping
2877
+ * before closing is load-bearing: the superseded consumer only wakes
2878
+ * after this microtask completes, sees `session.query !== myQuery`, and
2879
+ * exits via its supersession guards instead of running end-of-stream
2880
+ * cleanup (which would close the NEW query and dispose live resources).
2881
+ *
2882
+ * Only called from `prompt()` while the turn queue is idle (concurrent
2883
+ * RPC entry points join `queryRecreateInFlight`), so there are no in-flight
2884
+ * turns to migrate and the old query has nothing left to say. */
2885
+ async recreateSessionQuery(sessionId, session) {
2886
+ const newInput = new Pushable();
2887
+ let newQuery;
2888
+ try {
2889
+ if (!session.queryOptions) {
2890
+ // Only reachable for hand-built sessions (tests): createSession always
2891
+ // records the options. Routed through the failure path below so the
2892
+ // old query stays usable.
2893
+ throw new Error("session has no recorded query options to rebuild from");
2894
+ }
2895
+ // ONE thinking code path for creation and recreation: the session's
2896
+ // tri-state intent beats the env var once set ("off" wins even with
2897
+ // MAX_THINKING_TOKENS present, R1.5); untouched sessions keep the
2898
+ // env-driven behavior (R1.6).
2899
+ const thinking = effectiveThinkingConfig(session.thinkingEnabled, process.env.MAX_THINKING_TOKENS, this.logger);
2900
+ // Snapshot of the live-tracked model/mode the replacement is built
2901
+ // with. A setter that was ALREADY awaiting its control request on the
2902
+ // old query when this recreate started (i.e. past its
2903
+ // `queryRecreateInFlight` join) can land its state update after this
2904
+ // snapshot; the post-init delta re-apply below reconciles the NEW
2905
+ // query, and the synchronous pre-swap check abandons the recreate if
2906
+ // the state moves yet again.
2907
+ let appliedModelId = session.models.currentModelId;
2908
+ let appliedModeId = session.modes.currentModeId;
2909
+ const options = {
2910
+ ...session.queryOptions,
2911
+ // Live-tracked state that may have drifted from creation time via
2912
+ // setSessionMode/setModel. `availableModes` ids are the SDK's own
2913
+ // permission modes (see `applySessionMode`'s validation), so the cast
2914
+ // is sound.
2915
+ permissionMode: appliedModeId,
2916
+ model: appliedModelId,
2917
+ // Resume THIS conversation in the replacement subprocess.
2918
+ resume: sessionId,
2919
+ // Same controller: a client-supplied abort must keep governing the
2920
+ // session across the swap.
2921
+ abortController: session.abortController,
2922
+ };
2923
+ // `sessionId` is mutually exclusive with `resume` (and this is never a
2924
+ // fork); stale creation-time values would make the SDK reject or fork.
2925
+ delete options.sessionId;
2926
+ delete options.forkSession;
2927
+ // Route the fresh resolution in (a stale creation-time `thinking` must
2928
+ // not leak through when the fresh resolution is `undefined`).
2929
+ delete options.thinking;
2930
+ if (thinking !== undefined) {
2931
+ options.thinking = thinking;
2932
+ }
2933
+ if (session.currentAgent === DEFAULT_AGENT_ID) {
2934
+ delete options.agent;
2935
+ }
2936
+ else {
2937
+ options.agent = session.currentAgent;
2938
+ }
2939
+ newQuery = query({ prompt: newInput, options });
2940
+ const replacement = newQuery;
2941
+ // Everything the replacement needs before it may serve a turn, grouped
2942
+ // into one phase so it can be time-bounded below.
2943
+ const initPhase = (async () => {
2944
+ // Fail fast while the OLD query is still intact: a spawn/resume
2945
+ // problem surfaces here, not after the cutover.
2946
+ await replacement.initializationResult();
2947
+ // Flag-layer settings don't survive into a new subprocess; re-apply
2948
+ // the session's current effort and Fast mode before any turn runs on
2949
+ // it (mirrors createSession's initial-effort application). Both are
2950
+ // read post-init, so a setter update that landed during the init
2951
+ // await is already included.
2952
+ const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
2953
+ const effortLevel = typeof effortOpt?.currentValue === "string"
2954
+ ? toSdkEffortLevel(effortOpt.currentValue)
2955
+ : null;
2956
+ if (effortLevel !== null) {
2957
+ await replacement.applyFlagSettings({ effortLevel });
2958
+ }
2959
+ const supportsFastMode = session.modelInfos.find((m) => m.value === session.models.currentModelId)
2960
+ ?.supportsFastMode ?? false;
2961
+ if (session.fastModeEnabled && supportsFastMode) {
2962
+ await replacement.applyFlagSettings({ fastMode: true });
2963
+ }
2964
+ // Delta re-apply: a model/mode setter that was already in flight on
2965
+ // the OLD query when this recreate snapshotted the session state may
2966
+ // have landed its update during the awaits above — the client was
2967
+ // acked for a value the replacement wasn't built with. Reconcile via
2968
+ // the new query's live controls (the same calls the setters use), and
2969
+ // keep `options` truthful for the stored `queryOptions` snapshot. (A
2970
+ // setter that instead resolves AFTER the swap applies to the closed
2971
+ // old query and surfaces as a visible rejection to the client — not a
2972
+ // silent desync — which is acceptable.)
2973
+ if (session.models.currentModelId !== appliedModelId) {
2974
+ appliedModelId = session.models.currentModelId;
2975
+ options.model = appliedModelId;
2976
+ await replacement.setModel(appliedModelId);
2977
+ }
2978
+ if (session.modes.currentModeId !== appliedModeId) {
2979
+ appliedModeId = session.modes.currentModeId;
2980
+ options.permissionMode = appliedModeId;
2981
+ await replacement.setPermissionMode(appliedModeId);
2982
+ }
2983
+ })();
2984
+ // Bound the whole phase (a wedged replacement subprocess can hang
2985
+ // initializationResult() forever — the issue #680 wedge class). Every
2986
+ // join on `queryRecreateInFlight` — prompt/cancel/the setters, and
2987
+ // teardown/dispose via cancel() — would inherit such a hang, so expiry
2988
+ // rejects into the failure path below (replacement closed, old query
2989
+ // untouched, pending flag retained). The timer is cleared on every
2990
+ // path; if the timeout wins, the losing initPhase's later settlement is
2991
+ // absorbed by Promise.race's own subscription, so no rejection can
2992
+ // surface as unhandled.
2993
+ let initTimer;
2994
+ try {
2995
+ await Promise.race([
2996
+ initPhase,
2997
+ new Promise((_, reject) => {
2998
+ initTimer = setTimeout(() => {
2999
+ reject(new Error(`recreate timed out: the replacement query did not finish initializing ` +
3000
+ `within ${QUERY_RECREATE_INIT_TIMEOUT_MS}ms`));
3001
+ }, QUERY_RECREATE_INIT_TIMEOUT_MS);
3002
+ }),
3003
+ ]);
3004
+ }
3005
+ finally {
3006
+ clearTimeout(initTimer);
3007
+ }
3008
+ // Liveness re-check: the awaits above are a long window in which the
3009
+ // OLD query's stream can die on its own — its (still-bound, not yet
3010
+ // superseded) consumer then runs closeQueryStream (queryClosed = true,
3011
+ // settings disposed) and, for a dead process, evicts the session.
3012
+ // Installing the new query anyway would brick the session (queryClosed
3013
+ // blocks prompt/cancel/setSessionMode/setSessionConfigOption) or, in
3014
+ // the eviction case, leak the fresh subprocess where no teardown could
3015
+ // ever reach it. Treat it as a failed recreate: release the new
3016
+ // resources and keep the pending flag. Deliberately NO client note
3017
+ // here — "retried on the next prompt" would be false for a dead
3018
+ // session (and meaningless for an evicted one); the authoritative
3019
+ // signal stays the existing SESSION_ENDED flow.
3020
+ if (session.queryClosed || this.sessions[sessionId] !== session) {
3021
+ const reason = this.sessions[sessionId] !== session
3022
+ ? "the session was evicted while the replacement query initialized"
3023
+ : "the session's query stream ended while the replacement query initialized";
3024
+ this.logger.error(`Session ${sessionId}: abandoning Thinking query recreate: ${reason}.`);
3025
+ newInput.end();
3026
+ try {
3027
+ newQuery.close();
3028
+ }
3029
+ catch (closeError) {
3030
+ this.logger.error(`Session ${sessionId}: failed to close abandoned replacement query:`, closeError);
3031
+ }
3032
+ return;
3033
+ }
3034
+ // Reverse-interleaving re-check (synchronous — nothing may await
3035
+ // between here and the swap): if a straggler setter moved the model or
3036
+ // permission mode yet again after the delta re-apply in `initPhase`,
3037
+ // abandon the replacement rather than install it stale. The pending
3038
+ // flag is retained, so the next prompt rebuilds from fresh state.
3039
+ if (session.models.currentModelId !== appliedModelId ||
3040
+ session.modes.currentModeId !== appliedModeId) {
3041
+ this.logger.error(`Session ${sessionId}: abandoning Thinking query recreate: the session's model ` +
3042
+ `or permission mode changed while the replacement initialized; retrying on ` +
3043
+ `the next prompt.`);
3044
+ newInput.end();
3045
+ try {
3046
+ newQuery.close();
3047
+ }
3048
+ catch (closeError) {
3049
+ this.logger.error(`Session ${sessionId}: failed to close abandoned replacement query:`, closeError);
3050
+ }
3051
+ return;
3052
+ }
3053
+ // Cutover. Synchronous from here through the old-stream close: the
3054
+ // superseded consumer can only wake in a later microtask, so it never
3055
+ // observes an intermediate state.
3056
+ const oldQuery = session.query;
3057
+ const oldInput = session.input;
3058
+ session.query = newQuery;
3059
+ session.input = newInput;
3060
+ session.queryOptions = options;
3061
+ // The superseded consumer exits via its guards without touching the
3062
+ // session; drop its handle so the caller's ensureConsumer starts a
3063
+ // fresh consumer for the new query.
3064
+ session.consumer = undefined;
3065
+ session.pendingQueryRecreate = false;
3066
+ // End the old stream last; its consumer wakes on the final next() only
3067
+ // after the swap above is complete.
3068
+ oldInput.end();
3069
+ oldQuery.close();
3070
+ }
3071
+ catch (error) {
3072
+ // Keep the OLD query fully usable and the pending flag set (the next
3073
+ // prompt retries); the current turn proceeds with the previous thinking
3074
+ // config. Everything is contained here — never an unhandled rejection.
3075
+ this.logger.error(`Session ${sessionId}: failed to recreate query for Thinking change:`, error);
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
+ try {
3084
+ await this.client.sessionUpdate({
3085
+ sessionId,
3086
+ update: {
3087
+ sessionUpdate: "agent_message_chunk",
3088
+ content: {
3089
+ type: "text",
3090
+ text: "Note: the updated Thinking setting could not be applied to this turn " +
3091
+ "(recreating the session's query failed), so it continues with the previous " +
3092
+ "setting. The change will be retried on the next prompt.",
3093
+ },
3094
+ },
3095
+ });
3096
+ }
3097
+ catch (notifyError) {
3098
+ this.logger.error(`Session ${sessionId}: failed to notify the client about the Thinking recreate failure:`, notifyError);
3099
+ }
3100
+ }
3101
+ }
2650
3102
  /** Reconcile the session's Fast mode toggle with an SDK-reported
2651
3103
  * `fast_mode_state` (delivered on `system`/init and on user-turn `result`s).
2652
3104
  * The SDK can flip fast mode independently of the user — e.g. back to `on`
@@ -2810,8 +3262,11 @@ export class ClaudeAcpAgent {
2810
3262
  // Extract options from _meta if provided
2811
3263
  const sessionMeta = params._meta;
2812
3264
  const userProvidedOptions = sessionMeta?.claudeCode?.options;
2813
- // Configure thinking behavior from environment variable
2814
- const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
3265
+ // Configure thinking behavior through the same single code path query
3266
+ // recreation uses (`recreateSessionQuery`). A fresh session's Thinking
3267
+ // intent is untouched (`undefined`), so this resolves to exactly the
3268
+ // legacy env-driven MAX_THINKING_TOKENS behavior (R1.6).
3269
+ const thinking = effectiveThinkingConfig(undefined, process.env.MAX_THINKING_TOKENS, this.logger);
2815
3270
  // Parse model configuration from environment (e.g. Bedrock model overrides)
2816
3271
  const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
2817
3272
  // Elicitation modes the connected client advertised. We only forward
@@ -2844,6 +3299,10 @@ export class ClaudeAcpAgent {
2844
3299
  systemPrompt,
2845
3300
  settingSources: ["user", "project", "local"],
2846
3301
  ...(thinking !== undefined && { thinking }),
3302
+ // File checkpointing on by default so `/rewind N` can restore files
3303
+ // (story 006, R3.3). Placed before the user spread so an explicit user
3304
+ // `enableFileCheckpointing` still wins.
3305
+ enableFileCheckpointing: true,
2847
3306
  ...userProvidedOptions,
2848
3307
  // CLAUDE_MODEL_CONFIG env var is a fallback for model
2849
3308
  // configuration (e.g. Bedrock model ID overrides). When the caller
@@ -3007,13 +3466,31 @@ export class ClaudeAcpAgent {
3007
3466
  // consistent with what the user configured.
3008
3467
  const settingsAvailableModels = settingsManager.getSettings().availableModels;
3009
3468
  const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
3010
- const allowedModels = Array.isArray(settingsAvailableModels)
3011
- ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
3469
+ // The CATALOG: allowlist-applied (when configured) but deprecation-
3470
+ // UNfiltered. Preference resolution (`resolveModelPreference`) and
3471
+ // capability lookups read this list, so a persisted preference pointing at
3472
+ // a deprecated model keeps resolving with full capabilities (R4.3 — the
3473
+ // deprecation filter is visibility-only, never a forced migration).
3474
+ const catalogModels = Array.isArray(settingsAvailableModels)
3475
+ ? buildAllowlistedModels(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
3012
3476
  : initializationResult.models;
3013
- const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger);
3477
+ // The PICKER list: same pipeline plus the deprecation visibility filter
3478
+ // (R4.2). With an allowlist this goes through the exported boundary
3479
+ // `applyAvailableModelsAllowlist` (the filter lives inside it — pinned by
3480
+ // model-picker-filter.test.ts); the small allowlist recompute vs.
3481
+ // `catalogModels` is deliberate so the boundary stays the single place
3482
+ // filter and allowlist compose. Without an allowlist the raw SDK list is
3483
+ // the one list-building site that never crosses that boundary, so it
3484
+ // applies the SAME `hideDeprecatedModels` helper directly.
3485
+ const allowedModels = Array.isArray(settingsAvailableModels)
3486
+ ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides, this.logger)
3487
+ : hideDeprecatedModels(initializationResult.models, this.logger);
3488
+ const models = await getAvailableModels(q, catalogModels, allowedModels, initializationResult.models, settingsManager, this.logger);
3014
3489
  // Gate `auto` (and future model-specific modes) on the resolved model's
3015
3490
  // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
3016
- const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
3491
+ // Looked up in the UNfiltered catalog: a session honoring a persisted
3492
+ // deprecated preference must keep that model's real capabilities (R4.3).
3493
+ const currentModelInfo = catalogModels.find((m) => m.value === models.currentModelId);
3017
3494
  const availableModes = buildAvailableModes(currentModelInfo);
3018
3495
  // Clamp `permissionMode` if the resolved session does not offer it. The
3019
3496
  // common case is `permissions.defaultMode: "auto"` resolving to a model
@@ -3065,9 +3542,20 @@ export class ClaudeAcpAgent {
3065
3542
  const fastMode = {
3066
3543
  supported: currentModelInfo?.supportsFastMode ?? false,
3067
3544
  enabled: fastModeEnabled,
3068
- useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
3069
3545
  };
3070
- const configOptions = buildConfigOptions(modes, models, allowedModels, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode);
3546
+ const configOptions = buildConfigOptions(modes, models,
3547
+ // The UNfiltered catalog, matching the model-switch rebuild (which
3548
+ // passes `session.modelInfos`): `buildConfigOptions` reads this argument
3549
+ // only for the current model's effort capabilities — picker rows come
3550
+ // from `models.availableModels` — so a deprecated current model keeps
3551
+ // its effort option without leaking hidden rows (R4.3).
3552
+ catalogModels, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode,
3553
+ // A fresh session's Thinking intent is untouched (undefined), so the
3554
+ // display follows the env-driven state. `thinking` already holds
3555
+ // effectiveThinkingConfig(undefined, MAX_THINKING_TOKENS) from above, so
3556
+ // reusing it avoids re-parsing (and re-logging an invalid) env var —
3557
+ // exactly one error log per query creation.
3558
+ thinking !== undefined);
3071
3559
  // Apply the initial effort level to the SDK so it matches the UI default
3072
3560
  const initialEffort = configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
3073
3561
  if (initialEffort &&
@@ -3082,6 +3570,9 @@ export class ClaudeAcpAgent {
3082
3570
  input: input,
3083
3571
  cancelled: false,
3084
3572
  cwd: params.cwd,
3573
+ // Recorded so `recreateSessionQuery` (lazy Thinking application, R1.3)
3574
+ // can rebuild an equivalent query without re-running this assembly.
3575
+ queryOptions: options,
3085
3576
  sessionFingerprint: computeSessionFingerprint(params),
3086
3577
  settingsManager,
3087
3578
  accumulatedUsage: {
@@ -3092,7 +3583,11 @@ export class ClaudeAcpAgent {
3092
3583
  },
3093
3584
  modes,
3094
3585
  models,
3095
- modelInfos: allowedModels,
3586
+ // The UNfiltered catalog, NOT the picker list: `modelInfos` is never
3587
+ // rendered (picker rows come from `models.availableModels`) — it feeds
3588
+ // capability lookups and `resolveModelPreference` (refusal fallback),
3589
+ // which must keep seeing deprecated rows (R4.3, visibility-only filter).
3590
+ modelInfos: catalogModels,
3096
3591
  configOptions,
3097
3592
  agents,
3098
3593
  currentAgent,
@@ -3255,6 +3750,7 @@ function toSdkEffortLevel(value) {
3255
3750
  export const BUILTIN_AGENT_NAMES = new Set([
3256
3751
  "claude",
3257
3752
  "general-purpose",
3753
+ "claude-code-guide",
3258
3754
  "Explore",
3259
3755
  "Plan",
3260
3756
  "statusline-setup",
@@ -3287,8 +3783,8 @@ export const MODEL_CONFIG_ID = "model";
3287
3783
  export const EFFORT_CONFIG_ID = "effort";
3288
3784
  export const AGENT_CONFIG_ID = "agent";
3289
3785
  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}). */
3786
+ /** Select values for the Fast mode on/off option
3787
+ * (see {@link createFastModeConfigOption}). */
3292
3788
  export const FAST_MODE_ON = "on";
3293
3789
  export const FAST_MODE_OFF = "off";
3294
3790
  const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
@@ -3299,29 +3795,18 @@ const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
3299
3795
  export function fastModeStateEnabled(state) {
3300
3796
  return state !== "off";
3301
3797
  }
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 = {
3798
+ /** Build the Fast mode config option as a two-value on/off `select`. Emitted
3799
+ * for EVERY Client — the boolean option shape is gone (story 006, R2.1). Only
3800
+ * the emitted SHAPE is fixed to a select; boolean VALUES are still honored on
3801
+ * set (see {@link resolveFastModeEnabled}). This factory is the single source
3802
+ * of the option's shape, re-rendered by `refreshFastModeOption` /
3803
+ * `syncFastModeState` so the shape can never desync. */
3804
+ export function createFastModeConfigOption(enabled) {
3805
+ return {
3315
3806
  id: FAST_MODE_CONFIG_ID,
3316
3807
  name: "Fast mode",
3317
3808
  description: FAST_MODE_DESCRIPTION,
3318
3809
  category: "model_config",
3319
- };
3320
- if (useBooleanOption) {
3321
- return { ...base, type: "boolean", currentValue: enabled };
3322
- }
3323
- return {
3324
- ...base,
3325
3810
  type: "select",
3326
3811
  currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
3327
3812
  options: [
@@ -3331,8 +3816,8 @@ export function createFastModeConfigOption(enabled, useBooleanOption) {
3331
3816
  };
3332
3817
  }
3333
3818
  /** 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. */
3819
+ * request. Accepts the select's "on"/"off" strings or a native boolean,
3820
+ * kept for backward compatibility (R2.3). */
3336
3821
  export function resolveFastModeEnabled(params) {
3337
3822
  const value = params.value;
3338
3823
  if (typeof value === "boolean") {
@@ -3346,7 +3831,13 @@ export function resolveFastModeEnabled(params) {
3346
3831
  }
3347
3832
  throw new Error(`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`);
3348
3833
  }
3349
- export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode) {
3834
+ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode,
3835
+ /** Display state for the Thinking toggle: the session's tri-state intent
3836
+ * already collapsed to a boolean by the caller
3837
+ * (`intent ?? (effectiveThinkingConfig(undefined, env, logger) !== undefined)`).
3838
+ * Both session call sites always supply it (R1.1); `undefined` (direct
3839
+ * callers/tests) omits the row. */
3840
+ thinkingEnabled) {
3350
3841
  const options = [
3351
3842
  {
3352
3843
  id: MODE_CONFIG_ID,
@@ -3404,10 +3895,16 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
3404
3895
  });
3405
3896
  }
3406
3897
  // 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.
3898
+ // option is always emitted as a two-value on/off select for every Client
3899
+ // (R2.1); boolean values remain accepted on set for boolean-era clients.
3409
3900
  if (fastMode?.supported) {
3410
- options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption));
3901
+ options.push(createFastModeConfigOption(fastMode.enabled));
3902
+ }
3903
+ // Surface the Thinking toggle whenever the caller supplies its display
3904
+ // state. Unlike Fast mode it is model-independent — no `supported` gate —
3905
+ // and select-only ("on"/"off"), so every session shows it (R1.1).
3906
+ if (thinkingEnabled !== undefined) {
3907
+ options.push(createThinkingConfigOption(thinkingEnabled));
3411
3908
  }
3412
3909
  // Only surface the Agent picker when there's a real choice — i.e. the user
3413
3910
  // has configured at least one custom agent (built-ins are filtered out in
@@ -3549,7 +4046,8 @@ function resolveSettingsModel(models, settingsModel, logger) {
3549
4046
  return resolveModelPreference(models, settingsModel);
3550
4047
  }
3551
4048
  /**
3552
- * Restrict the SDK's model list to the user's `availableModels` allowlist
4049
+ * Deprecation-UNfiltered core of {@link applyAvailableModelsAllowlist}:
4050
+ * restrict the SDK's model list to the user's `availableModels` allowlist
3553
4051
  * (already merged-and-deduped across settings sources by `SettingsManager`).
3554
4052
  * The user's exact entries become the model IDs surfaced via configOptions
3555
4053
  * and passed to `setModel`, which prevents Claude Code from silently
@@ -3559,12 +4057,16 @@ function resolveSettingsModel(models, settingsModel, logger) {
3559
4057
  * Display info and capability flags are copied from the closest SDK match so
3560
4058
  * the UI still renders sensible names and effort levels.
3561
4059
  *
4060
+ * Kept separate from the exported boundary so session creation can obtain the
4061
+ * allowlist-applied-but-unfiltered CATALOG that preference resolution and
4062
+ * capability lookups read (R4.3 — the deprecation filter is visibility-only).
4063
+ *
3562
4064
  * Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
3563
4065
  * - `undefined` is handled by the caller (no allowlist applied).
3564
4066
  * - The Default option is unaffected by `availableModels` — it always remains
3565
4067
  * available, even when the allowlist is `[]`.
3566
4068
  */
3567
- export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsModelOverrides) {
4069
+ function buildAllowlistedModels(sdkModels, allowlist, settingsModelOverrides) {
3568
4070
  // Default is always preserved per the docs. Synthesize one if the SDK
3569
4071
  // didn't surface it so downstream code (e.g. `getAvailableModels` picking
3570
4072
  // `models[0]` as a fallback) still has something to work with.
@@ -3616,7 +4118,59 @@ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsMode
3616
4118
  }
3617
4119
  return result;
3618
4120
  }
3619
- async function getAvailableModels(query, models, sdkModels, settingsManager, logger) {
4121
+ /**
4122
+ * Visibility filter for PICKER-bound model lists (R4.2): drop rows
4123
+ * {@link filterDeprecatedModels} flags as deprecated/legacy. Every
4124
+ * list-building site applies this same helper — via
4125
+ * {@link applyAvailableModelsAllowlist} when an allowlist is configured, or
4126
+ * directly on the raw SDK list otherwise — so the picker never renders a
4127
+ * deprecated row regardless of configuration.
4128
+ *
4129
+ * This is visibility-only: preference resolution and capability lookups keep
4130
+ * reading the UNfiltered catalog (R4.3), so a persisted preference pointing at
4131
+ * a deprecated model is still honored.
4132
+ *
4133
+ * Edge: if the filter would hide EVERY row, fall back to the unfiltered input
4134
+ * so config-option building never receives an empty model list, and log it.
4135
+ * The logger is optional because this is also reached from the free exported
4136
+ * boundary (tests, library callers) where no agent logger exists.
4137
+ */
4138
+ function hideDeprecatedModels(models, logger) {
4139
+ const visible = filterDeprecatedModels(models);
4140
+ if (visible.length === 0 && models.length > 0) {
4141
+ logger?.error("Deprecation filter would hide every available model; showing the unfiltered list instead.");
4142
+ return models;
4143
+ }
4144
+ return visible;
4145
+ }
4146
+ /**
4147
+ * The exported picker-list boundary: {@link buildAllowlistedModels} (the
4148
+ * user's `availableModels` allowlist semantics — see its doc) composed with
4149
+ * {@link hideDeprecatedModels} (the deprecation visibility filter, R4.2).
4150
+ *
4151
+ * The filter runs on the allowlist RESULT, not on `sdkModels`: allowlisted
4152
+ * entries copy `displayName`/`description` from their closest SDK match, so a
4153
+ * deprecated row is hidden even when the allowlist names it explicitly.
4154
+ * Entries with no SDK match copy the entry text itself into `displayName`
4155
+ * ({@link buildAllowlistedModels}), so the heuristic DOES read user-authored
4156
+ * text: a custom entry containing "legacy"/"deprecated" is hidden from the
4157
+ * picker (rare, accepted false positive — the model stays reachable via
4158
+ * `settings.model` / `ANTHROPIC_MODEL`, which resolve over the unfiltered
4159
+ * catalog).
4160
+ */
4161
+ export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsModelOverrides, logger) {
4162
+ return hideDeprecatedModels(buildAllowlistedModels(sdkModels, allowlist, settingsModelOverrides), logger);
4163
+ }
4164
+ async function getAvailableModels(query,
4165
+ /** Deprecation-UNfiltered catalog (allowlist-applied when configured):
4166
+ * preference resolution and the default pick read this list so a persisted
4167
+ * deprecated preference still resolves (R4.3). */
4168
+ models,
4169
+ /** Deprecation-filtered picker list: becomes the rendered `availableModels`
4170
+ * rows (R4.2). The resolved `currentModelId` may legitimately be absent
4171
+ * from these rows (deprecated persisted preference) — the picker then
4172
+ * shows no selection, mirroring the refusal-fallback bookkeeping. */
4173
+ pickerModels, sdkModels, settingsManager, logger) {
3620
4174
  const settings = settingsManager.getSettings();
3621
4175
  let currentModel = models[0];
3622
4176
  let resolvedFromInput;
@@ -3655,7 +4209,7 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
3655
4209
  await query.setModel(currentModel.value);
3656
4210
  }
3657
4211
  return {
3658
- availableModels: models.map((model) => ({
4212
+ availableModels: pickerModels.map((model) => ({
3659
4213
  modelId: model.value,
3660
4214
  name: model.displayName,
3661
4215
  description: model.description,
@@ -3674,7 +4228,7 @@ function getAvailableSlashCommands(commands) {
3674
4228
  "release-notes",
3675
4229
  "todos",
3676
4230
  ];
3677
- return commands
4231
+ const advertised = commands
3678
4232
  .map((command) => {
3679
4233
  const input = command.argumentHint
3680
4234
  ? {
@@ -3694,6 +4248,17 @@ function getAvailableSlashCommands(commands) {
3694
4248
  };
3695
4249
  })
3696
4250
  .filter((command) => !UNSUPPORTED_COMMANDS.includes(command.name));
4251
+ // `/rewind` is handled fully locally by the adapter (story 006, R3.1), so it
4252
+ // is absent from the SDK command list; advertise it here with an input hint.
4253
+ // Deduped by name so a same-named SDK command (should one ever appear) wins.
4254
+ if (!advertised.some((command) => command.name === "rewind")) {
4255
+ advertised.push({
4256
+ name: "rewind",
4257
+ description: "List file checkpoints, or restore files to one with `/rewind <n>`.",
4258
+ input: { hint: "checkpoint number (omit to list)" },
4259
+ });
4260
+ }
4261
+ return advertised;
3697
4262
  }
3698
4263
  function formatUriAsLink(uri) {
3699
4264
  try {
@@ -4286,22 +4851,6 @@ async function fetchContextUsedTokens(query, logger) {
4286
4851
  return null;
4287
4852
  }
4288
4853
  }
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
4854
  function parseModelConfig(raw) {
4306
4855
  if (!raw)
4307
4856
  return undefined;