@lucascouts/claude-agent-acp-plus 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/acp-agent.js CHANGED
@@ -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");
@@ -481,6 +484,14 @@ export class ClaudeAcpAgent {
481
484
  * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
482
485
  * tests can shrink it. */
483
486
  forceCancelGraceMs = DEFAULT_FORCE_CANCEL_GRACE_MS;
487
+ /** The account selector's last choice, as a selector value (see
488
+ * `accounts.ts`). Held on the AGENT, not on a session, because that is what
489
+ * it governs: the account applies to sessions created after it is set, and
490
+ * the thread that was on screen when the user chose keeps the account it
491
+ * started under (D7 — a session id belongs to one account's local history,
492
+ * so a live thread cannot be carried across a switch). `undefined` until a
493
+ * selection is made, in which case the first declared account stands. */
494
+ selectedAccount;
484
495
  constructor(client, logger) {
485
496
  this.sessions = {};
486
497
  this.client = client;
@@ -1007,6 +1018,12 @@ export class ClaudeAcpAgent {
1007
1018
  session.fileChangeReportRequestIds.add(fileChangeReportRequestId);
1008
1019
  fileChangeAudit = createFileChangeAuditTurnState(fileChangeReportRequestId);
1009
1020
  }
1021
+ // R2.3: exactly `/usage`, and nothing else in the prompt. A second block
1022
+ // (an attached file, a second text part) is a model turn that happens to
1023
+ // mention the command, not the local command itself.
1024
+ const isUsageCommand = params.prompt.length === 1 &&
1025
+ params.prompt[0]?.type === "text" &&
1026
+ isUsageCommandText(params.prompt[0].text);
1010
1027
  session.titles.onPrompt(params.prompt);
1011
1028
  // Each prompt is a Turn whose deferred the persistent consumer settles once
1012
1029
  // the turn's outcome is known. `prompt()` owns no loop: it enqueues the
@@ -1015,6 +1032,9 @@ export class ClaudeAcpAgent {
1015
1032
  const turn = {
1016
1033
  promptUuid,
1017
1034
  isLocalOnlyCommand,
1035
+ ...(isUsageCommand
1036
+ ? { isUsageCommand: true, usageMarkdownAbort: new AbortController() }
1037
+ : {}),
1018
1038
  ...(fileChangeAudit ? { fileChangeAudit } : {}),
1019
1039
  settled: false,
1020
1040
  resolve: () => { },
@@ -1310,11 +1330,6 @@ export class ClaudeAcpAgent {
1310
1330
  // stop_reason "refusal" and structured stop_details. We capture the
1311
1331
  // human-readable explanation so the terminal `result` can surface it.
1312
1332
  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
1333
  // Anthropic API message id of the assistant message currently being
1319
1334
  // streamed, captured from `message_start` so the streamed chunks that follow
1320
1335
  // (whose delta events don't carry it) can all be tagged with the same,
@@ -1349,6 +1364,12 @@ export class ClaudeAcpAgent {
1349
1364
  * recognizable by the `parentToolUseId` meta that toAcpNotifications
1350
1365
  * stamps from `parent_tool_use_id`, and never reach the top-level feed
1351
1366
  * as the turn's answer. */
1367
+ /** Compaction as one ACP tool lifecycle, replacing the in-progress boolean
1368
+ * this consumer used to infer it from (story 010, R2.1/R2.2). Declared
1369
+ * ahead of `sendUpdate` because that chokepoint consults it; the closure
1370
+ * below only dereferences `sendUpdate` when an update is actually sent,
1371
+ * which is always after both bindings exist. */
1372
+ const compaction = new ContextCompactionLifecycle((notification) => sendUpdate(notification));
1352
1373
  const sendUpdate = async (notification) => {
1353
1374
  const { update } = notification;
1354
1375
  if (isFileChangeAuditReportPhase(session.activeTurn?.fileChangeAudit) &&
@@ -1361,6 +1382,14 @@ export class ClaudeAcpAgent {
1361
1382
  }
1362
1383
  if (update.sessionUpdate === "agent_message_chunk") {
1363
1384
  const claudeMeta = update._meta?.claudeCode;
1385
+ // A failed manual compaction's error also arrives as assistant text;
1386
+ // the tool lifecycle already carried it, so drop that one copy rather
1387
+ // than report the same failure twice (R2.2).
1388
+ if (!claudeMeta?.parentToolUseId &&
1389
+ update.content.type === "text" &&
1390
+ compaction.consumeDuplicateErrorOutput(update.content.text)) {
1391
+ return;
1392
+ }
1364
1393
  if (!claudeMeta?.parentToolUseId) {
1365
1394
  session.emittedAssistantText = true;
1366
1395
  session.titles.onAssistantText(update.content);
@@ -1413,7 +1442,6 @@ export class ClaudeAcpAgent {
1413
1442
  lastAssistantWasUsageLimit = false;
1414
1443
  lastAssistantFailureTitle = undefined;
1415
1444
  lastRefusalExplanation = null;
1416
- compactionInProgress = false;
1417
1445
  // Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
1418
1446
  // activation can fire mid-message (the replayed user echo with
1419
1447
  // --replay-user-messages lands between a message's blocks); clearing the
@@ -1432,6 +1460,22 @@ export class ClaudeAcpAgent {
1432
1460
  if (session.activeTurn)
1433
1461
  session.activeTurn.carriedUsage = undefined;
1434
1462
  };
1463
+ /** Start this turn's structured `/usage` render, once, and hand back the
1464
+ * in-flight promise; undefined when the turn owns no render.
1465
+ *
1466
+ * Started at ACTIVATION rather than when the output arrives, so the
1467
+ * bounded wait overlaps the command instead of following it: by the time
1468
+ * the CLI has printed its answer the report has usually already lost or
1469
+ * won its race, and the user waits for neither. */
1470
+ const ensureUsageMarkdown = (turn) => {
1471
+ if (!turn.isUsageCommand || !turn.usageMarkdownAbort) {
1472
+ return undefined;
1473
+ }
1474
+ // `session.query` is read here rather than captured: a provider switch
1475
+ // can replace it, and the render must ask the query that is live now.
1476
+ turn.usageMarkdown ??= structuredUsageMarkdown(session.query, turn.usageMarkdownAbort.signal, this.logger);
1477
+ return turn.usageMarkdown;
1478
+ };
1435
1479
  /** Promote a queued turn to active: it becomes the one output is attributed
1436
1480
  * to, and its scratch starts fresh. Clears the cancelled flag so a turn
1437
1481
  * enqueued after a prior cancel isn't treated as cancelled. Also clears any
@@ -1443,6 +1487,7 @@ export class ClaudeAcpAgent {
1443
1487
  const activateTurn = (turn) => {
1444
1488
  session.activeTurn = turn;
1445
1489
  session.cancelled = false;
1490
+ ensureUsageMarkdown(turn);
1446
1491
  session.pendingOrphanResults = 0;
1447
1492
  session.orphanCommands?.clear();
1448
1493
  // Two-phase sweep of registry entries the level signal ended (see
@@ -1481,12 +1526,20 @@ export class ClaudeAcpAgent {
1481
1526
  *
1482
1527
  * But an echo-less result can also be an ORPHAN: cancel() settles+removes a
1483
1528
  * queued turn whose user message was already pushed, so the SDK still runs
1484
- * it and emits a result with no uuid to match. Promoting the head for an
1529
+ * it and emits a result with no echo to match. Promoting the head for an
1485
1530
  * orphan would misattribute its stop reason/usage to an unrelated later
1486
1531
  * prompt. `session.pendingOrphanResults` counts exactly how many such
1487
1532
  * 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
- const ensureActiveTurn = () => {
1533
+ * result), so we skip those and only promote once the count is drained.
1534
+ *
1535
+ * `resultUserMessageUuid` is the result's own join key (SDK 0.3.246+
1536
+ * echoes the triggering send's client uuid on results; absent on older
1537
+ * CLIs, synthetic/meta turns, and session-scoped failures). When present
1538
+ * it upgrades the map lane from positional heuristics to an exact match:
1539
+ * a stamp naming an orphaned command consumes the result outright, and a
1540
+ * stamp naming anything else positively refutes "this is a dead turn's
1541
+ * result", so the dup-over-loss one-skip must not eat it. */
1542
+ const ensureActiveTurn = (resultUserMessageUuid) => {
1490
1543
  if (session.activeTurn) {
1491
1544
  if (!isHeldOpen(session.activeTurn)) {
1492
1545
  return;
@@ -1538,8 +1591,19 @@ export class ClaudeAcpAgent {
1538
1591
  // double-consume it. The unexpected-transition logging in the frame
1539
1592
  // handler is the tripwire for that class of drift.
1540
1593
  if (session.orphanCommands?.size) {
1594
+ // Resolved BEFORE the drain below, which deletes every started/zombie
1595
+ // entry: a stamp naming a "started" orphan would no longer be found
1596
+ // afterwards, and the result would promote the head — misattributing
1597
+ // a dead turn's outcome to a live prompt, the exact failure the map
1598
+ // exists to prevent. The lookup order is load-bearing.
1599
+ const stampedOrphanUuid = resultUserMessageUuid !== undefined && session.orphanCommands.has(resultUserMessageUuid)
1600
+ ? resultUserMessageUuid
1601
+ : undefined;
1541
1602
  let consumedOrphanResult = false;
1542
1603
  let oldestPending;
1604
+ // The started/zombie drain applies regardless of the stamp: commands
1605
+ // folded into the turn that emitted this result share it, and zombies'
1606
+ // late results have already passed (or never existed).
1543
1607
  for (const [uuid, state] of session.orphanCommands) {
1544
1608
  if (state === "started" || state === "zombie") {
1545
1609
  consumedOrphanResult = true;
@@ -1549,17 +1613,33 @@ export class ClaudeAcpAgent {
1549
1613
  oldestPending ??= uuid;
1550
1614
  }
1551
1615
  }
1552
- if (consumedOrphanResult) {
1616
+ if (stampedOrphanUuid !== undefined) {
1617
+ // Exact join: the result names an orphaned command. Delete the
1618
+ // matched entry even when it is still "pending" (its dispatch frame
1619
+ // was lost) and consume the result — no promotion.
1620
+ session.orphanCommands.delete(stampedOrphanUuid);
1553
1621
  return;
1554
1622
  }
1555
- if (oldestPending !== undefined) {
1556
- // No dispatch was seen before this result, so it is very likely a
1557
- // live turn's — but a lost "started" frame would mean it IS the
1558
- // orphan's (dup-over-loss: prefer one wrong skip over
1559
- // misattributing a dead turn's outcome to a live prompt). Grant
1560
- // each pending entry exactly one skip, like the count lane did.
1561
- session.orphanCommands.delete(oldestPending);
1562
- return;
1623
+ if (resultUserMessageUuid !== undefined) {
1624
+ // The stamp names a send that is NOT in the orphan map, so this is
1625
+ // a live turn's result: skip both the consumed-return (its folded
1626
+ // orphans were drained above, but the result itself still needs a
1627
+ // turn) and the dup-over-loss one-skip the stamp refutes, and fall
1628
+ // through to promote the head.
1629
+ }
1630
+ else {
1631
+ if (consumedOrphanResult) {
1632
+ return;
1633
+ }
1634
+ if (oldestPending !== undefined) {
1635
+ // No dispatch was seen before this result, so it is very likely a
1636
+ // live turn's — but a lost "started" frame would mean it IS the
1637
+ // orphan's (dup-over-loss: prefer one wrong skip over
1638
+ // misattributing a dead turn's outcome to a live prompt). Grant
1639
+ // each pending entry exactly one skip, like the count lane did.
1640
+ session.orphanCommands.delete(oldestPending);
1641
+ return;
1642
+ }
1563
1643
  }
1564
1644
  }
1565
1645
  const head = firstUnsettledQueuedTurn();
@@ -1600,6 +1680,49 @@ export class ClaudeAcpAgent {
1600
1680
  * spelling of "a prompt is pending" shared by the head promotion and
1601
1681
  * the autonomous stretch-close guard. */
1602
1682
  const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled);
1683
+ /** Claim the structured render for whichever turn is producing this local
1684
+ * command output. Three answers, and the caller must distinguish all
1685
+ * three:
1686
+ *
1687
+ * undefined — not a `/usage` turn, or one of R2.4's three ways out
1688
+ * fired: publish `originalOutput` unchanged
1689
+ * string — the render: publish it INSTEAD of `originalOutput`
1690
+ * null — publish nothing (a duplicate of an already-replaced
1691
+ * frame, or a turn cancelled during the wait)
1692
+ *
1693
+ * No content signature and no text parsing: which turn owns a render was
1694
+ * decided from the prompt, so a command whose output happens to look like
1695
+ * `/usage`'s can never be rewritten. */
1696
+ const takeUsageMarkdown = async (originalOutput) => {
1697
+ const turn = session.activeTurn ?? firstUnsettledQueuedTurn();
1698
+ if (!turn) {
1699
+ return undefined;
1700
+ }
1701
+ const pending = ensureUsageMarkdown(turn);
1702
+ if (!pending) {
1703
+ return undefined;
1704
+ }
1705
+ const markdown = await pending;
1706
+ // That await can span the whole bounded wait, and a cancel landing inside
1707
+ // it ends the turn. A cancelled turn publishes nothing — not the render,
1708
+ // and not the original text either.
1709
+ if (session.cancelled) {
1710
+ return null;
1711
+ }
1712
+ if (markdown === null) {
1713
+ return undefined;
1714
+ }
1715
+ if (turn.usageMarkdownDelivered) {
1716
+ // One local command can reach the client through more than one SDK
1717
+ // message shape. Suppress an exact mirror of the frame already
1718
+ // replaced, but let a later, genuinely different frame (an
1719
+ // interruption diagnostic, say) take the normal path.
1720
+ return turn.usageOriginalOutput === originalOutput ? null : undefined;
1721
+ }
1722
+ turn.usageMarkdownDelivered = true;
1723
+ turn.usageOriginalOutput = originalOutput;
1724
+ return markdown;
1725
+ };
1603
1726
  /** Whether any background subagent this turn spawned is still live —
1604
1727
  * while true, the turn's settlement stays deferred so the subagent's
1605
1728
  * output and permission requests land inside it (see
@@ -1668,6 +1791,9 @@ export class ClaudeAcpAgent {
1668
1791
  // Captured before the settled flip below (isHeldOpen tests !settled).
1669
1792
  const wasHeld = isHeldOpen(turn);
1670
1793
  turn.settled = true;
1794
+ // The turn is over: abandon any structured `/usage` render still in
1795
+ // flight rather than leaving it to run out its own clock (D5).
1796
+ turn.usageMarkdownAbort?.abort();
1671
1797
  disarmForceCancel(session);
1672
1798
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
1673
1799
  session.activeTurn = null;
@@ -1700,6 +1826,7 @@ export class ClaudeAcpAgent {
1700
1826
  }
1701
1827
  this.finishFileChangeAudit(session, turn, "providerError");
1702
1828
  turn.settled = true;
1829
+ turn.usageMarkdownAbort?.abort();
1703
1830
  session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn);
1704
1831
  session.activeTurn = null;
1705
1832
  streamedToolInputs.clear();
@@ -1748,6 +1875,7 @@ export class ClaudeAcpAgent {
1748
1875
  this.finishFileChangeAudit(session, turn, "providerError");
1749
1876
  const wasHeld = isHeldOpen(turn);
1750
1877
  turn.settled = true;
1878
+ turn.usageMarkdownAbort?.abort();
1751
1879
  if (wasHeld) {
1752
1880
  // A held turn's answer already streamed and its outcome is
1753
1881
  // recorded — a stream death during the post-answer hold is a
@@ -2073,50 +2201,43 @@ export class ClaudeAcpAgent {
2073
2201
  }
2074
2202
  break;
2075
2203
  case "status": {
2076
- // These banners count as delivered text (via sendUpdate), so
2077
- // an echo-less turn that only ever emits them (e.g. `/compact`,
2078
- // promoted at its own result) doesn't have its result text
2079
- // re-emitted by the issue-#453 fallback.
2204
+ // Compaction is reported as ONE tool lifecycle rather than the
2205
+ // assistant-text banners this branch used to emit (R2.1). The
2206
+ // banners needed a boolean in-progress guard because the SDK
2207
+ // repeats the terminal `status` and the two copies are
2208
+ // indistinguishable here; the lifecycle owns that de-duplication
2209
+ // by phase instead, so the guard is gone rather than inert (R2.2).
2210
+ //
2211
+ // The SDK signals manual `/compact` completion with a status
2212
+ // message carrying `compact_result`, not the `compact_boundary`
2213
+ // message (which only fires when there's content to compact) —
2214
+ // so both frames must be able to close a lifecycle.
2080
2215
  if (message.status === "compacting") {
2081
- compactionInProgress = true;
2082
- await sendUpdate({
2083
- sessionId: message.session_id,
2084
- update: {
2085
- sessionUpdate: "agent_message_chunk",
2086
- content: { type: "text", text: "Compacting..." },
2087
- },
2088
- });
2216
+ await compaction.start(message.session_id, message.uuid);
2089
2217
  }
2090
- else if (message.compact_result === "success" && compactionInProgress) {
2091
- // The SDK signals manual `/compact` completion with a status
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
- });
2218
+ else if (message.compact_result === "success") {
2219
+ await compaction.finish(message.session_id, message.uuid, "completed");
2102
2220
  }
2103
- else if (message.compact_result === "failed" && compactionInProgress) {
2104
- compactionInProgress = false;
2105
- const reason = message.compact_error ? `: ${message.compact_error}` : ".";
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
- },
2221
+ else if (message.compact_result === "failed") {
2222
+ await compaction.finish(message.session_id, message.uuid, "failed", {
2223
+ ...(message.compact_error ? { error: message.compact_error } : {}),
2112
2224
  });
2113
2225
  }
2114
2226
  break;
2115
2227
  }
2116
2228
  case "compact_boundary": {
2229
+ // This is the only frame carrying the token counts, and it
2230
+ // arrives AFTER the terminal `status` — so it enriches the
2231
+ // lifecycle rather than reporting an outcome again
2232
+ // (`enrichTerminal`): the update omits `status`, leaving exactly
2233
+ // one terminal transition the client can see (R2.2). It also
2234
+ // stands alone when no `status` preceded it (an automatic
2235
+ // compaction, or a replay that dropped the opening frame).
2236
+ const compactMetadata = message.compact_metadata;
2237
+ await compaction.finish(message.session_id, message.uuid, "completed", compactMetadata ? contextCompactionMetadataFromBoundary(compactMetadata) : {}, true);
2117
2238
  // Refresh the displayed usage immediately so the client doesn't
2118
2239
  // keep showing the stale pre-compaction size (e.g. "944k/1m")
2119
- // right after the user sees "Compacting completed", which is
2240
+ // right after the compaction tool call completes, which is
2120
2241
  // confusing and wrong.
2121
2242
  //
2122
2243
  // Prefer the SDK's authoritative post-compaction `used` via
@@ -2131,10 +2252,6 @@ export class ClaudeAcpAgent {
2131
2252
  // `size` keeps coming from session.contextWindowSize —
2132
2253
  // compaction frees occupancy, it doesn't change the model's
2133
2254
  // 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
2255
  const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
2139
2256
  lastAssistantUsage = null;
2140
2257
  lastAssistantTotalUsage = usedTokens ?? 0;
@@ -2150,11 +2267,24 @@ export class ClaudeAcpAgent {
2150
2267
  break;
2151
2268
  }
2152
2269
  case "local_command_output": {
2270
+ // A failed `/compact` also prints its error here; the tool
2271
+ // lifecycle already carried it, so consume that one duplicate
2272
+ // (matched by text, once) without hiding other command output.
2273
+ if (compaction.consumeDuplicateErrorOutput(message.content)) {
2274
+ break;
2275
+ }
2276
+ // R2.3/R2.4: a `/usage` turn publishes its structured render
2277
+ // here instead; anything else, and any way out, publishes the
2278
+ // CLI's own text byte-for-byte.
2279
+ const usageMarkdown = await takeUsageMarkdown(message.content);
2280
+ if (usageMarkdown === null) {
2281
+ break;
2282
+ }
2153
2283
  await sendUpdate({
2154
2284
  sessionId: message.session_id,
2155
2285
  update: {
2156
2286
  sessionUpdate: "agent_message_chunk",
2157
- content: { type: "text", text: message.content },
2287
+ content: { type: "text", text: usageMarkdown ?? message.content },
2158
2288
  },
2159
2289
  });
2160
2290
  break;
@@ -2192,6 +2322,10 @@ export class ClaudeAcpAgent {
2192
2322
  // the interrupted turn's tokens entirely (issue #844). Zero
2193
2323
  // when the cancel pre-empted the result (wedge/force-cancel).
2194
2324
  if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
2325
+ // An interrupt can pre-empt the result entirely, so the
2326
+ // lifecycle's own reset there never ran; close it here or a
2327
+ // half-open compaction would leak into the next turn.
2328
+ compaction.reset();
2195
2329
  settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
2196
2330
  // An interrupt can pre-empt the turn's result entirely
2197
2331
  // (nothing ran the result-case `finally`), so close the
@@ -2254,6 +2388,9 @@ export class ClaudeAcpAgent {
2254
2388
  else if (!session.cancelled &&
2255
2389
  session.activeTurn &&
2256
2390
  !session.activeTurn.settled) {
2391
+ // Same reason as the cancelled branch: this turn will never
2392
+ // reach the result that would have reset the lifecycle.
2393
+ compaction.reset();
2257
2394
  // Deliberately only the ACTIVE turn: a queued turn that
2258
2395
  // was never echoed is NOT failed here, because an idle
2259
2396
  // can legitimately precede the SDK picking up freshly
@@ -2669,7 +2806,7 @@ export class ClaudeAcpAgent {
2669
2806
  // the map in that case).
2670
2807
  if (!isAutonomousResult) {
2671
2808
  recordResultForOrphanCommands();
2672
- ensureActiveTurn();
2809
+ ensureActiveTurn(message.user_message_uuid);
2673
2810
  // Once the submitted goal command has produced its own result,
2674
2811
  // no older runtime update can still precede it in the ordered
2675
2812
  // SDK stream. Stop suppressing updates even when this runtime
@@ -2700,6 +2837,12 @@ export class ClaudeAcpAgent {
2700
2837
  // through the early break below, which the gated `finally`
2701
2838
  // leaves alone).
2702
2839
  const deliveredAssistantText = session.emittedAssistantText;
2840
+ // The deleted "Compacting…" banners counted as delivered text
2841
+ // simply by going through sendUpdate; tool calls don't, so a turn
2842
+ // that ONLY compacted (e.g. `/compact`, promoted at its own
2843
+ // result) needs this to keep its result text from being re-emitted
2844
+ // by the issue-#453 fallback below.
2845
+ const deliveredCompactionOutput = compaction.hasDeliveredOutput;
2703
2846
  // Every user-turn result terminates a turn (settle, reject, or
2704
2847
  // orphan skip) and the SDK follows it with a trailing
2705
2848
  // `session_state_changed: idle` — record the debt so the idle
@@ -2946,9 +3089,19 @@ export class ClaudeAcpAgent {
2946
3089
  // the fallback there. (Autonomous results never get here —
2947
3090
  // they exit at the early break above — so no background
2948
3091
  // prose can be injected into the feed.)
2949
- if (session.activeTurn?.isLocalOnlyCommand ||
2950
- (!deliveredAssistantText && (message.usage.output_tokens ?? 0) === 0)) {
2951
- for (const notification of toAcpNotifications(message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
3092
+ const shouldForwardResult = session.activeTurn?.isLocalOnlyCommand ||
3093
+ (!deliveredAssistantText &&
3094
+ !deliveredCompactionOutput &&
3095
+ (message.usage.output_tokens ?? 0) === 0);
3096
+ if (shouldForwardResult) {
3097
+ // A `/usage` turn whose output arrives only on the result.
3098
+ // Claiming it here also stops the raw text following a
3099
+ // render already published from another message shape.
3100
+ const usageMarkdown = await takeUsageMarkdown(message.result);
3101
+ if (usageMarkdown === null) {
3102
+ break;
3103
+ }
3104
+ for (const notification of toAcpNotifications(usageMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, this.client, this.logger)) {
2952
3105
  await sendUpdate(notification);
2953
3106
  }
2954
3107
  }
@@ -3023,6 +3176,11 @@ export class ClaudeAcpAgent {
3023
3176
  finally {
3024
3177
  if (!isAutonomousResult) {
3025
3178
  session.emittedAssistantText = false;
3179
+ // A result closes this turn's compaction lifecycle. Reset here
3180
+ // rather than at idle: an owed idle from this turn can arrive
3181
+ // after the next turn has already started, and would erase that
3182
+ // turn's compaction state instead of its own.
3183
+ compaction.reset();
3026
3184
  // R1.2: a user turn's terminal result is the moment consumption
3027
3185
  // actually changed, so refresh the account quota windows here —
3028
3186
  // in the `finally`, which is the one point EVERY exit from this
@@ -3043,6 +3201,17 @@ export class ClaudeAcpAgent {
3043
3201
  break;
3044
3202
  }
3045
3203
  case "stream_event": {
3204
+ // Compaction streams as its own block type. The deltas carry the
3205
+ // generated summary, which stays internal to the agent — all the
3206
+ // client gets is one keep-alive on the open tool call, so a long
3207
+ // compaction doesn't look stalled.
3208
+ const isCompactionProgress = (message.event.type === "content_block_start" &&
3209
+ message.event.content_block.type === "compaction") ||
3210
+ (message.event.type === "content_block_delta" &&
3211
+ message.event.delta.type === "compaction_delta");
3212
+ if (isCompactionProgress) {
3213
+ await compaction.heartbeat(message.session_id, message.uuid);
3214
+ }
3046
3215
  // `message_start` carries the Anthropic API message id; capture it
3047
3216
  // so the streamed chunks that follow (whose delta events don't carry
3048
3217
  // it) can all be tagged with the same, replay-stable id.
@@ -3265,6 +3434,18 @@ export class ClaudeAcpAgent {
3265
3434
  if (session.cancelled) {
3266
3435
  break;
3267
3436
  }
3437
+ // Synthetic assistant frames carry the CLI's local-command output.
3438
+ // On resume the SDK can replay a stale frame from an earlier compact
3439
+ // attempt after a later compaction completed. Scope the suppression
3440
+ // to the compaction lifecycle and the synthetic frame itself rather
3441
+ // than to the owning turn: one model turn may compact more than
3442
+ // once, and its real assistant response must still be delivered.
3443
+ if (message.type === "assistant" &&
3444
+ message.parent_tool_use_id === null &&
3445
+ message.message.model === "<synthetic>" &&
3446
+ compaction.hasDeliveredOutput) {
3447
+ break;
3448
+ }
3268
3449
  // Snapshot the latest top-level assistant usage and model so the
3269
3450
  // next `result` can emit a usage_update tied to the right context
3270
3451
  // window. Subagent messages are excluded to keep the snapshot
@@ -3303,7 +3484,13 @@ export class ClaudeAcpAgent {
3303
3484
  message.message.content.includes("<local-command-stdout>")) {
3304
3485
  const stripped = stripLocalCommandMetadata(message.message.content);
3305
3486
  if (typeof stripped === "string") {
3306
- for (const notification of toAcpNotifications(stripped, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
3487
+ // The usual shape for a real `/usage`: the comment above names
3488
+ // it as one of the commands the CLI wraps in these markers.
3489
+ const usageMarkdown = await takeUsageMarkdown(stripped);
3490
+ if (usageMarkdown === null) {
3491
+ break;
3492
+ }
3493
+ for (const notification of toAcpNotifications(usageMarkdown ?? stripped, message.message.role, params.sessionId, session.toolUseCache, this.client, this.logger, {
3307
3494
  clientCapabilities: this.clientCapabilities,
3308
3495
  parentToolUseId: message.parent_tool_use_id,
3309
3496
  cwd: session.cwd,
@@ -3644,6 +3831,11 @@ export class ClaudeAcpAgent {
3644
3831
  await session.queryRecreateInFlight;
3645
3832
  }
3646
3833
  session.cancelled = true;
3834
+ // Every turn's structured `/usage` render is abandoned here — the queue
3835
+ // still holds the active turn at this point, so one sweep covers both.
3836
+ for (const turn of session.turnQueue ?? []) {
3837
+ turn.usageMarkdownAbort?.abort();
3838
+ }
3647
3839
  session.pendingExitPlanModeInterruption = undefined;
3648
3840
  session.pendingExitPlanContextReset = undefined;
3649
3841
  // The stream already ended (see closeQueryStream): every in-flight turn was
@@ -3686,8 +3878,10 @@ export class ClaudeAcpAgent {
3686
3878
  }
3687
3879
  // Each removed queued turn's user message was already pushed to the SDK,
3688
3880
  // which processes input FIFO and will still emit a result for it with no
3689
- // uuid to match. Track those so the consumer skips them (see
3690
- // ensureActiveTurn) rather than misattributing them to the head.
3881
+ // user echo to match (0.3.246+ CLIs do stamp results with the
3882
+ // triggering send's user_message_uuid, which ensureActiveTurn uses as
3883
+ // an exact join when present). Track those so the consumer skips them
3884
+ // (see ensureActiveTurn) rather than misattributing them to the head.
3691
3885
  // msg_lifecycle_v1 CLIs get per-uuid tracking drained by the command's
3692
3886
  // own terminal lifecycle frame — exact under command coalescing, where
3693
3887
  // N queued commands fold into ONE turn emitting one result and a plain
@@ -4619,6 +4813,14 @@ export class ClaudeAcpAgent {
4619
4813
  // same way it does for any unsupported level.
4620
4814
  available: isUltracodeAvailable(newModelInfo?.supportsEffort ? (newModelInfo.supportedEffortLevels ?? []) : [], { disableWorkflows: session.workflowsDisabled }),
4621
4815
  enabled: session.ultracode,
4816
+ }, {
4817
+ disableWorkflows: session.workflowsDisabled,
4818
+ // Threaded for the same reason Thinking is (R1.7): an option not
4819
+ // passed through this rebuild silently drops from the picker on
4820
+ // every model switch. The account is model-independent, so the row
4821
+ // and its selection survive unchanged.
4822
+ accounts: session.declaredAccounts,
4823
+ currentAccount: session.currentAccount,
4622
4824
  });
4623
4825
  // Sync effort with the SDK if it changed after the model switch
4624
4826
  const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
@@ -4649,6 +4851,26 @@ export class ClaudeAcpAgent {
4649
4851
  session.currentAgent = value;
4650
4852
  session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
4651
4853
  }
4854
+ else if (configId === ACCOUNT_CONFIG_ID) {
4855
+ // Resolve BEFORE recording anything: an account nobody declared must
4856
+ // leave the session exactly as it was. `setSessionConfigOption`'s shared
4857
+ // validation already refuses a value that is not among the option's
4858
+ // entries; this second check covers the one path that bypasses it (a
4859
+ // client round-tripping `currentValue`) and keeps the refusal here
4860
+ // rather than in a caller (R6.3).
4861
+ const account = accountForValue(session.declaredAccounts ?? [], value);
4862
+ if (!account) {
4863
+ throw new Error(`Invalid value for config option ${configId}: ${value}`);
4864
+ }
4865
+ // No SDK call, and no query recreate: the account is applied through the
4866
+ // per-session `env` at query CREATION (D2/D9 — the SDK already accepts
4867
+ // one, so no Zed patch is involved), and this session's query keeps the
4868
+ // account it was created under (D7).
4869
+ this.selectedAccount = value;
4870
+ session.currentAccount = value;
4871
+ session.accountEnv = envForAccount(account);
4872
+ session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
4873
+ }
4652
4874
  else {
4653
4875
  session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
4654
4876
  if (configId === EFFORT_CONFIG_ID) {
@@ -5247,9 +5469,28 @@ export class ClaudeAcpAgent {
5247
5469
  env: { ...baseSettings?.env, ...providerEnv },
5248
5470
  };
5249
5471
  }
5472
+ // The account this session runs under (R6.3): the selector's last choice
5473
+ // when it names a declared account, else the first declared one. Read from
5474
+ // the resolved settings, because accounts are DECLARED, never discovered
5475
+ // (D4) — nothing here scans a home directory for credential stores. With
5476
+ // none declared there is no overlay and the process's own configuration
5477
+ // home stands, which is exactly the behavior that predates this option.
5478
+ const declaredAccounts = readDeclaredAccounts(settingsManager.getSettings(), this.logger);
5479
+ const accountEntry = accountInForce(declaredAccounts, this.selectedAccount);
5480
+ // The SESSION's account home, never the ADAPTER's (D3): this overlay is
5481
+ // handed to the SDK per session and to nothing else. `process.env` is not
5482
+ // mutated — `CLAUDE_CONFIG_DIR` is read once into a module-level constant
5483
+ // that `settings.ts` uses to find the adapter's OWN settings.json, so a
5484
+ // mutation would be seen by some readers and not others.
5485
+ const accountEnv = accountEntry ? envForAccount(accountEntry.account) : undefined;
5250
5486
  const env = {
5251
5487
  ...process.env,
5252
5488
  ...userProvidedOptions?.env,
5489
+ // After the client-supplied env: the selector is a live user choice,
5490
+ // and an agent entry that pinned CLAUDE_CONFIG_DIR is precisely the
5491
+ // "account as a second agent" shape this option replaces (D1). Before
5492
+ // the provider routing, which sets ANTHROPIC_* and never this key.
5493
+ ...accountEnv,
5253
5494
  // Client-managed LLM routing: `providers/set` config wins, else the
5254
5495
  // legacy gateway auth request. Routing is baked into the query at
5255
5496
  // creation; provider updates recreate loaded queries between turns.
@@ -5533,6 +5774,13 @@ export class ClaudeAcpAgent {
5533
5774
  // `xhigh` on its own is a legitimate selection that is not Ultracode.
5534
5775
  available: isUltracodeAvailable(currentModelInfo?.supportsEffort ? (currentModelInfo.supportedEffortLevels ?? []) : [], { disableWorkflows: workflowsDisabled }),
5535
5776
  enabled: settingsManager.getSettings().ultracode === true,
5777
+ }, {
5778
+ disableWorkflows: workflowsDisabled,
5779
+ // Both halves of the selector, resolved above alongside the env they
5780
+ // produced — so the row the client renders and the configuration
5781
+ // directory the query was actually started with cannot disagree.
5782
+ accounts: declaredAccounts,
5783
+ currentAccount: accountEntry?.value,
5536
5784
  });
5537
5785
  // Apply the initial effort level to the SDK so it matches the UI default
5538
5786
  const initialEffort = configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
@@ -5611,6 +5859,9 @@ export class ClaudeAcpAgent {
5611
5859
  currentAgent,
5612
5860
  ultracode: initialUltracode,
5613
5861
  workflowsDisabled,
5862
+ declaredAccounts,
5863
+ currentAccount: accountEntry?.value,
5864
+ accountEnv,
5614
5865
  fastModeEnabled,
5615
5866
  fastModeDisabledReason,
5616
5867
  abortController,
@@ -5980,8 +6231,12 @@ thinkingEnabled,
5980
6231
  * direct callers/tests, in which case availability is derived from
5981
6232
  * `settings` and the entry renders unselected. */
5982
6233
  ultracode,
5983
- /** Resolved session settings, read only for `disableWorkflows` — the half of
5984
- * the Ultracode gate that is not a model capability. */
6234
+ /** Resolved session settings, read for `disableWorkflows` — the half of the
6235
+ * Ultracode gate that is not a model capability — and, alongside them, the
6236
+ * declared accounts plus the one in force, which decide whether an account
6237
+ * selector is advertised at all (R6.1/R6.2). `accounts` is not a field of
6238
+ * the SDK's `Settings`, so it is carried beside them rather than picked
6239
+ * from them. */
5985
6240
  settings) {
5986
6241
  const options = [
5987
6242
  SessionModeManager.configOption(modes),
@@ -6087,6 +6342,15 @@ settings) {
6087
6342
  ],
6088
6343
  });
6089
6344
  }
6345
+ // The account selector, last because it is the least often touched of the
6346
+ // rows and the only one that governs the NEXT thread rather than this one.
6347
+ // `createAccountConfigOption` returns undefined below two declared accounts,
6348
+ // so the row is genuinely absent there rather than present and empty (D8,
6349
+ // R6.2).
6350
+ const accountOption = createAccountConfigOption(settings?.accounts ?? [], settings?.currentAccount);
6351
+ if (accountOption) {
6352
+ options.push(accountOption);
6353
+ }
6090
6354
  return options;
6091
6355
  }
6092
6356
  // Claude Code CLI persists display strings like "opus[1m]" in settings,