@lucascouts/claude-agent-acp-plus 0.26.0 → 0.28.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
@@ -19,7 +19,7 @@ import { filterSupersededModels } from "./model-recency.js";
19
19
  import { SettingsManager } from "./settings.js";
20
20
  import { ContextCompactionLifecycle, contextCompactionMetadataFromBoundary, } from "./context-compaction.js";
21
21
  import { createThinkingConfigOption, effectiveThinkingConfig, resolveThinkingSelection, THINKING_CONFIG_ID, } from "./thinking-option.js";
22
- import { effortOptionValue, isUltracodeAvailable, isUltracodeValue, ULTRACODE_OPTION_NAME, ULTRACODE_OPTION_VALUE, ultracodeFlagSettings, } from "./ultracode.js";
22
+ import { applyLegacyUltracodeEffort, applyUltracodeReadback, applyUltracodeToggle, effortFlagSettings, isLegacyUltracodeEffort, isUltracodeAvailable, readUltracodeState, resolveUltracodeRequested, ULTRACODE_CONFIG_ID, ultracodeConfigOption, } from "./ultracode.js";
23
23
  import { handleRewindCommand, parseRewindInvocation } from "./rewind-command.js";
24
24
  import { resolvePermissionMode, sessionAllowsBypass } from "./permissions/modes.js";
25
25
  import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js";
@@ -152,6 +152,28 @@ export const TASKS_OUTPUT_METHOD = "_claude/tasks/output";
152
152
  export const TASKS_CAPABILITY_KEY = TASKS_META_KEY;
153
153
  /** Version 2 (story 041) adds `hasOutput`, `tail` and `_claude/tasks/output`. */
154
154
  export const TASKS_CAPABILITY_VERSION = TASKS_CONTRACT_VERSION;
155
+ /** Refresh the account quota windows of one session now, on demand. Params
156
+ * {@link UsageRefreshRequest}, result {@link UsageRefreshResponse}. The windows
157
+ * themselves arrive the way they always do — one `usage_update` per window,
158
+ * carrying `_meta["_claude/rateLimit"]` — before the response does. */
159
+ export const USAGE_REFRESH_METHOD = "_claude/usage/refresh";
160
+ /** `agentCapabilities._meta` key advertising {@link USAGE_REFRESH_METHOD}. A
161
+ * client gates on this, never on the agent's name (see
162
+ * {@link TASKS_CAPABILITY_KEY}). */
163
+ export const USAGE_CAPABILITY_KEY = "_claude/usage";
164
+ export const USAGE_CAPABILITY_VERSION = 1;
165
+ /** How old a shared quota sample may be and still answer an on-demand refresh.
166
+ *
167
+ * The client asks on every hover of the context ring, so this is the knob that
168
+ * trades freshness against cost. Five seconds is shorter than any change a
169
+ * person reading a tooltip could tell apart — neither the five-hour nor the
170
+ * weekly window moves measurably in that time — yet long enough that a burst
171
+ * of hovers, or several Zeo windows hovered in turn, costs one control request
172
+ * between them rather than one each. Much shorter than the idle poll's
173
+ * {@link sharedSampleMaxAgeMs}: a hover is someone asking "what is it NOW?",
174
+ * and a sample most of an interval old is exactly what the poll already
175
+ * showed them. */
176
+ export const USAGE_REFRESH_MAX_SAMPLE_AGE_MS = 5_000;
155
177
  /** The SDK's own text for a rejected control request, passed through to the
156
178
  * client as the internal error's message (R2.4). */
157
179
  function errorMessage(error) {
@@ -195,6 +217,10 @@ export function parseTasksBackgroundRequest(params) {
195
217
  toolCallId: requireIdField(params, TASKS_BACKGROUND_METHOD, "toolCallId"),
196
218
  };
197
219
  }
220
+ /** Validate raw JSON-RPC params into a {@link UsageRefreshRequest}. */
221
+ export function parseUsageRefreshRequest(params) {
222
+ return { sessionId: requireIdField(params, USAGE_REFRESH_METHOD, "sessionId") };
223
+ }
198
224
  /** How often the account quota windows are refreshed while a session sits idle,
199
225
  * in milliseconds; `0` disables polling entirely.
200
226
  *
@@ -204,10 +230,17 @@ export function parseTasksBackgroundRequest(params) {
204
230
  * the old utilization, and the bar is wrong in the one direction that matters
205
231
  * (it under-reports how much is available) until the next turn ends.
206
232
  *
233
+ * Thirty seconds by default (sixty until the quota tooltip): the tooltip over
234
+ * the context ring shows these windows as numbers, so a reading up to a minute
235
+ * old was visible as one. The cost does not double with it — every adapter
236
+ * process on the desktop shares one sample per interval (quota-cache.ts), so
237
+ * it is still about one control request per interval per account. Hovering
238
+ * the ring asks for a fresher one on demand (`_claude/usage/refresh`).
239
+ *
207
240
  * The floor is not timidity: each tick costs one control request, and neither
208
241
  * the five-hour nor the seven-day window can move fast enough for a tighter
209
242
  * loop to show anything a 15-second one would miss. */
