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