@lucascouts/claude-agent-acp-plus 0.12.0 → 0.14.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/accounts.d.ts +143 -0
- package/dist/accounts.d.ts.map +1 -0
- package/dist/accounts.js +196 -0
- package/dist/acp-agent.d.ts +81 -3
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +426 -67
- package/dist/context-compaction-meta.d.ts +73 -0
- package/dist/context-compaction-meta.d.ts.map +1 -0
- package/dist/context-compaction-meta.js +53 -0
- package/dist/context-compaction.d.ts +160 -0
- package/dist/context-compaction.d.ts.map +1 -0
- package/dist/context-compaction.js +275 -0
- package/dist/usage-markdown.d.ts +92 -0
- package/dist/usage-markdown.d.ts.map +1 -0
- package/dist/usage-markdown.js +373 -0
- package/package.json +1 -1
package/dist/acp-agent.js
CHANGED
|
@@ -14,6 +14,7 @@ import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createEli
|
|
|
14
14
|
import { agentName } from "./agent-name.js";
|
|
15
15
|
import { filterDeprecatedModels } from "./model-deprecation.js";
|
|
16
16
|
import { SettingsManager } from "./settings.js";
|
|
17
|
+
import { ContextCompactionLifecycle, contextCompactionMetadataFromBoundary, } from "./context-compaction.js";
|
|
17
18
|
import { createThinkingConfigOption, effectiveThinkingConfig, resolveThinkingSelection, THINKING_CONFIG_ID, } from "./thinking-option.js";
|
|
18
19
|
import { effortOptionValue, isUltracodeAvailable, isUltracodeValue, ULTRACODE_OPTION_NAME, ULTRACODE_OPTION_VALUE, ultracodeFlagSettings, } from "./ultracode.js";
|
|
19
20
|
import { handleRewindCommand, parseRewindInvocation } from "./rewind-command.js";
|
|
@@ -30,6 +31,8 @@ import { acceptedPlanToolResult, ExitPlanCoordinator, executionDiagnostic, exitP
|
|
|
30
31
|
import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
|
31
32
|
import { parseToolResultMeta } from "./tool-result-meta.js";
|
|
32
33
|
import { AccountUsageTracker } from "./account-usage.js";
|
|
34
|
+
import { ACCOUNT_CONFIG_ID, accountForValue, accountInForce, createAccountConfigOption, envForAccount, readDeclaredAccounts, } from "./accounts.js";
|
|
35
|
+
import { isUsageCommandText, structuredUsageMarkdown } from "./usage-markdown.js";
|
|
33
36
|
export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
|
34
37
|
import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
|
|
35
38
|
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
@@ -112,6 +115,37 @@ function parseSteerRequest(params) {
|
|
|
112
115
|
_meta: _meta,
|
|
113
116
|
};
|
|
114
117
|
}
|
|
118
|
+
/** How often the account quota windows are refreshed while a session sits idle,
|
|
119
|
+
* in milliseconds; `0` disables polling entirely.
|
|
120
|
+
*
|
|
121
|
+
* Without this the windows move at only three moments: session establishment,
|
|
122
|
+
* the end of every turn, and a pushed `rate_limit_event`. None of them is time
|
|
123
|
+
* — so a five-hour window that resets while nobody is prompting keeps showing
|
|
124
|
+
* the old utilization, and the bar is wrong in the one direction that matters
|
|
125
|
+
* (it under-reports how much is available) until the next turn ends.
|
|
126
|
+
*
|
|
127
|
+
* The floor is not timidity: each tick costs one control request, and neither
|
|
128
|
+
* the five-hour nor the seven-day window can move fast enough for a tighter
|
|
129
|
+
* loop to show anything a 15-second one would miss. */
|
|
130
|
+
const QUOTA_POLL_DEFAULT_MS = 60_000;
|
|
131
|
+
const QUOTA_POLL_FLOOR_MS = 15_000;
|
|
132
|
+
/** Resolve the poll interval from the environment, clamped. An unparseable or
|
|
133
|
+
* negative value falls back to the default rather than disabling the refresh:
|
|
134
|
+
* a typo should not silently return the bars to the stale behaviour this
|
|
135
|
+
* exists to fix. Exactly `0` disables, because that is the only way to ask. */
|
|
136
|
+
export function quotaPollIntervalMs(raw = process.env.CLAUDE_ACP_QUOTA_POLL_MS) {
|
|
137
|
+
if (raw === undefined || raw.trim() === "") {
|
|
138
|
+
return QUOTA_POLL_DEFAULT_MS;
|
|
139
|
+
}
|
|
140
|
+
const parsed = Number(raw);
|
|
141
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
142
|
+
return QUOTA_POLL_DEFAULT_MS;
|
|
143
|
+
}
|
|
144
|
+
if (parsed === 0) {
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
return Math.max(parsed, QUOTA_POLL_FLOOR_MS);
|
|
148
|
+
}
|
|
115
149
|
/** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
|
|
116
150
|
* model did on its own (a task-notification followup, a peer/coordinator/
|
|
117
151
|
* observer message it handled) rather than the user's prompt. Absent,
|
|
@@ -481,6 +515,14 @@ export class ClaudeAcpAgent {
|
|
|
481
515
|
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
|
482
516
|
* tests can shrink it. */
|
|
483
517
|
forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
|
|
518
|
+
/** The account selector's last choice, as a selector value (see
|
|
519
|
+
* `accounts.ts`). Held on the AGENT, not on a session, because that is what
|
|
520
|
+
* it governs: the account applies to sessions created after it is set, and
|
|
521
|
+
* the thread that was on screen when the user chose keeps the account it
|
|
522
|
+
* started under (D7 — a session id belongs to one account's local history,
|
|
523
|
+
* so a live thread cannot be carried across a switch). `undefined` until a
|
|
524
|
+
* selection is made, in which case the first declared account stands. */
|
|
525
|
+
selectedAccount;
|
|
484
526
|
constructor(client, logger) {
|
|
485
527
|
this.sessions = {};
|
|
486
528
|
this.client = client;
|
|
@@ -1007,6 +1049,12 @@ export class ClaudeAcpAgent {
|
|
|
1007
1049
|
session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
|
|
1008
1050
|
fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
|
|
1009
1051
|
}
|
|
1052
|
+
// R2.3: exactly `/usage`, and nothing else in the prompt. A second block
|
|
1053
|
+
// (an attached file, a second text part) is a model turn that happens to
|
|
1054
|
+
// mention the command, not the local command itself.
|
|
1055
|
+
const isUsageCommand = params.prompt.length === 1 &&
|
|
1056
|
+
params.prompt[0]?.type === "text" &&
|
|
1057
|
+
isUsageCommandText(params.prompt[0].text);
|
|
1010
1058
|
session.titles.onPrompt(params.prompt);
|
|
1011
1059
|
// Each prompt is a Turn whose deferred the persistent consumer settles once
|
|
1012
1060
|
// the turn's outcome is known. `prompt()` owns no loop: it enqueues the
|
|
@@ -1015,6 +1063,9 @@ export class ClaudeAcpAgent {
|
|
|
1015
1063
|
const turn = {
|
|
1016
1064
|
promptUuid,
|
|
1017
1065
|
isLocalOnlyCommand,
|
|
1066
|
+
...(isUsageCommand
|
|
1067
|
+
? { isUsageCommand: true, usageMarkdownAbort: new AbortController() }
|
|
1068
|
+
: {}),
|
|
1018
1069
|
...(fileChangeAudit ? { fileChangeAudit } : {}),
|
|
1019
1070
|
settled: false,
|
|
1020
1071
|
resolve: () => { },
|
|
@@ -1105,7 +1156,14 @@ export class ClaudeAcpAgent {
|
|
|
1105
1156
|
// answers to, and it is written out here rather than hidden behind a
|
|
1106
1157
|
// helper so a rename lands as one named failure in the log below, not as
|
|
1107
1158
|
// a quiet degradation to no report at all.
|
|
1108
|
-
|
|
1159
|
+
// `skipBehaviors` drops the local-transcript scan that fills the report's
|
|
1160
|
+
// `behaviors` section. Nothing here reads it — `windowsFrom` touches only
|
|
1161
|
+
// `rate_limits_available` and `rate_limits` — so on every call, not just
|
|
1162
|
+
// the polled ones, that scan was pure cost. `/usage` still asks for the
|
|
1163
|
+
// full report, because it renders those contributions.
|
|
1164
|
+
const report = await session.query.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({
|
|
1165
|
+
skipBehaviors: true,
|
|
1166
|
+
});
|
|
1109
1167
|
// The session can be torn down or recreated while the request is in
|
|
1110
1168
|
// flight (provider switch, a changed cwd on session/load). A successor
|
|
1111
1169
|
// must not inherit its predecessor's windows.
|
|
@@ -1142,6 +1200,50 @@ export class ClaudeAcpAgent {
|
|
|
1142
1200
|
this.logger.error(`Session ${sessionId}: structured usage report unavailable: ${error}`);
|
|
1143
1201
|
}
|
|
1144
1202
|
}
|
|
1203
|
+
/** Start the idle refresh of the account quota windows for this session.
|
|
1204
|
+
*
|
|
1205
|
+
* Idempotent, and deliberately called at the end of every turn rather than
|
|
1206
|
+
* once: the first call arms, the rest return immediately, so no caller has to
|
|
1207
|
+
* know whether this is turn one.
|
|
1208
|
+
*
|
|
1209
|
+
* The tick does nothing while a turn is in flight. That turn's own end
|
|
1210
|
+
* already refreshes the windows, and a second control request would buy the
|
|
1211
|
+
* same answer twice — the same reasoning that excludes autonomous cycles at
|
|
1212
|
+
* the end-of-turn call site. */
|
|
1213
|
+
armAccountUsagePolling(sessionId, session) {
|
|
1214
|
+
if (session.accountUsageTimer) {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const intervalMs = quotaPollIntervalMs();
|
|
1218
|
+
if (intervalMs === 0) {
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
// Closure-local rather than a Session field: it guards this timer alone, and
|
|
1222
|
+
// a slow report must not let ticks pile up into a queue of duplicate control
|
|
1223
|
+
// requests against a session that is already struggling to answer one.
|
|
1224
|
+
let inFlight = false;
|
|
1225
|
+
const timer = setInterval(() => {
|
|
1226
|
+
// Re-resolve through the map. A provider update replaces the Session
|
|
1227
|
+
// object under the same id and the replacement arms its own timer, so an
|
|
1228
|
+
// orphaned closure must retire rather than keep polling a husk.
|
|
1229
|
+
if (this.sessions[sessionId] !== session || session.queryClosed) {
|
|
1230
|
+
clearInterval(timer);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (session.turnQueue?.length || session.activeTurn || inFlight) {
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
inFlight = true;
|
|
1237
|
+
// `publishAccountUsage` never rejects — it logs and returns — so this
|
|
1238
|
+
// cannot become an unhandled rejection, and `finally` always re-opens.
|
|
1239
|
+
void this.publishAccountUsage(sessionId, session).finally(() => {
|
|
1240
|
+
inFlight = false;
|
|
1241
|
+
});
|
|
1242
|
+
}, intervalMs);
|
|
1243
|
+
// A quota refresh is never a reason to keep the process alive.
|
|
1244
|
+
timer.unref?.();
|
|
1245
|
+
session.accountUsageTimer = timer;
|
|
1246
|
+
}
|
|
1145
1247
|
async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
|
|
1146
1248
|
const goalUpdate = goalUpdateFromPrompt(prompt);
|
|
1147
1249
|
if (goalUpdate !== undefined) {
|
|
@@ -1310,11 +1412,6 @@ export class ClaudeAcpAgent {
|
|
|
1310
1412
|
// stop_reason "refusal" and structured stop_details. We capture the
|
|
1311
1413
|
// human-readable explanation so the terminal `result` can surface it.
|
|
1312
1414
|
let lastRefusalExplanation = null;
|
|
1313
|
-
// Tracks whether we're inside a compaction. The SDK emits the terminal
|
|
1314
|
-
// `status` (compact_result success/failed) twice for a single failed
|
|
1315
|
-
// compaction, and the two messages are indistinguishable — so we report the
|
|
1316
|
-
// outcome only while a compaction is in progress, then clear this.
|
|
1317
|
-
let compactionInProgress = false;
|
|
1318
1415
|
// Anthropic API message id of the assistant message currently being
|
|
1319
1416
|
// streamed, captured from `message_start` so the streamed chunks that follow
|
|
1320
1417
|
// (whose delta events don't carry it) can all be tagged with the same,
|
|
@@ -1349,6 +1446,12 @@ export class ClaudeAcpAgent {
|
|
|
1349
1446
|
* recognizable by the `parentToolUseId` meta that toAcpNotifications
|
|
1350
1447
|
* stamps from `parent_tool_use_id`, and never reach the top-level feed
|
|
1351
1448
|
* as the turn's answer. */
|
|
1449
|
+
/** Compaction as one ACP tool lifecycle, replacing the in-progress boolean
|
|
1450
|
+
* this consumer used to infer it from (story 010, R2.1/R2.2). Declared
|
|
1451
|
+
* ahead of `sendUpdate` because that chokepoint consults it; the closure
|
|
1452
|
+
* below only dereferences `sendUpdate` when an update is actually sent,
|
|
1453
|
+
* which is always after both bindings exist. */
|
|
1454
|
+
const compaction = new ContextCompactionLifecycle((notification) => sendUpdate(notification));
|
|
1352
1455
|
const sendUpdate = async (notification) => {
|
|
1353
1456
|
const { update } = notification;
|
|
1354
1457
|
if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
|
|
@@ -1361,6 +1464,14 @@ export class ClaudeAcpAgent {
|
|
|
1361
1464
|
}
|
|
1362
1465
|
if (update.sessionUpdate === "agent_message_chunk") {
|
|
1363
1466
|
const claudeMeta = update._meta?.claudeCode;
|
|
1467
|
+
// A failed manual compaction's error also arrives as assistant text;
|
|
1468
|
+
// the tool lifecycle already carried it, so drop that one copy rather
|
|
1469
|
+
// than report the same failure twice (R2.2).
|
|
1470
|
+
if (!claudeMeta?.parentToolUseId &&
|
|
1471
|
+
update.content.type === "text" &&
|
|
1472
|
+
compaction.consumeDuplicateErrorOutput(update.content.text)) {
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1364
1475
|
if (!claudeMeta?.parentToolUseId) {
|
|
1365
1476
|
session.emittedAssistantText = true;
|
|
1366
1477
|
session.titles.onAssistantText(update.content);
|
|
@@ -1413,7 +1524,6 @@ export class ClaudeAcpAgent {
|
|
|
1413
1524
|
lastAssistantWasUsageLimit = false;
|
|
1414
1525
|
lastAssistantFailureTitle = undefined;
|
|
1415
1526
|
lastRefusalExplanation = null;
|
|
1416
|
-
compactionInProgress = false;
|
|
1417
1527
|
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
|
|
1418
1528
|
// activation can fire mid-message (the replayed user echo with
|
|
1419
1529
|
// --replay-user-messages lands between a message's blocks); clearing the
|
|
@@ -1432,6 +1542,22 @@ export class ClaudeAcpAgent {
|
|
|
1432
1542
|
if (session.activeTurn)
|
|
1433
1543
|
session.activeTurn.carriedUsage = undefined;
|
|
1434
1544
|
};
|
|
1545
|
+
/** Start this turn's structured `/usage` render, once, and hand back the
|
|
1546
|
+
* in-flight promise; undefined when the turn owns no render.
|
|
1547
|
+
*
|
|
1548
|
+
* Started at ACTIVATION rather than when the output arrives, so the
|
|
1549
|
+
* bounded wait overlaps the command instead of following it: by the time
|
|
1550
|
+
* the CLI has printed its answer the report has usually already lost or
|
|
1551
|
+
* won its race, and the user waits for neither. */
|
|
1552
|
+
const ensureUsageMarkdown = (turn) => {
|
|
1553
|
+
if (!turn.isUsageCommand || !turn.usageMarkdownAbort) {
|
|
1554
|
+
return undefined;
|
|
1555
|
+
}
|
|
1556
|
+
// `session.query` is read here rather than captured: a provider switch
|
|
1557
|
+
// can replace it, and the render must ask the query that is live now.
|
|
1558
|
+
turn.usageMarkdown ??= structuredUsageMarkdown(session.query, turn.usageMarkdownAbort.signal, this.logger);
|
|
1559
|
+
return turn.usageMarkdown;
|
|
1560
|
+
};
|
|
1435
1561
|
/** Promote a queued turn to active: it becomes the one output is attributed
|
|
1436
1562
|
* to, and its scratch starts fresh. Clears the cancelled flag so a turn
|
|
1437
1563
|
* enqueued after a prior cancel isn't treated as cancelled. Also clears any
|
|
@@ -1443,6 +1569,7 @@ export class ClaudeAcpAgent {
|
|
|
1443
1569
|
const activateTurn = (turn) => {
|
|
1444
1570
|
session.activeTurn = turn;
|
|
1445
1571
|
session.cancelled = false;
|
|
1572
|
+
ensureUsageMarkdown(turn);
|
|
1446
1573
|
session.pendingOrphanResults = 0;
|
|
1447
1574
|
session.orphanCommands?.clear();
|
|
1448
1575
|
// Two-phase sweep of registry entries the level signal ended (see
|
|
@@ -1481,12 +1608,20 @@ export class ClaudeAcpAgent {
|
|
|
1481
1608
|
*
|
|
1482
1609
|
* But an echo-less result can also be an ORPHAN: cancel() settles+removes a
|
|
1483
1610
|
* queued turn whose user message was already pushed, so the SDK still runs
|
|
1484
|
-
* it and emits a result with no
|
|
1611
|
+
* it and emits a result with no echo to match. Promoting the head for an
|
|
1485
1612
|
* orphan would misattribute its stop reason/usage to an unrelated later
|
|
1486
1613
|
* prompt. `session.pendingOrphanResults` counts exactly how many such
|
|
1487
1614
|
* orphans are still expected (FIFO, they arrive before any live turn's
|
|
1488
|
-
* result), so we skip those and only promote once the count is drained.
|
|
1489
|
-
|
|
1615
|
+
* result), so we skip those and only promote once the count is drained.
|
|
1616
|
+
*
|
|
1617
|
+
* `resultUserMessageUuid` is the result's own join key (SDK 0.3.246+
|
|
1618
|
+
* echoes the triggering send's client uuid on results; absent on older
|
|
1619
|
+
* CLIs, synthetic/meta turns, and session-scoped failures). When present
|
|
1620
|
+
* it upgrades the map lane from positional heuristics to an exact match:
|
|
1621
|
+
* a stamp naming an orphaned command consumes the result outright, and a
|
|
1622
|
+
* stamp naming anything else positively refutes "this is a dead turn's
|
|
1623
|
+
* result", so the dup-over-loss one-skip must not eat it. */
|
|
1624
|
+
const ensureActiveTurn = (resultUserMessageUuid) => {
|
|
1490
1625
|
if (session.activeTurn) {
|
|
1491
1626
|
if (!isHeldOpen(session.activeTurn)) {
|
|
1492
1627
|
return;
|
|
@@ -1538,8 +1673,19 @@ export class ClaudeAcpAgent {
|
|
|
1538
1673
|
// double-consume it. The unexpected-transition logging in the frame
|
|
1539
1674
|
// handler is the tripwire for that class of drift.
|
|
1540
1675
|
if (session.orphanCommands?.size) {
|
|
1676
|
+
// Resolved BEFORE the drain below, which deletes every started/zombie
|
|
1677
|
+
// entry: a stamp naming a "started" orphan would no longer be found
|
|
1678
|
+
// afterwards, and the result would promote the head — misattributing
|
|
1679
|
+
// a dead turn's outcome to a live prompt, the exact failure the map
|
|
1680
|
+
// exists to prevent. The lookup order is load-bearing.
|
|
1681
|
+
const stampedOrphanUuid = resultUserMessageUuid !== undefined && session.orphanCommands.has(resultUserMessageUuid)
|
|
1682
|
+
? resultUserMessageUuid
|
|
1683
|
+
: undefined;
|
|
1541
1684
|
let consumedOrphanResult = false;
|
|
1542
1685
|
let oldestPending;
|
|
1686
|
+
// The started/zombie drain applies regardless of the stamp: commands
|
|
1687
|
+
// folded into the turn that emitted this result share it, and zombies'
|
|
1688
|
+
// late results have already passed (or never existed).
|
|
1543
1689
|
for (const [uuid, state] of session.orphanCommands) {
|
|
1544
1690
|
if (state === "started" || state === "zombie") {
|
|
1545
1691
|
consumedOrphanResult = true;
|
|
@@ -1549,17 +1695,33 @@ export class ClaudeAcpAgent {
|
|
|
1549
1695
|
oldestPending ??= uuid;
|
|
1550
1696
|
}
|
|
1551
1697
|
}
|
|
1552
|
-
if (
|
|
1698
|
+
if (stampedOrphanUuid !== undefined) {
|
|
1699
|
+
// Exact join: the result names an orphaned command. Delete the
|
|
1700
|
+
// matched entry even when it is still "pending" (its dispatch frame
|
|
1701
|
+
// was lost) and consume the result — no promotion.
|
|
1702
|
+
session.orphanCommands.delete(stampedOrphanUuid);
|
|
1553
1703
|
return;
|
|
1554
1704
|
}
|
|
1555
|
-
if (
|
|
1556
|
-
//
|
|
1557
|
-
// live turn's
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1560
|
-
//
|
|
1561
|
-
|
|
1562
|
-
|
|
1705
|
+
if (resultUserMessageUuid !== undefined) {
|
|
1706
|
+
// The stamp names a send that is NOT in the orphan map, so this is
|
|
1707
|
+
// a live turn's result: skip both the consumed-return (its folded
|
|
1708
|
+
// orphans were drained above, but the result itself still needs a
|
|
1709
|
+
// turn) and the dup-over-loss one-skip the stamp refutes, and fall
|
|
1710
|
+
// through to promote the head.
|
|
1711
|
+
}
|
|
1712
|
+
else {
|
|
1713
|
+
if (consumedOrphanResult) {
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (oldestPending !== undefined) {
|
|
1717
|
+
// No dispatch was seen before this result, so it is very likely a
|
|
1718
|
+
// live turn's — but a lost "started" frame would mean it IS the
|
|
1719
|
+
// orphan's (dup-over-loss: prefer one wrong skip over
|
|
1720
|
+
// misattributing a dead turn's outcome to a live prompt). Grant
|
|
1721
|
+
// each pending entry exactly one skip, like the count lane did.
|
|
1722
|
+
session.orphanCommands.delete(oldestPending);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1563
1725
|
}
|
|
1564
1726
|
}
|
|
1565
1727
|
const head = firstUnsettledQueuedTurn();
|
|
@@ -1600,6 +1762,49 @@ export class ClaudeAcpAgent {
|
|
|
1600
1762
|
* spelling of "a prompt is pending" shared by the head promotion and
|
|
1601
1763
|
* the autonomous stretch-close guard. */
|
|
1602
1764
|
const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
|
|
1765
|
+
/** Claim the structured render for whichever turn is producing this local
|
|
1766
|
+
* command output. Three answers, and the caller must distinguish all
|
|
1767
|
+
* three:
|
|
1768
|
+
*
|
|
1769
|
+
* undefined — not a `/usage` turn, or one of R2.4's three ways out
|
|
1770
|
+
* fired: publish `originalOutput` unchanged
|
|
1771
|
+
* string — the render: publish it INSTEAD of `originalOutput`
|
|
1772
|
+
* null — publish nothing (a duplicate of an already-replaced
|
|
1773
|
+
* frame, or a turn cancelled during the wait)
|
|
1774
|
+
*
|
|
1775
|
+
* No content signature and no text parsing: which turn owns a render was
|
|
1776
|
+
* decided from the prompt, so a command whose output happens to look like
|
|
1777
|
+
* `/usage`'s can never be rewritten. */
|
|
1778
|
+
const takeUsageMarkdown = async (originalOutput) => {
|
|
1779
|
+
const turn = session.activeTurn ?? firstUnsettledQueuedTurn();
|
|
1780
|
+
if (!turn) {
|
|
1781
|
+
return undefined;
|
|
1782
|
+
}
|
|
1783
|
+
const pending = ensureUsageMarkdown(turn);
|
|
1784
|
+
if (!pending) {
|
|
1785
|
+
return undefined;
|
|
1786
|
+
}
|
|
1787
|
+
const markdown = await pending;
|
|
1788
|
+
// That await can span the whole bounded wait, and a cancel landing inside
|
|
1789
|
+
// it ends the turn. A cancelled turn publishes nothing — not the render,
|
|
1790
|
+
// and not the original text either.
|
|
1791
|
+
if (session.cancelled) {
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
if (markdown === null) {
|
|
1795
|
+
return undefined;
|
|
1796
|
+
}
|
|
1797
|
+
if (turn.usageMarkdownDelivered) {
|
|
1798
|
+
// One local command can reach the client through more than one SDK
|
|
1799
|
+
// message shape. Suppress an exact mirror of the frame already
|
|
1800
|
+
// replaced, but let a later, genuinely different frame (an
|
|
1801
|
+
// interruption diagnostic, say) take the normal path.
|
|
1802
|
+
return turn.usageOriginalOutput === originalOutput ? null : undefined;
|
|
1803
|
+
}
|
|
1804
|
+
turn.usageMarkdownDelivered = true;
|
|
1805
|
+
turn.usageOriginalOutput = originalOutput;
|
|
1806
|
+
return markdown;
|
|
1807
|
+
};
|
|
1603
1808
|
/** Whether any background subagent this turn spawned is still live —
|
|
1604
1809
|
* while true, the turn's settlement stays deferred so the subagent's
|
|
1605
1810
|
* output and permission requests land inside it (see
|
|
@@ -1668,6 +1873,9 @@ export class ClaudeAcpAgent {
|
|
|
1668
1873
|
// Captured before the settled flip below (isHeldOpen tests !settled).
|
|
1669
1874
|
const wasHeld = isHeldOpen(turn);
|
|
1670
1875
|
turn.settled = true;
|
|
1876
|
+
// The turn is over: abandon any structured `/usage` render still in
|
|
1877
|
+
// flight rather than leaving it to run out its own clock (D5).
|
|
1878
|
+
turn.usageMarkdownAbort?.abort();
|
|
1671
1879
|
disarmForceCancel(session);
|
|
1672
1880
|
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
1673
1881
|
session.activeTurn = null;
|
|
@@ -1700,6 +1908,7 @@ export class ClaudeAcpAgent {
|
|
|
1700
1908
|
}
|
|
1701
1909
|
this.finishFileChangeAudit(session, turn, "providerError");
|
|
1702
1910
|
turn.settled = true;
|
|
1911
|
+
turn.usageMarkdownAbort?.abort();
|
|
1703
1912
|
session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
|
|
1704
1913
|
session.activeTurn = null;
|
|
1705
1914
|
streamedToolInputs.clear();
|
|
@@ -1748,6 +1957,7 @@ export class ClaudeAcpAgent {
|
|
|
1748
1957
|
this.finishFileChangeAudit(session, turn, "providerError");
|
|
1749
1958
|
const wasHeld = isHeldOpen(turn);
|
|
1750
1959
|
turn.settled = true;
|
|
1960
|
+
turn.usageMarkdownAbort?.abort();
|
|
1751
1961
|
if (wasHeld) {
|
|
1752
1962
|
// A held turn's answer already streamed and its outcome is
|
|
1753
1963
|
// recorded — a stream death during the post-answer hold is a
|
|
@@ -2073,50 +2283,43 @@ export class ClaudeAcpAgent {
|
|
|
2073
2283
|
}
|
|
2074
2284
|
break;
|
|
2075
2285
|
case "status": {
|
|
2076
|
-
//
|
|
2077
|
-
//
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2286
|
+
// Compaction is reported as ONE tool lifecycle rather than the
|
|
2287
|
+
// assistant-text banners this branch used to emit (R2.1). The
|
|
2288
|
+
// banners needed a boolean in-progress guard because the SDK
|
|
2289
|
+
// repeats the terminal `status` and the two copies are
|
|
2290
|
+
// indistinguishable here; the lifecycle owns that de-duplication
|
|
2291
|
+
// by phase instead, so the guard is gone rather than inert (R2.2).
|
|
2292
|
+
//
|
|
2293
|
+
// The SDK signals manual `/compact` completion with a status
|
|
2294
|
+
// message carrying `compact_result`, not the `compact_boundary`
|
|
2295
|
+
// message (which only fires when there's content to compact) —
|
|
2296
|
+
// so both frames must be able to close a lifecycle.
|
|
2080
2297
|
if (message.status === "compacting") {
|
|
2081
|
-
|
|
2082
|
-
await sendUpdate({
|
|
2083
|
-
sessionId: message.session_id,
|
|
2084
|
-
update: {
|
|
2085
|
-
sessionUpdate: "agent_message_chunk",
|
|
2086
|
-
content: { type: "text", text: "Compacting..." },
|
|
2087
|
-
},
|
|
2088
|
-
});
|
|
2298
|
+
await compaction.start(message.session_id, message.uuid);
|
|
2089
2299
|
}
|
|
2090
|
-
else if (message.compact_result === "success"
|
|
2091
|
-
|
|
2092
|
-
// message carrying `compact_result`, not the `compact_boundary`
|
|
2093
|
-
// message (which only fires when there's content to compact).
|
|
2094
|
-
compactionInProgress = false;
|
|
2095
|
-
await sendUpdate({
|
|
2096
|
-
sessionId: message.session_id,
|
|
2097
|
-
update: {
|
|
2098
|
-
sessionUpdate: "agent_message_chunk",
|
|
2099
|
-
content: { type: "text", text: "\n\nCompacting completed." },
|
|
2100
|
-
},
|
|
2101
|
-
});
|
|
2300
|
+
else if (message.compact_result === "success") {
|
|
2301
|
+
await compaction.finish(message.session_id, message.uuid, "completed");
|
|
2102
2302
|
}
|
|
2103
|
-
else if (message.compact_result === "failed"
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
await sendUpdate({
|
|
2107
|
-
sessionId: message.session_id,
|
|
2108
|
-
update: {
|
|
2109
|
-
sessionUpdate: "agent_message_chunk",
|
|
2110
|
-
content: { type: "text", text: `\n\nCompacting failed${reason}` },
|
|
2111
|
-
},
|
|
2303
|
+
else if (message.compact_result === "failed") {
|
|
2304
|
+
await compaction.finish(message.session_id, message.uuid, "failed", {
|
|
2305
|
+
...(message.compact_error ? { error: message.compact_error } : {}),
|
|
2112
2306
|
});
|
|
2113
2307
|
}
|
|
2114
2308
|
break;
|
|
2115
2309
|
}
|
|
2116
2310
|
case "compact_boundary": {
|
|
2311
|
+
// This is the only frame carrying the token counts, and it
|
|
2312
|
+
// arrives AFTER the terminal `status` — so it enriches the
|
|
2313
|
+
// lifecycle rather than reporting an outcome again
|
|
2314
|
+
// (`enrichTerminal`): the update omits `status`, leaving exactly
|
|
2315
|
+
// one terminal transition the client can see (R2.2). It also
|
|
2316
|
+
// stands alone when no `status` preceded it (an automatic
|
|
2317
|
+
// compaction, or a replay that dropped the opening frame).
|
|
2318
|
+
const compactMetadata = message.compact_metadata;
|
|
2319
|
+
await compaction.finish(message.session_id, message.uuid, "completed", compactMetadata ? contextCompactionMetadataFromBoundary(compactMetadata) : {}, true);
|
|
2117
2320
|
// Refresh the displayed usage immediately so the client doesn't
|
|
2118
2321
|
// keep showing the stale pre-compaction size (e.g. "944k/1m")
|
|
2119
|
-
// right after the
|
|
2322
|
+
// right after the compaction tool call completes, which is
|
|
2120
2323
|
// confusing and wrong.
|
|
2121
2324
|
//
|
|
2122
2325
|
// Prefer the SDK's authoritative post-compaction `used` via
|
|
@@ -2131,10 +2334,6 @@ export class ClaudeAcpAgent {
|
|
|
2131
2334
|
// `size` keeps coming from session.contextWindowSize —
|
|
2132
2335
|
// compaction frees occupancy, it doesn't change the model's
|
|
2133
2336
|
// window.
|
|
2134
|
-
//
|
|
2135
|
-
// The "Compacting completed." text is emitted from the `status`
|
|
2136
|
-
// handler (keyed on `compact_result`), not here, so the failure
|
|
2137
|
-
// path gets a message too.
|
|
2138
2337
|
const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
|
|
2139
2338
|
lastAssistantUsage = null;
|
|
2140
2339
|
lastAssistantTotalUsage = usedTokens ?? 0;
|
|
@@ -2150,11 +2349,24 @@ export class ClaudeAcpAgent {
|
|
|
2150
2349
|
break;
|
|
2151
2350
|
}
|
|
2152
2351
|
case "local_command_output": {
|
|
2352
|
+
// A failed `/compact` also prints its error here; the tool
|
|
2353
|
+
// lifecycle already carried it, so consume that one duplicate
|
|
2354
|
+
// (matched by text, once) without hiding other command output.
|
|
2355
|
+
if (compaction.consumeDuplicateErrorOutput(message.content)) {
|
|
2356
|
+
break;
|
|
2357
|
+
}
|
|
2358
|
+
// R2.3/R2.4: a `/usage` turn publishes its structured render
|
|
2359
|
+
// here instead; anything else, and any way out, publishes the
|
|
2360
|
+
// CLI's own text byte-for-byte.
|
|
2361
|
+
const usageMarkdown = await takeUsageMarkdown(message.content);
|
|
2362
|
+
if (usageMarkdown === null) {
|
|
2363
|
+
break;
|
|
2364
|
+
}
|
|
2153
2365
|
await sendUpdate({
|
|
2154
2366
|
sessionId: message.session_id,
|
|
2155
2367
|
update: {
|
|
2156
2368
|
sessionUpdate: "agent_message_chunk",
|
|
2157
|
-
content: { type: "text", text: message.content },
|
|
2369
|
+
content: { type: "text", text: usageMarkdown ?? message.content },
|
|
2158
2370
|
},
|
|
2159
2371
|
});
|
|
2160
2372
|
break;
|
|
@@ -2192,6 +2404,10 @@ export class ClaudeAcpAgent {
|
|
|
2192
2404
|
// the interrupted turn's tokens entirely (issue #844). Zero
|
|
2193
2405
|
// when the cancel pre-empted the result (wedge/force-cancel).
|
|
2194
2406
|
if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
|
|
2407
|
+
// An interrupt can pre-empt the result entirely, so the
|
|
2408
|
+
// lifecycle's own reset there never ran; close it here or a
|
|
2409
|
+
// half-open compaction would leak into the next turn.
|
|
2410
|
+
compaction.reset();
|
|
2195
2411
|
settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
|
|
2196
2412
|
// An interrupt can pre-empt the turn's result entirely
|
|
2197
2413
|
// (nothing ran the result-case `finally`), so close the
|
|
@@ -2254,6 +2470,9 @@ export class ClaudeAcpAgent {
|
|
|
2254
2470
|
else if (!session.cancelled &&
|
|
2255
2471
|
session.activeTurn &&
|
|
2256
2472
|
!session.activeTurn.settled) {
|
|
2473
|
+
// Same reason as the cancelled branch: this turn will never
|
|
2474
|
+
// reach the result that would have reset the lifecycle.
|
|
2475
|
+
compaction.reset();
|
|
2257
2476
|
// Deliberately only the ACTIVE turn: a queued turn that
|
|
2258
2477
|
// was never echoed is NOT failed here, because an idle
|
|
2259
2478
|
// can legitimately precede the SDK picking up freshly
|
|
@@ -2669,7 +2888,7 @@ export class ClaudeAcpAgent {
|
|
|
2669
2888
|
// the map in that case).
|
|
2670
2889
|
if (!isAutonomousResult) {
|
|
2671
2890
|
recordResultForOrphanCommands();
|
|
2672
|
-
ensureActiveTurn();
|
|
2891
|
+
ensureActiveTurn(message.user_message_uuid);
|
|
2673
2892
|
// Once the submitted goal command has produced its own result,
|
|
2674
2893
|
// no older runtime update can still precede it in the ordered
|
|
2675
2894
|
// SDK stream. Stop suppressing updates even when this runtime
|
|
@@ -2700,6 +2919,12 @@ export class ClaudeAcpAgent {
|
|
|
2700
2919
|
// through the early break below, which the gated `finally`
|
|
2701
2920
|
// leaves alone).
|
|
2702
2921
|
const deliveredAssistantText = session.emittedAssistantText;
|
|
2922
|
+
// The deleted "Compacting…" banners counted as delivered text
|
|
2923
|
+
// simply by going through sendUpdate; tool calls don't, so a turn
|
|
2924
|
+
// that ONLY compacted (e.g. `/compact`, promoted at its own
|
|
2925
|
+
// result) needs this to keep its result text from being re-emitted
|
|
2926
|
+
// by the issue-#453 fallback below.
|
|
2927
|
+
const deliveredCompactionOutput = compaction.hasDeliveredOutput;
|
|
2703
2928
|
// Every user-turn result terminates a turn (settle, reject, or
|
|
2704
2929
|
// orphan skip) and the SDK follows it with a trailing
|
|
2705
2930
|
// `session_state_changed: idle` — record the debt so the idle
|
|
@@ -2946,9 +3171,19 @@ export class ClaudeAcpAgent {
|
|
|
2946
3171
|
// the fallback there. (Autonomous results never get here —
|
|
2947
3172
|
// they exit at the early break above — so no background
|
|
2948
3173
|
// prose can be injected into the feed.)
|
|
2949
|
-
|
|
2950
|
-
(!deliveredAssistantText &&
|
|
2951
|
-
|
|
3174
|
+
const shouldForwardResult = session.activeTurn?.isLocalOnlyCommand ||
|
|
3175
|
+
(!deliveredAssistantText &&
|
|
3176
|
+
!deliveredCompactionOutput &&
|
|
3177
|
+
(message.usage.output_tokens ?? 0) === 0);
|
|
3178
|
+
if (shouldForwardResult) {
|
|
3179
|
+
// A `/usage` turn whose output arrives only on the result.
|
|
3180
|
+
// Claiming it here also stops the raw text following a
|
|
3181
|
+
// render already published from another message shape.
|
|
3182
|
+
const usageMarkdown = await takeUsageMarkdown(message.result);
|
|
3183
|
+
if (usageMarkdown === null) {
|
|
3184
|
+
break;
|
|
3185
|
+
}
|
|
3186
|
+
for (const notification of toAcpNotifications(usageMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
|
|
2952
3187
|
await sendUpdate(notification);
|
|
2953
3188
|
}
|
|
2954
3189
|
}
|
|
@@ -3023,6 +3258,11 @@ export class ClaudeAcpAgent {
|
|
|
3023
3258
|
finally {
|
|
3024
3259
|
if (!isAutonomousResult) {
|
|
3025
3260
|
session.emittedAssistantText = false;
|
|
3261
|
+
// A result closes this turn's compaction lifecycle. Reset here
|
|
3262
|
+
// rather than at idle: an owed idle from this turn can arrive
|
|
3263
|
+
// after the next turn has already started, and would erase that
|
|
3264
|
+
// turn's compaction state instead of its own.
|
|
3265
|
+
compaction.reset();
|
|
3026
3266
|
// R1.2: a user turn's terminal result is the moment consumption
|
|
3027
3267
|
// actually changed, so refresh the account quota windows here —
|
|
3028
3268
|
// in the `finally`, which is the one point EVERY exit from this
|
|
@@ -3038,11 +3278,26 @@ export class ClaudeAcpAgent {
|
|
|
3038
3278
|
// the trailing idle is, and a stalled control request there is
|
|
3039
3279
|
// the same exposure `fetchContextUsedTokens` already accepts.
|
|
3040
3280
|
await this.publishAccountUsage(params.sessionId, session);
|
|
3281
|
+
// A settled turn is the proof that control requests are being
|
|
3282
|
+
// serviced (before the first one they are not — #886/#880), so
|
|
3283
|
+
// this is the earliest honest moment to start the idle refresh.
|
|
3284
|
+
this.armAccountUsagePolling(params.sessionId, session);
|
|
3041
3285
|
}
|
|
3042
3286
|
}
|
|
3043
3287
|
break;
|
|
3044
3288
|
}
|
|
3045
3289
|
case "stream_event": {
|
|
3290
|
+
// Compaction streams as its own block type. The deltas carry the
|
|
3291
|
+
// generated summary, which stays internal to the agent — all the
|
|
3292
|
+
// client gets is one keep-alive on the open tool call, so a long
|
|
3293
|
+
// compaction doesn't look stalled.
|
|
3294
|
+
const isCompactionProgress = (message.event.type === "content_block_start" &&
|
|
3295
|
+
message.event.content_block.type === "compaction") ||
|
|
3296
|
+
(message.event.type === "content_block_delta" &&
|
|
3297
|
+
message.event.delta.type === "compaction_delta");
|
|
3298
|
+
if (isCompactionProgress) {
|
|
3299
|
+
await compaction.heartbeat(message.session_id, message.uuid);
|
|
3300
|
+
}
|
|
3046
3301
|
// `message_start` carries the Anthropic API message id; capture it
|
|
3047
3302
|
// so the streamed chunks that follow (whose delta events don't carry
|
|
3048
3303
|
// it) can all be tagged with the same, replay-stable id.
|
|
@@ -3265,6 +3520,18 @@ export class ClaudeAcpAgent {
|
|
|
3265
3520
|
if (session.cancelled) {
|
|
3266
3521
|
break;
|
|
3267
3522
|
}
|
|
3523
|
+
// Synthetic assistant frames carry the CLI's local-command output.
|
|
3524
|
+
// On resume the SDK can replay a stale frame from an earlier compact
|
|
3525
|
+
// attempt after a later compaction completed. Scope the suppression
|
|
3526
|
+
// to the compaction lifecycle and the synthetic frame itself rather
|
|
3527
|
+
// than to the owning turn: one model turn may compact more than
|
|
3528
|
+
// once, and its real assistant response must still be delivered.
|
|
3529
|
+
if (message.type === "assistant" &&
|
|
3530
|
+
message.parent_tool_use_id === null &&
|
|
3531
|
+
message.message.model === "<synthetic>" &&
|
|
3532
|
+
compaction.hasDeliveredOutput) {
|
|
3533
|
+
break;
|
|
3534
|
+
}
|
|
3268
3535
|
// Snapshot the latest top-level assistant usage and model so the
|
|
3269
3536
|
// next `result` can emit a usage_update tied to the right context
|
|
3270
3537
|
// window. Subagent messages are excluded to keep the snapshot
|
|
@@ -3303,7 +3570,13 @@ export class ClaudeAcpAgent {
|
|
|
3303
3570
|
message.message.content.includes("<local-command-stdout>")) {
|
|
3304
3571
|
const stripped = stripLocalCommandMetadata(message.message.content);
|
|
3305
3572
|
if (typeof stripped === "string") {
|
|
3306
|
-
|
|
3573
|
+
// The usual shape for a real `/usage`: the comment above names
|
|
3574
|
+
// it as one of the commands the CLI wraps in these markers.
|
|
3575
|
+
const usageMarkdown = await takeUsageMarkdown(stripped);
|
|
3576
|
+
if (usageMarkdown === null) {
|
|
3577
|
+
break;
|
|
3578
|
+
}
|
|
3579
|
+
for (const notification of toAcpNotifications(usageMarkdown ?? stripped, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
|
|
3307
3580
|
clientCapabilities: this.clientCapabilities,
|
|
3308
3581
|
parentToolUseId: message.parent_tool_use_id,
|
|
3309
3582
|
cwd: session.cwd,
|
|
@@ -3644,6 +3917,11 @@ export class ClaudeAcpAgent {
|
|
|
3644
3917
|
await session.queryRecreateInFlight;
|
|
3645
3918
|
}
|
|
3646
3919
|
session.cancelled = true;
|
|
3920
|
+
// Every turn's structured `/usage` render is abandoned here — the queue
|
|
3921
|
+
// still holds the active turn at this point, so one sweep covers both.
|
|
3922
|
+
for (const turn of session.turnQueue ?? []) {
|
|
3923
|
+
turn.usageMarkdownAbort?.abort();
|
|
3924
|
+
}
|
|
3647
3925
|
session.pendingExitPlanModeInterruption = undefined;
|
|
3648
3926
|
session.pendingExitPlanContextReset = undefined;
|
|
3649
3927
|
// The stream already ended (see closeQueryStream): every in-flight turn was
|
|
@@ -3686,8 +3964,10 @@ export class ClaudeAcpAgent {
|
|
|
3686
3964
|
}
|
|
3687
3965
|
// Each removed queued turn's user message was already pushed to the SDK,
|
|
3688
3966
|
// which processes input FIFO and will still emit a result for it with no
|
|
3689
|
-
//
|
|
3690
|
-
//
|
|
3967
|
+
// user echo to match (0.3.246+ CLIs do stamp results with the
|
|
3968
|
+
// triggering send's user_message_uuid, which ensureActiveTurn uses as
|
|
3969
|
+
// an exact join when present). Track those so the consumer skips them
|
|
3970
|
+
// (see ensureActiveTurn) rather than misattributing them to the head.
|
|
3691
3971
|
// msg_lifecycle_v1 CLIs get per-uuid tracking drained by the command's
|
|
3692
3972
|
// own terminal lifecycle frame — exact under command coalescing, where
|
|
3693
3973
|
// N queued commands fold into ONE turn emitting one result and a plain
|
|
@@ -3874,6 +4154,15 @@ export class ClaudeAcpAgent {
|
|
|
3874
4154
|
}
|
|
3875
4155
|
session.queryClosed = true;
|
|
3876
4156
|
session.consumer = undefined;
|
|
4157
|
+
// Every teardown path reaches this method, which is why the quota timer is
|
|
4158
|
+
// cleared here and nowhere else: a `clearInterval` repeated at each of the
|
|
4159
|
+
// four call sites is one merge away from missing the fifth. A session whose
|
|
4160
|
+
// stream is closed cannot answer the usage control request anyway, so a
|
|
4161
|
+
// surviving timer would only log a failure a minute, forever.
|
|
4162
|
+
if (session.accountUsageTimer) {
|
|
4163
|
+
clearInterval(session.accountUsageTimer);
|
|
4164
|
+
session.accountUsageTimer = undefined;
|
|
4165
|
+
}
|
|
3877
4166
|
session.settingsManager.dispose();
|
|
3878
4167
|
session.input.end();
|
|
3879
4168
|
session.query.close();
|
|
@@ -4619,6 +4908,14 @@ export class ClaudeAcpAgent {
|
|
|
4619
4908
|
// same way it does for any unsupported level.
|
|
4620
4909
|
available: isUltracodeAvailable(newModelInfo?.supportsEffort ? (newModelInfo.supportedEffortLevels ?? []) : [], { disableWorkflows: session.workflowsDisabled }),
|
|
4621
4910
|
enabled: session.ultracode,
|
|
4911
|
+
}, {
|
|
4912
|
+
disableWorkflows: session.workflowsDisabled,
|
|
4913
|
+
// Threaded for the same reason Thinking is (R1.7): an option not
|
|
4914
|
+
// passed through this rebuild silently drops from the picker on
|
|
4915
|
+
// every model switch. The account is model-independent, so the row
|
|
4916
|
+
// and its selection survive unchanged.
|
|
4917
|
+
accounts: session.declaredAccounts,
|
|
4918
|
+
currentAccount: session.currentAccount,
|
|
4622
4919
|
});
|
|
4623
4920
|
// Sync effort with the SDK if it changed after the model switch
|
|
4624
4921
|
const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
|
|
@@ -4649,6 +4946,26 @@ export class ClaudeAcpAgent {
|
|
|
4649
4946
|
session.currentAgent = value;
|
|
4650
4947
|
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
4651
4948
|
}
|
|
4949
|
+
else if (configId === ACCOUNT_CONFIG_ID) {
|
|
4950
|
+
// Resolve BEFORE recording anything: an account nobody declared must
|
|
4951
|
+
// leave the session exactly as it was. `setSessionConfigOption`'s shared
|
|
4952
|
+
// validation already refuses a value that is not among the option's
|
|
4953
|
+
// entries; this second check covers the one path that bypasses it (a
|
|
4954
|
+
// client round-tripping `currentValue`) and keeps the refusal here
|
|
4955
|
+
// rather than in a caller (R6.3).
|
|
4956
|
+
const account = accountForValue(session.declaredAccounts ?? [], value);
|
|
4957
|
+
if (!account) {
|
|
4958
|
+
throw new Error(`Invalid value for config option ${configId}: ${value}`);
|
|
4959
|
+
}
|
|
4960
|
+
// No SDK call, and no query recreate: the account is applied through the
|
|
4961
|
+
// per-session `env` at query CREATION (D2/D9 — the SDK already accepts
|
|
4962
|
+
// one, so no Zed patch is involved), and this session's query keeps the
|
|
4963
|
+
// account it was created under (D7).
|
|
4964
|
+
this.selectedAccount = value;
|
|
4965
|
+
session.currentAccount = value;
|
|
4966
|
+
session.accountEnv = envForAccount(account);
|
|
4967
|
+
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
4968
|
+
}
|
|
4652
4969
|
else {
|
|
4653
4970
|
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
4654
4971
|
if (configId === EFFORT_CONFIG_ID) {
|
|
@@ -5247,9 +5564,28 @@ export class ClaudeAcpAgent {
|
|
|
5247
5564
|
env: { ...baseSettings?.env, ...providerEnv },
|
|
5248
5565
|
};
|
|
5249
5566
|
}
|
|
5567
|
+
// The account this session runs under (R6.3): the selector's last choice
|
|
5568
|
+
// when it names a declared account, else the first declared one. Read from
|
|
5569
|
+
// the resolved settings, because accounts are DECLARED, never discovered
|
|
5570
|
+
// (D4) — nothing here scans a home directory for credential stores. With
|
|
5571
|
+
// none declared there is no overlay and the process's own configuration
|
|
5572
|
+
// home stands, which is exactly the behavior that predates this option.
|
|
5573
|
+
const declaredAccounts = readDeclaredAccounts(settingsManager.getSettings(), this.logger);
|
|
5574
|
+
const accountEntry = accountInForce(declaredAccounts, this.selectedAccount);
|
|
5575
|
+
// The SESSION's account home, never the ADAPTER's (D3): this overlay is
|
|
5576
|
+
// handed to the SDK per session and to nothing else. `process.env` is not
|
|
5577
|
+
// mutated — `CLAUDE_CONFIG_DIR` is read once into a module-level constant
|
|
5578
|
+
// that `settings.ts` uses to find the adapter's OWN settings.json, so a
|
|
5579
|
+
// mutation would be seen by some readers and not others.
|
|
5580
|
+
const accountEnv = accountEntry ? envForAccount(accountEntry.account) : undefined;
|
|
5250
5581
|
const env = {
|
|
5251
5582
|
...process.env,
|
|
5252
5583
|
...userProvidedOptions?.env,
|
|
5584
|
+
// After the client-supplied env: the selector is a live user choice,
|
|
5585
|
+
// and an agent entry that pinned CLAUDE_CONFIG_DIR is precisely the
|
|
5586
|
+
// "account as a second agent" shape this option replaces (D1). Before
|
|
5587
|
+
// the provider routing, which sets ANTHROPIC_* and never this key.
|
|
5588
|
+
...accountEnv,
|
|
5253
5589
|
// Client-managed LLM routing: `providers/set` config wins, else the
|
|
5254
5590
|
// legacy gateway auth request. Routing is baked into the query at
|
|
5255
5591
|
// creation; provider updates recreate loaded queries between turns.
|
|
@@ -5533,6 +5869,13 @@ export class ClaudeAcpAgent {
|
|
|
5533
5869
|
// `xhigh` on its own is a legitimate selection that is not Ultracode.
|
|
5534
5870
|
available: isUltracodeAvailable(currentModelInfo?.supportsEffort ? (currentModelInfo.supportedEffortLevels ?? []) : [], { disableWorkflows: workflowsDisabled }),
|
|
5535
5871
|
enabled: settingsManager.getSettings().ultracode === true,
|
|
5872
|
+
}, {
|
|
5873
|
+
disableWorkflows: workflowsDisabled,
|
|
5874
|
+
// Both halves of the selector, resolved above alongside the env they
|
|
5875
|
+
// produced — so the row the client renders and the configuration
|
|
5876
|
+
// directory the query was actually started with cannot disagree.
|
|
5877
|
+
accounts: declaredAccounts,
|
|
5878
|
+
currentAccount: accountEntry?.value,
|
|
5536
5879
|
});
|
|
5537
5880
|
// Apply the initial effort level to the SDK so it matches the UI default
|
|
5538
5881
|
const initialEffort = configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
|
|
@@ -5611,6 +5954,9 @@ export class ClaudeAcpAgent {
|
|
|
5611
5954
|
currentAgent,
|
|
5612
5955
|
ultracode: initialUltracode,
|
|
5613
5956
|
workflowsDisabled,
|
|
5957
|
+
declaredAccounts,
|
|
5958
|
+
currentAccount: accountEntry?.value,
|
|
5959
|
+
accountEnv,
|
|
5614
5960
|
fastModeEnabled,
|
|
5615
5961
|
fastModeDisabledReason,
|
|
5616
5962
|
abortController,
|
|
@@ -5980,8 +6326,12 @@ thinkingEnabled,
|
|
|
5980
6326
|
* direct callers/tests, in which case availability is derived from
|
|
5981
6327
|
* `settings` and the entry renders unselected. */
|
|
5982
6328
|
ultracode,
|
|
5983
|
-
/** Resolved session settings, read
|
|
5984
|
-
*
|
|
6329
|
+
/** Resolved session settings, read for `disableWorkflows` — the half of the
|
|
6330
|
+
* Ultracode gate that is not a model capability — and, alongside them, the
|
|
6331
|
+
* declared accounts plus the one in force, which decide whether an account
|
|
6332
|
+
* selector is advertised at all (R6.1/R6.2). `accounts` is not a field of
|
|
6333
|
+
* the SDK's `Settings`, so it is carried beside them rather than picked
|
|
6334
|
+
* from them. */
|
|
5985
6335
|
settings) {
|
|
5986
6336
|
const options = [
|
|
5987
6337
|
SessionModeManager.configOption(modes),
|
|
@@ -6087,6 +6437,15 @@ settings) {
|
|
|
6087
6437
|
],
|
|
6088
6438
|
});
|
|
6089
6439
|
}
|
|
6440
|
+
// The account selector, last because it is the least often touched of the
|
|
6441
|
+
// rows and the only one that governs the NEXT thread rather than this one.
|
|
6442
|
+
// `createAccountConfigOption` returns undefined below two declared accounts,
|
|
6443
|
+
// so the row is genuinely absent there rather than present and empty (D8,
|
|
6444
|
+
// R6.2).
|
|
6445
|
+
const accountOption = createAccountConfigOption(settings?.accounts ?? [], settings?.currentAccount);
|
|
6446
|
+
if (accountOption) {
|
|
6447
|
+
options.push(accountOption);
|
|
6448
|
+
}
|
|
6090
6449
|
return options;
|
|
6091
6450
|
}
|
|
6092
6451
|
// Claude Code CLI persists display strings like "opus[1m]" in settings,
|