210
- const QUOTA_POLL_DEFAULT_MS = 60_000;
243
+ const QUOTA_POLL_DEFAULT_MS = 30_000;
211
244
  const QUOTA_POLL_FLOOR_MS = 15_000;
212
245
  /** Whether sessions ask the SDK for progress summaries on subagent tasks
213
246
  * (`CLAUDE_ACP_PROGRESS_SUMMARIES`). Only `1` or `true` turn it on; anything
@@ -251,7 +284,11 @@ export function quotaPollIntervalMs(raw = process.env.CLAUDE_ACP_QUOTA_POLL_MS)
251
284
  * (the result is skipped, its trailing idle absorbed as owed, so the
252
285
  * #825 detector can't fire); misrouting an autonomous result into the
253
286
  * user lane is the bounded misattribution class this set exists to
254
- * reduce. */
287
+ * reduce.
288
+ *
289
+ * Exception: a result from one of these origins that names a pending prompt
290
+ * in `user_message_uuids` consumed that folded prompt and takes the user
291
+ * lane (see the result handler). */
255
292
  const AUTONOMOUS_RESULT_ORIGINS = new Set([
256
293
  "task-notification",
257
294
  "peer",
@@ -872,6 +909,7 @@ export class ClaudeAcpAgent {
872
909
  promptQueueing: true,
873
910
  },
874
911
  [TASKS_CAPABILITY_KEY]: { version: TASKS_CAPABILITY_VERSION },
912
+ [USAGE_CAPABILITY_KEY]: { version: USAGE_CAPABILITY_VERSION },
875
913
  },
876
914
  promptCapabilities: {
877
915
  image: true,
@@ -1350,14 +1388,19 @@ export class ClaudeAcpAgent {
1350
1388
  */
1351
1389
  /** Fetch the account's quota windows and publish them to this session.
1352
1390
  *
1353
- * `allowCache` is the difference between the two callers. A turn ending and a
1391
+ * `cacheMaxAgeMs` is the difference between the callers. A turn ending and a
1354
1392
  * session being established are moments when THIS session's own consumption
1355
- * just changed, so they always fetch. The idle poll has no such reason: it is
1356
- * asking about an account, and every other adapter process on the desktop is
1357
- * asking the same question about the same account. It reads the shared sample
1358
- * instead -- see quota-cache.ts for why sharing, not tighter intervals, is
1359
- * what makes separate windows agree. */
1360
- async publishAccountUsage(sessionId, session, { allowCache = false } = {}) {
1393
+ * just changed, so they pass none and always fetch. The idle poll and the
1394
+ * on-demand refresh have no such reason: they are asking about an account,
1395
+ * and every other adapter process on the desktop is asking the same question
1396
+ * about the same account. They accept a shared sample up to that age instead
1397
+ * -- see quota-cache.ts for why sharing, not tighter intervals, is what makes
1398
+ * separate windows agree.
1399
+ *
1400
+ * Resolves `true` when at least one window was published, `false` when none
1401
+ * was: no report, a report with no plan windows (an API-key session), or a
1402
+ * session replaced while the request was in flight. Never rejects. */
1403
+ async publishAccountUsage(sessionId, session, { cacheMaxAgeMs } = {}) {
1361
1404
  try {
1362
1405
  // Not `getUsage`: this awful spelling is the only name the real `Query`
1363
1406
  // answers to, and it is written out here rather than hidden behind a
@@ -1371,9 +1414,7 @@ export class ClaudeAcpAgent {
1371
1414
  // A sample another process already paid for, when this call is allowed to
1372
1415
  // take one. `null` covers every failure the cache can have, so a broken
1373
1416
  // cache degrades to exactly the behaviour that existed before it.
1374
- const cached = allowCache
1375
- ? await readFreshLimits(sharedSampleMaxAgeMs(quotaPollIntervalMs()))
1376
- : null;
1417
+ const cached = cacheMaxAgeMs !== undefined ? await readFreshLimits(cacheMaxAgeMs) : null;
1377
1418
  const report = cached ??
1378
1419
  (await session.query.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({
1379
1420
  skipBehaviors: true,
@@ -1388,7 +1429,7 @@ export class ClaudeAcpAgent {
1388
1429
  // flight (provider switch, a changed cwd on session/load). A successor
1389
1430
  // must not inherit its predecessor's windows.
1390
1431
  if (this.sessions[sessionId] !== session) {
1391
- return;
1432
+ return false;
1392
1433
  }
1393
1434
  // `usage_update` is the only carrier ACP gives this `_meta`, and it
1394
1435
  // requires both numbers. `contextUsedTokens` is the session-scoped mirror
@@ -1404,6 +1445,7 @@ export class ClaudeAcpAgent {
1404
1445
  // single report's bars disagree about the context they were measured in.
1405
1446
  const used = session.contextUsedTokens ?? 0;
1406
1447
  const size = session.contextWindowSize;
1448
+ let published = false;
1407
1449
  for (const window of this.accountUsageTracker(session).windowsFrom(report)) {
1408
1450
  await this.client.sessionUpdate({
1409
1451
  sessionId,
@@ -1414,10 +1456,13 @@ export class ClaudeAcpAgent {
1414
1456
  _meta: { "_claude/rateLimit": window },
1415
1457
  },
1416
1458
  });
1459
+ published = true;
1417
1460
  }
1461
+ return published;
1418
1462
  }
1419
1463
  catch (error) {
1420
1464
  this.logger.error(`Session ${sessionId}: structured usage report unavailable: ${error}`);
1465
+ return false;
1421
1466
  }
1422
1467
  }
1423
1468
  /** Start the idle refresh of the account quota windows for this session.
@@ -1438,6 +1483,7 @@ export class ClaudeAcpAgent {
1438
1483
  if (intervalMs === 0) {
1439
1484
  return;
1440
1485
  }
1486
+ const cacheMaxAgeMs = sharedSampleMaxAgeMs(intervalMs);
1441
1487
  // Closure-local rather than a Session field: it guards this timer alone, and
1442
1488
  // a slow report must not let ticks pile up into a queue of duplicate control
1443
1489
  // requests against a session that is already struggling to answer one.
@@ -1456,7 +1502,7 @@ export class ClaudeAcpAgent {
1456
1502
  inFlight = true;
1457
1503
  // `publishAccountUsage` never rejects — it logs and returns — so this
1458
1504
  // cannot become an unhandled rejection, and `finally` always re-opens.
1459
- void this.publishAccountUsage(sessionId, session, { allowCache: true }).finally(() => {
1505
+ void this.publishAccountUsage(sessionId, session, { cacheMaxAgeMs }).finally(() => {
1460
1506
  inFlight = false;
1461
1507
  });
1462
1508
  }, intervalMs);
@@ -1464,6 +1510,70 @@ export class ClaudeAcpAgent {
1464
1510
  timer.unref?.();
1465
1511
  session.accountUsageTimer = timer;
1466
1512
  }
1513
+ /**
1514
+ * `_claude/usage/refresh`: publish this session's quota windows now, on
1515
+ * demand, and answer whether any were published.
1516
+ *
1517
+ * The client asks on every hover of the context ring, so the cost is bounded
1518
+ * twice. A shared sample younger than {@link USAGE_REFRESH_MAX_SAMPLE_AGE_MS}
1519
+ * answers without a control request, and a refresh already in flight for this
1520
+ * session is joined rather than repeated — however fast the hovers come, one
1521
+ * session never has two of these requests queued.
1522
+ *
1523
+ * Skipped while a turn is in flight, answering `{ refreshed: false }`. The
1524
+ * report is read-only, but SDK control requests are SERIALIZED: an interrupt
1525
+ * queued behind a usage request waits for it (upstream #1216 measured
1526
+ * 0.7-1.1 s for the same queueing behind `getContextUsage`), and `cancel`
1527
+ * awaits its interrupt. A hover during a turn is the common case — people
1528
+ * watch the ring fill — so allowing it would put a round-trip in front of the
1529
+ * Stop button exactly when it is most likely to be pressed. Nothing is lost:
1530
+ * the turn's own end refreshes the windows (R1.2), and a live
1531
+ * `rate_limit_event` still lands mid-turn.
1532
+ *
1533
+ * Before the first turn a control request may not be serviced at all on older
1534
+ * CLIs (#886/#880); such a call simply waits, joined by every later hover,
1535
+ * alongside the establishment refresh that is already waiting the same way.
1536
+ *
1537
+ * Never rejects for a missing report: a fetch failure is logged by
1538
+ * `publishAccountUsage` and answers `false`, as does an API-key session,
1539
+ * which has no plan windows to publish. Only an unknown session is an error,
1540
+ * the same invalid-params error the `_claude/tasks` methods answer.
1541
+ */
1542
+ async refreshUsage(params) {
1543
+ const { sessionId } = params;
1544
+ const log = (outcome, reason) => this.logger.log(`[usage/refresh] method=${USAGE_REFRESH_METHOD} sessionId=${logValue(sessionId)} outcome=${outcome}` +
1545
+ (reason ? ` reason=${reason}` : ""));
1546
+ const session = Object.hasOwn(this.sessions, sessionId) ? this.sessions[sessionId] : undefined;
1547
+ if (!session) {
1548
+ log("error", "unknown_session");
1549
+ throw RequestError.invalidParams(undefined, `unknown session ${sessionId}`);
1550
+ }
1551
+ if (session.queryClosed) {
1552
+ log("skipped", "session_ended");
1553
+ return { refreshed: false };
1554
+ }
1555
+ let coalesced = true;
1556
+ let pending = session.usageRefreshInFlight;
1557
+ if (!pending) {
1558
+ if (session.turnQueue?.length || session.activeTurn) {
1559
+ log("skipped", "turn_in_flight");
1560
+ return { refreshed: false };
1561
+ }
1562
+ coalesced = false;
1563
+ pending = this.publishAccountUsage(sessionId, session, {
1564
+ cacheMaxAgeMs: USAGE_REFRESH_MAX_SAMPLE_AGE_MS,
1565
+ }).finally(() => {
1566
+ if (session.usageRefreshInFlight === pending) {
1567
+ session.usageRefreshInFlight = undefined;
1568
+ }
1569
+ });
1570
+ session.usageRefreshInFlight = pending;
1571
+ }
1572
+ // `publishAccountUsage` never rejects, so neither does this await.
1573
+ const refreshed = await pending;
1574
+ log(refreshed ? "published" : "nothing", coalesced ? "coalesced" : undefined);
1575
+ return { refreshed };
1576
+ }
1467
1577
  async publishGoalFromPrompt(sessionId, prompt, commandUuid) {
1468
1578
  const goalUpdate = goalUpdateFromPrompt(prompt);
1469
1579
  if (goalUpdate !== undefined) {
@@ -2115,6 +2225,40 @@ export class ClaudeAcpAgent {
2115
2225
  };
2116
2226
  /** The unsettled in-flight turn owning this prompt uuid, if any. */
2117
2227
  const findUnsettledTurn = (uuid) => (session.turnQueue ?? []).find((t) => t.promptUuid === uuid && !t.settled);
2228
+ /** Whether this result is the answer to a prompt that is still waiting.
2229
+ *
2230
+ * Claude Code can start a turn on its own, for example when a background
2231
+ * task finishes. If the user sends a prompt while that turn runs, Claude
2232
+ * Code adds the prompt to the running turn. The turn's result is still
2233
+ * marked as a background result, but it lists the uuids of the prompts it
2234
+ * answered.
2235
+ *
2236
+ * A turn held open for its background subagents already has its result,
2237
+ * so a result that names it does not answer it. */
2238
+ const answersPendingPrompt = (message) => {
2239
+ // Newer CLIs list every prompt the turn answered. Older ones name only
2240
+ // the last one.
2241
+ let answeredPromptUuids = [];
2242
+ if (Array.isArray(message.user_message_uuids)) {
2243
+ answeredPromptUuids = message.user_message_uuids;
2244
+ }
2245
+ else if (typeof message.user_message_uuid === "string") {
2246
+ answeredPromptUuids = [message.user_message_uuid];
2247
+ }
2248
+ for (const promptUuid of answeredPromptUuids) {
2249
+ const turn = findUnsettledTurn(promptUuid);
2250
+ if (turn === undefined) {
2251
+ // Not a prompt of this session, or it was answered already.
2252
+ continue;
2253
+ }
2254
+ if (isHeldOpen(turn)) {
2255
+ // This turn already has its result and only waits for its subagents.
2256
+ continue;
2257
+ }
2258
+ return true;
2259
+ }
2260
+ return false;
2261
+ };
2118
2262
  /** The first queued turn still awaiting its outcome, if any — the single
2119
2263
  * spelling of "a prompt is pending" shared by the head promotion and
2120
2264
  * the autonomous stretch-close guard. */
@@ -3403,7 +3547,10 @@ export class ClaudeAcpAgent {
3403
3547
  // the user's prompt's. Autonomous results must never touch the
3404
3548
  // user-turn lifecycle (stop reason, settles, failActive,
3405
3549
  // slash-command output forwarding), though their cost is real.
3406
- const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
3550
+ // The exception: the user's prompt was added to that turn while it
3551
+ // ran, so its result answers the prompt (see answersPendingPrompt).
3552
+ const startedByClaudeCode = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind);
3553
+ const isAutonomousResult = startedByClaudeCode && !answersPendingPrompt(message);
3407
3554
  const pendingExitPlanModeInterruption = session.pendingExitPlanModeInterruption;
3408
3555
  const pendingExitPlanContextReset = session.pendingExitPlanContextReset;
3409
3556
  try {
@@ -4962,6 +5109,18 @@ export class ClaudeAcpAgent {
4962
5109
  await this.applyFastMode(session, resolveFastModeEnabled(params));
4963
5110
  return { configOptions: session.configOptions };
4964
5111
  }
5112
+ // Ultracode is a boolean too, and bypasses the same validation. The legacy
5113
+ // `effort = "ultracode"` (the retired notch) is accepted for one release.
5114
+ const useBoolean = clientSupportsBooleanConfigOptions(this.clientCapabilities);
5115
+ if (params.configId === ULTRACODE_CONFIG_ID) {
5116
+ await applyUltracodeToggle(session, resolveUltracodeRequested(params), useBoolean);
5117
+ return { configOptions: session.configOptions };
5118
+ }
5119
+ if (params.configId === EFFORT_CONFIG_ID &&
5120
+ isLegacyUltracodeEffort(params.value) &&
5121
+ (await applyLegacyUltracodeEffort(session, useBoolean))) {
5122
+ return { configOptions: session.configOptions };
5123
+ }
4965
5124
  // The Thinking toggle only records the session's intent — the SDK query
4966
5125
  // swap is deferred to the next prompt() start (lazy recreate, sub-task
4967
5126
  // 1.3), so there is no SDK call to fail here and the config option is
@@ -5647,7 +5806,10 @@ export class ClaudeAcpAgent {
5647
5806
  if (session.fastModeDisabledReason === "model_not_allowed") {
5648
5807
  session.fastModeDisabledReason = undefined;
5649
5808
  }
5650
- // Rebuild config options since effort levels depend on the selected model
5809
+ // Rebuild config options since effort levels depend on the selected model.
5810
+ // The new model decides whether Ultracode is shown (heuristic now, the
5811
+ // CLI's readback below); the retained intent decides its value.
5812
+ session.ultracodeAvailable = isUltracodeAvailable(newModelInfo?.supportsEffort ? (newModelInfo.supportedEffortLevels ?? []) : [], { disableWorkflows: session.workflowsDisabled });
5651
5813
  const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
5652
5814
  const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
5653
5815
  session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort, session.agents, session.currentAgent, {
@@ -5666,15 +5828,7 @@ export class ClaudeAcpAgent {
5666
5828
  // (R1.6).
5667
5829
  session.thinkingEnabled ??
5668
5830
  effectiveThinkingConfig(undefined, process.env.MAX_THINKING_TOKENS, this.logger) !==
5669
- undefined, {
5670
- // The new model decides whether the entry renders at all; the session's
5671
- // retained intent decides whether it renders selected. A model without
5672
- // `xhigh` drops the entry, and `validEffort` then falls back for it the
5673
- // same way it does for any unsupported level.
5674
- available: isUltracodeAvailable(newModelInfo?.supportsEffort ? (newModelInfo.supportedEffortLevels ?? []) : [], { disableWorkflows: session.workflowsDisabled }),
5675
- enabled: session.ultracode,
5676
- }, {
5677
- disableWorkflows: session.workflowsDisabled,
5831
+ undefined, this.ultracodeState(session, session.ultracodeAvailable), {
5678
5832
  // Threaded for the same reason Thinking is (R1.7): an option not
5679
5833
  // passed through this rebuild silently drops from the picker on
5680
5834
  // every model switch. The account is model-independent, so the row
@@ -5686,8 +5840,7 @@ export class ClaudeAcpAgent {
5686
5840
  const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
5687
5841
  const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
5688
5842
  if (newEffort !== currentEffort) {
5689
- await session.query.applyFlagSettings(ultracodeFlagSettings(newEffort));
5690
- session.ultracode = isUltracodeValue(newEffort);
5843
+ await session.query.applyFlagSettings(effortFlagSettings(newEffort, session.ultracodeRequested));
5691
5844
  }
5692
5845
  // Emit current_mode_update only after session.modes AND
5693
5846
  // session.configOptions have been fully reconciled. This way, a failure
@@ -5699,8 +5852,10 @@ export class ClaudeAcpAgent {
5699
5852
  await this.sessionModes.publishFallbackState(sessionId, session);
5700
5853
  }
5701
5854
  // Last, so the switch's own control requests don't queue behind it.
5702
- if (modelChanged)
5855
+ if (modelChanged) {
5703
5856
  this.refreshContextWindowInBackground(sessionId, session);
5857
+ this.refreshUltracodeInBackground(sessionId, session, false);
5858
+ }
5704
5859
  }
5705
5860
  else if (configId === AGENT_CONFIG_ID) {
5706
5861
  // Live agent switch — no subprocess restart needed. Apply the SDK flag
@@ -5737,11 +5892,9 @@ export class ClaudeAcpAgent {
5737
5892
  else {
5738
5893
  session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
5739
5894
  if (configId === EFFORT_CONFIG_ID) {
5740
- // Both keys, always. Selecting a plain level after Ultracode must CLEAR
5741
- // the flag, and only an explicit `null` clears it — see
5742
- // `ultracodeFlagSettings`.
5743
- await session.query.applyFlagSettings(ultracodeFlagSettings(value));
5744
- session.ultracode = isUltracodeValue(value);
5895
+ // An effort change without `ultracode` turns it off, so the intent
5896
+ // rides along — see `effortFlagSettings`.
5897
+ await session.query.applyFlagSettings(effortFlagSettings(value, session.ultracodeRequested));
5745
5898
  }
5746
5899
  }
5747
5900
  }
@@ -5794,6 +5947,50 @@ export class ClaudeAcpAgent {
5794
5947
  session.fastModeEnabled = enabled;
5795
5948
  this.refreshFastModeOption(session, enabled);
5796
5949
  }
5950
+ /** The Ultracode display state for `buildConfigOptions`. */
5951
+ ultracodeState(session, available) {
5952
+ return {
5953
+ available,
5954
+ requested: session.ultracodeRequested,
5955
+ useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
5956
+ };
5957
+ }
5958
+ /**
5959
+ * Replace the Ultracode heuristic with the CLI's `applied` report, without
5960
+ * blocking the caller — never inline, because control requests queue ahead
5961
+ * of interrupt and close (upstream #1216). `seedIntent` at session start; a
5962
+ * model switch keeps its intent and only re-reads availability. Deferred a
5963
+ * tick so a resulting `config_option_update` follows the session/new reply.
5964
+ */
5965
+ refreshUltracodeInBackground(sessionId, session, seedIntent) {
5966
+ const { query } = session;
5967
+ const modelId = session.models.currentModelId;
5968
+ const requestedAtStart = session.ultracodeRequested;
5969
+ const stillCurrent = () => this.sessions[sessionId] === session &&
5970
+ session.query === query &&
5971
+ session.models.currentModelId === modelId;
5972
+ setTimeout(() => {
5973
+ void readUltracodeState(query, (error) => {
5974
+ this.logger.log(`[ultracode] get_settings failed session=${sessionId}: ${String(error)}`);
5975
+ })
5976
+ .then(async (readback) => {
5977
+ if (!readback || !stillCurrent())
5978
+ return;
5979
+ // A toggle that landed while the read was in flight is newer than it.
5980
+ const seed = seedIntent && session.ultracodeRequested === requestedAtStart;
5981
+ const useBoolean = clientSupportsBooleanConfigOptions(this.clientCapabilities);
5982
+ if (!applyUltracodeReadback(session, readback, seed, useBoolean))
5983
+ return;
5984
+ await this.client.sessionUpdate({
5985
+ sessionId,
5986
+ update: { sessionUpdate: "config_option_update", configOptions: session.configOptions },
5987
+ });
5988
+ })
5989
+ .catch((error) => {
5990
+ this.logger.error(`[ultracode] readback failed session=${sessionId}:`, error);
5991
+ });
5992
+ }, 0);
5993
+ }
5797
5994
  /** Lazily swap a session's SDK query for a fresh one so a Thinking change
5798
5995
  * takes effect on the next turn (R1.3): thinking has no live flag-settings
5799
5996
  * path in the SDK, so mid-session application goes through query recreation
@@ -5915,14 +6112,16 @@ export class ClaudeAcpAgent {
5915
6112
  // await is already included.
5916
6113
  const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
5917
6114
  const effortValue = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
5918
- const { effortLevel, ultracode } = ultracodeFlagSettings(effortValue);
5919
- // Only the positive case is re-applied: the replacement's flag layer
6115
+ const effortFlags = effortFlagSettings(effortValue, session.ultracodeRequested);
6116
+ // Only the positive cases are re-applied: the replacement's flag layer
5920
6117
  // starts empty, so clearing a key that was never set would be a control
5921
- // request that says nothing. `ultracode` is sent WITH its level rather
5922
- // than alone — the SDK requires an xhigh-capable model for it, and a
5923
- // flag layer carrying one half of the pair is a state no picker names.
5924
- if (effortLevel !== null) {
5925
- await replacement.applyFlagSettings(ultracode === true ? { effortLevel, ultracode: true } : { effortLevel });
6118
+ // request that says nothing. The Ultracode intent is replayed even at
6119
+ // Default effort.
6120
+ if (effortFlags.effortLevel !== null) {
6121
+ await replacement.applyFlagSettings(effortFlags);
6122
+ }
6123
+ else if (effortFlags.ultracode) {
6124
+ await replacement.applyFlagSettings({ ultracode: true });
5926
6125
  }
5927
6126
  const supportsFastMode = session.modelInfos.find((m) => m.value === session.models.currentModelId)
5928
6127
  ?.supportsFastMode ?? false;
@@ -6704,6 +6903,7 @@ export class ClaudeAcpAgent {
6704
6903
  disabledReason: fastModeDisabledReason,
6705
6904
  };
6706
6905
  const workflowsDisabled = settingsManager.getSettings().disableWorkflows === true;
6906
+ const ultracodeRequested = settingsManager.getSettings().ultracode === true;
6707
6907
  const configOptions = buildConfigOptions(modes, models,
6708
6908
  // Catalog-based (see `modelInfos` above), matching the model-switch
6709
6909
  // rebuild (which passes `session.modelInfos`): `buildConfigOptions`
@@ -6717,31 +6917,24 @@ export class ClaudeAcpAgent {
6717
6917
  // effectiveThinkingConfig(undefined, MAX_THINKING_TOKENS) from above, so
6718
6918
  // reusing it avoids re-parsing (and re-logging an invalid) env var —
6719
6919
  // exactly one error log per query creation.
6720
- thinking !== undefined, {
6721
- // Seeded from settings, not inferred from the level: `Settings.ultracode`
6722
- // is what the user (or a `--settings` payload) actually asked for, while
6723
- // `xhigh` on its own is a legitimate selection that is not Ultracode.
6724
- available: isUltracodeAvailable(currentModelInfo?.supportsEffort ? (currentModelInfo.supportedEffortLevels ?? []) : [], { disableWorkflows: workflowsDisabled }),
6725
- enabled: settingsManager.getSettings().ultracode === true,
6726
- }, {
6727
- disableWorkflows: workflowsDisabled,
6920
+ thinking !== undefined,
6921
+ // Seeded from settings and the heuristic; the CLI's own report replaces
6922
+ // both in the background (`refreshUltracodeInBackground`, below).
6923
+ this.ultracodeState({ ultracodeRequested }, isUltracodeAvailable(currentModelInfo?.supportsEffort ? (currentModelInfo.supportedEffortLevels ?? []) : [], { disableWorkflows: workflowsDisabled })), {
6728
6924
  // Both halves of the selector, resolved above alongside the env they
6729
6925
  // produced — so the row the client renders and the configuration
6730
6926
  // directory the query was actually started with cannot disagree.
6731
6927
  accounts: declaredAccounts,
6732
6928
  currentAccount: accountEntry?.value,
6733
6929
  });
6734
- // Apply the initial effort level to the SDK so it matches the UI default
6930
+ // Apply the initial effort level to the SDK so it matches the UI default.
6931
+ // It carries the Ultracode intent: an effort change without the key would
6932
+ // turn off an `ultracode: true` the session was started with.
6735
6933
  const initialEffort = configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
6736
- // The picker's resolved value, not the raw setting: `buildConfigOptions`
6737
- // has already dropped Ultracode if this model cannot reach `xhigh`, and the
6738
- // session must track what is displayed rather than what was requested.
6739
- const initialUltracode = typeof initialEffort?.currentValue === "string" &&
6740
- isUltracodeValue(initialEffort.currentValue);
6741
6934
  if (initialEffort &&
6742
6935
  typeof initialEffort.currentValue === "string" &&
6743
6936
  initialEffort.currentValue !== "default") {
6744
- await q.applyFlagSettings(ultracodeFlagSettings(initialEffort.currentValue));
6937
+ await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, ultracodeRequested));
6745
6938
  }
6746
6939
  // Seed the context window WITHOUT any extra IPC on the session/new path.
6747
6940
  // On session/load, the resumed session's own `getContextUsage` report — a
@@ -6806,7 +6999,8 @@ export class ClaudeAcpAgent {
6806
6999
  configOptions,
6807
7000
  agents,
6808
7001
  currentAgent,
6809
- ultracode: initialUltracode,
7002
+ ultracodeRequested,
7003
+ ultracodeAvailable: configOptions.some((o) => o.id === ULTRACODE_CONFIG_ID),
6810
7004
  workflowsDisabled,
6811
7005
  declaredAccounts,
6812
7006
  currentAccount: accountEntry?.value,
@@ -6854,6 +7048,7 @@ export class ClaudeAcpAgent {
6854
7048
  // A guessed window seed (text heuristic / default) is refined the same way,
6855
7049
  // unawaited; a resumed session's authoritative seed makes it a no-op.
6856
7050
  this.refreshContextWindowInBackground(sessionId, this.sessions[sessionId]);
7051
+ this.refreshUltracodeInBackground(sessionId, this.sessions[sessionId], true);
6857
7052
  return {
6858
7053
  sessionId,
6859
7054
  modes,
@@ -7187,16 +7382,11 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
7187
7382
  * Both session call sites always supply it (R1.1); `undefined` (direct
7188
7383
  * callers/tests) omits the row. */
7189
7384
  thinkingEnabled,
7190
- /** Display state for the Ultracode entry of the effort picker. Omitted by
7191
- * direct callers/tests, in which case availability is derived from
7192
- * `settings` and the entry renders unselected. */
7385
+ /** Display state for the Ultracode option, placed after effort. Omitted by
7386
+ * direct callers/tests, in which case the option is not shown. */
7193
7387
  ultracode,
7194
- /** Resolved session settings, read for `disableWorkflows` — the half of the
7195
- * Ultracode gate that is not a model capability — and, alongside them, the
7196
- * declared accounts plus the one in force, which decide whether an account
7197
- * selector is advertised at all (R6.1/R6.2). `accounts` is not a field of
7198
- * the SDK's `Settings`, so it is carried beside them rather than picked
7199
- * from them. */
7388
+ /** The declared accounts plus the one in force, which decide whether an
7389
+ * account selector is advertised at all (R6.1/R6.2). */
7200
7390
  settings) {
7201
7391
  const options = [
7202
7392
  SessionModeManager.configOption(modes),
@@ -7230,11 +7420,6 @@ settings) {
7230
7420
  ? (currentModelInfo.supportedEffortLevels ?? [])
7231
7421
  : [];
7232
7422
  if (supportedLevels.length > 0) {
7233
- // Ultracode rides the effort picker as one extra entry past the last level,
7234
- // which is where the VS Code extension draws it too. It is NOT a level (see
7235
- // `ultracode.ts`): the entry stands for `xhigh` plus workflow orchestration,
7236
- // and it is offered only when both halves are reachable.
7237
- const ultracodeAvailable = ultracode?.available ?? isUltracodeAvailable(supportedLevels, settings);
7238
7423
  const effortOptions = [
7239
7424
  { value: "default", name: "Default" },
7240
7425
  ...supportedLevels.map((level) => ({
@@ -7244,21 +7429,9 @@ settings) {
7244
7429
  .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
7245
7430
  .join(" "),
7246
7431
  })),
7247
- ...(ultracodeAvailable
7248
- ? [{ value: ULTRACODE_OPTION_VALUE, name: ULTRACODE_OPTION_NAME }]
7249
- : []),
7250
7432
  ];
7251
- const includes = (l) => l === "default" ||
7252
- supportedLevels.includes(l) ||
7253
- (ultracodeAvailable && l === ULTRACODE_OPTION_VALUE);
7254
- // `effortOptionValue` resolves the flag against the level BEFORE validation,
7255
- // so an Ultracode session on a model that dropped `xhigh` falls back to
7256
- // "Default" through the same path any unsupported level does.
7257
- const displayedEffort = effortOptionValue(currentEffortLevel, {
7258
- available: ultracodeAvailable,
7259
- enabled: ultracode?.enabled ?? false,
7260
- });
7261
- const validEffort = displayedEffort && includes(displayedEffort) ? displayedEffort : "default";
7433
+ const includes = (l) => l === "default" || supportedLevels.includes(l);
7434
+ const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
7262
7435
  options.push({
7263
7436
  id: EFFORT_CONFIG_ID,
7264
7437
  name: "Effort",
@@ -7269,6 +7442,11 @@ settings) {
7269
7442
  options: effortOptions,
7270
7443
  });
7271
7444
  }
7445
+ // Ultracode: a toggle beside effort, not an effort level (`ultracode.ts`).
7446
+ const ultracodeOption = ultracodeConfigOption(ultracode);
7447
+ if (ultracodeOption) {
7448
+ options.push(ultracodeOption);
7449
+ }
7272
7450
  // Surface the Fast mode toggle only when the current model supports it. The
7273
7451
  // option renders as a native boolean toggle for Clients that opted in, and a
7274
7452
  // two-value select otherwise.
@@ -8724,6 +8902,7 @@ export function runAcp(logger) {
8724
8902
  .onRequest(TASKS_STOP_METHOD, { parse: parseTasksStopRequest }, (ctx) => agent.stopTask(ctx.params))
8725
8903
  .onRequest(TASKS_OUTPUT_METHOD, { parse: parseTasksOutputRequest }, (ctx) => agent.taskOutput(ctx.params))
8726
8904
  .onRequest(TASKS_BACKGROUND_METHOD, { parse: parseTasksBackgroundRequest }, (ctx) => agent.backgroundTask(ctx.params))
8905
+ .onRequest(USAGE_REFRESH_METHOD, { parse: parseUsageRefreshRequest }, (ctx) => agent.refreshUsage(ctx.params))
8727
8906
  .onRequest(GOAL_CONTROL_METHOD, { parse: parseGoalRequest }, (ctx) => agent.goal(ctx.params))
8728
8907
  .connect(stream);
8729
8908
  agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);