@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.d.ts +108 -35
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +623 -74
- package/dist/model-deprecation.d.ts +30 -0
- package/dist/model-deprecation.d.ts.map +1 -0
- package/dist/model-deprecation.js +58 -0
- package/dist/rewind-command.d.ts +140 -0
- package/dist/rewind-command.d.ts.map +1 -0
- package/dist/rewind-command.js +278 -0
- package/dist/thinking-option.d.ts +85 -0
- package/dist/thinking-option.d.ts.map +1 -0
- package/dist/thinking-option.js +108 -0
- package/package.json +2 -2
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
|
|
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
|
|
2001
|
-
//
|
|
2002
|
-
// string-only validation the select-style options below
|
|
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
|
-
|
|
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
|
|
2633
|
-
*
|
|
2634
|
-
*
|
|
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
|
|
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
|
|
2814
|
-
|
|
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
|
-
|
|
3011
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
3291
|
-
*
|
|
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
|
-
/**
|
|
3303
|
-
*
|
|
3304
|
-
*
|
|
3305
|
-
*
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
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
|
|
3335
|
-
*
|
|
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
|
|
3408
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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;
|