From a409242b8266eb7569223d68bec20e7898d42689 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:01:35 +0000 Subject: [PATCH 01/94] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20dedupe=20memor?= =?UTF-8?q?y=20sweep=20recordUsage=20callbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the byte-identical consolidation/harvest sweep `recordUsage` callbacks in MemoryConsolidationService into a shared `makeSweepUsageRecorder` helper. Behavior-preserving: same sidecar recording and analyticsIngest emit; only the workspaceId source and analyticsSource literal differ per call site. Auto-cleanup checkpoint: f7f0f02587c09fb86f97f704f3f24f5b3904260d --- .../services/memoryConsolidationService.ts | 86 ++++++++++--------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/src/node/services/memoryConsolidationService.ts b/src/node/services/memoryConsolidationService.ts index b536e2b8e5..d7755e53d2 100644 --- a/src/node/services/memoryConsolidationService.ts +++ b/src/node/services/memoryConsolidationService.ts @@ -17,6 +17,7 @@ import * as path from "node:path"; import writeFileAtomic from "write-file-atomic"; import { z } from "zod"; import type { LanguageModel } from "ai"; +import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { modelCostsIncluded } from "@/node/services/providerModelFactory"; import type { SessionUsageService } from "@/node/services/sessionUsageService"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; @@ -496,6 +497,39 @@ export class MemoryConsolidationService extends EventEmitter { }; } + /** + * Build the `recordUsage` callback shared by the consolidation and harvest + * sweeps. Both route the sweep's billed usage to the headless-usage sidecar + * and, when a row is recorded, request an ingest pass (forwarded by + * ServiceContainer) so sweep spend reaches dashboard totals promptly instead + * of stranding until an unrelated stream-end or restart. + */ + private makeSweepUsageRecorder( + workspaceId: string, + modelString: string, + created: { model: LanguageModel; metadataModel: string }, + analyticsSource: "memory_consolidation" | "memory_harvest" + ): (usage: LanguageModelV2Usage, providerMetadata?: Record) => Promise { + return async (usage, providerMetadata) => { + const recorded = await this.sessionUsageService?.recordHeadlessUsage( + workspaceId, + modelString, + usage, + providerMetadata, + { + costsIncluded: modelCostsIncluded(created.model), + analyticsSource, + // Creation-time identity: a catalog refresh mid-run must not + // re-attribute this spend (see ModelFactoryLike). + metadataModel: created.metadataModel, + } + ); + if (recorded) { + this.emit("analyticsIngest", { workspaceId }); + } + }; + } + /** * Workspace-removal drain (r60): abort every in-flight dream/harvest run * for this workspace and await them BOUNDED. The stream abort settles a @@ -664,27 +698,12 @@ export class MemoryConsolidationService extends EventEmitter { AbortSignal.timeout(MEMORY_CONSOLIDATION_TIMEOUT_MS), removalSignal, ]), - recordUsage: async (usage, providerMetadata) => { - const recorded = await this.sessionUsageService?.recordHeadlessUsage( - workspaceId, - modelString, - usage, - providerMetadata, - { - costsIncluded: modelCostsIncluded(modelResult.data.model), - analyticsSource: "memory_consolidation", - // Creation-time identity: a catalog refresh mid-run must not - // re-attribute this spend (see ModelFactoryLike). - metadataModel: modelResult.data.metadataModel, - } - ); - // The sidecar row only reaches dashboard totals via an explicit - // ingest pass; request one (forwarded by ServiceContainer) so sweep - // spend doesn't strand until an unrelated stream-end or restart. - if (recorded) { - this.emit("analyticsIngest", { workspaceId }); - } - }, + recordUsage: this.makeSweepUsageRecorder( + workspaceId, + modelString, + modelResult.data, + "memory_consolidation" + ), }); // A stream failure (provider error or the run timeout) means the pass did // NOT cover the memory state: skip the journal record so the debounce and @@ -821,25 +840,12 @@ export class MemoryConsolidationService extends EventEmitter { AbortSignal.timeout(MEMORY_CONSOLIDATION_TIMEOUT_MS), removalSignal, ]), - recordUsage: async (usage, providerMetadata) => { - const recorded = await this.sessionUsageService?.recordHeadlessUsage( - metadata.workspaceId, - modelString, - usage, - providerMetadata, - { - costsIncluded: modelCostsIncluded(modelResult.data.model), - analyticsSource: "memory_harvest", - // Creation-time identity (see ModelFactoryLike). - metadataModel: modelResult.data.metadataModel, - } - ); - // Same as consolidation above: request an ingest pass so harvest - // spend reaches dashboard totals promptly. - if (recorded) { - this.emit("analyticsIngest", { workspaceId: metadata.workspaceId }); - } - }, + recordUsage: this.makeSweepUsageRecorder( + metadata.workspaceId, + modelString, + modelResult.data, + "memory_harvest" + ), }); if (harvest.streamError !== undefined) { throw new Error(`harvest stream failed: ${harvest.streamError}`); From 14776de69078876ac016bd0f33e8d758d3764a0e Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:30:11 +0000 Subject: [PATCH 02/94] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20dedupe=20memor?= =?UTF-8?q?y=20scope-full=20cap=20check=20into=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/memoryService.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/node/services/memoryService.ts b/src/node/services/memoryService.ts index 5d8b8240d9..6534c466d1 100644 --- a/src/node/services/memoryService.ts +++ b/src/node/services/memoryService.ts @@ -716,6 +716,21 @@ export class MemoryService extends EventEmitter { return store; } + /** + * Enforce the per-scope file cap before creating a new file. Throws a + * MemoryCommandError with a uniform "scope is full" message when the store + * already holds MEMORY_MAX_FILES_PER_SCOPE files. Deduplicated from the + * `create` and `saveFile` (new-file) paths, which enforced this identically. + */ + private async assertScopeHasRoom(store: MemoryStore, scope: MemoryScope): Promise { + const files = await store.listFiles(); + if (files.length >= MEMORY_MAX_FILES_PER_SCOPE) { + throw new MemoryCommandError( + `The ${scope} memory scope is full (${MEMORY_MAX_FILES_PER_SCOPE} files); delete unused files first` + ); + } + } + private requireFilePath(parsed: ParsedMemoryPath, virtualPath: string): MemoryScope { if (parsed.scope === null || parsed.relPath === "") { throw new MemoryCommandError( @@ -972,12 +987,7 @@ export class MemoryService extends EventEmitter { `A ${existing === "dir" ? "directory" : "file"} already exists at ${virtualPath}. To overwrite a file, delete it first, then create it.` ); } - const files = await store.listFiles(); - if (files.length >= MEMORY_MAX_FILES_PER_SCOPE) { - throw new MemoryCommandError( - `The ${scope} memory scope is full (${MEMORY_MAX_FILES_PER_SCOPE} files); delete unused files first` - ); - } + await this.assertScopeHasRoom(store, scope); await assertMutationCommittable(this.config.rootDir, ctx, abortSignal, virtualPath); await store.writeFile(parsed.relPath, fileText); // Row is written before the create is acknowledged (mutation → row → ack). @@ -1451,12 +1461,7 @@ export class MemoryService extends EventEmitter { if (kind !== null) { return conflict(`A file already exists at ${virtualPath}; reload before saving`); } - const files = await store.listFiles(); - if (files.length >= MEMORY_MAX_FILES_PER_SCOPE) { - throw new MemoryCommandError( - `The ${scope} memory scope is full (${MEMORY_MAX_FILES_PER_SCOPE} files); delete unused files first` - ); - } + await this.assertScopeHasRoom(store, scope); } else { if (kind === null) { return conflict(`${virtualPath} no longer exists; it may have been deleted`); From 152560d73c3380696d402dc413f152a8f2c981ef Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:09:58 +0000 Subject: [PATCH 03/94] refactor: dedupe blockquote line formatting in bash monitor wake prompt --- src/node/services/bashMonitorWakeStore.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/node/services/bashMonitorWakeStore.ts b/src/node/services/bashMonitorWakeStore.ts index 7c8e1c4151..47fbe65327 100644 --- a/src/node/services/bashMonitorWakeStore.ts +++ b/src/node/services/bashMonitorWakeStore.ts @@ -492,6 +492,14 @@ export interface BashMonitorWakePromptContext { taskAwaitable?: boolean; } +/** + * Prefix each line with Markdown blockquote syntax (`> `) and join with newlines. Used for both + * the matched output and the lost-monitor script rendered in buildBashMonitorWakePrompt. + */ +function blockquoteLines(lines: readonly string[]): string { + return lines.map((line) => `> ${line}`).join("\n"); +} + export function buildBashMonitorWakePrompt( records: readonly BashMonitorWakeRecord[], context?: ReadonlyMap @@ -517,20 +525,14 @@ export function buildBashMonitorWakePrompt( const sections = records.map((record) => { const displayName = record.displayName ?? record.processId; const monitorLine = `Monitor: /${record.filter}/${record.filterExclude ? " (inverted)" : ""}`; - const lines = record.lines - .map(sanitizeBashMonitorWakeLine) - .map((line) => `> ${line}`) - .join("\n"); + const lines = blockquoteLines(record.lines.map(sanitizeBashMonitorWakeLine)); const dropped = record.droppedLines > 0 ? `\nDropped matched lines: ${record.droppedLines}` : ""; if (record.kind === "monitor-lost") { // The script is agent-authored (it wrote the bash call), so it is not marked // untrusted; any matched output lines keep the untrusted marker. - const script = (record.script ?? "") - .split("\n") - .map((line) => `> ${line}`) - .join("\n"); + const script = blockquoteLines((record.script ?? "").split("\n")); const matchedOutputLabel = record.lostReason === "runtime-failure" ? "Matched output before monitor retirement" From a300ba5906610cfaee1cd1236d37ef4756f6a6a2 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:38:01 +0000 Subject: [PATCH 04/94] refactor: dedupe tool_search removal in prepareToolSearch Both fallback branches in prepareToolSearch inlined the identical { [TOOL_SEARCH_TOOL_NAME]: _removed, ...rest } destructure to drop the built-in tool_search entry from the record. Extract a module-level withoutToolSearch(tools) helper; output is byte-identical. --- src/common/utils/tools/toolCatalog.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/common/utils/tools/toolCatalog.ts b/src/common/utils/tools/toolCatalog.ts index a0af8a8eee..febcfa9207 100644 --- a/src/common/utils/tools/toolCatalog.ts +++ b/src/common/utils/tools/toolCatalog.ts @@ -328,6 +328,12 @@ export function buildToolCatalogOverview(catalog: readonly ToolCatalogEntry[]): return `${OVERVIEW_HEADER}\n${lines.join("\n")}\n${OVERVIEW_END_MARKER}`; } +/** Return the tool record with the built-in `tool_search` entry removed. */ +function withoutToolSearch(tools: Record): Record { + const { [TOOL_SEARCH_TOOL_NAME]: _removed, ...rest } = tools; + return rest; +} + /** * Post-policy gate: decides whether tool-search deferral is active for this * stream and returns the (possibly adjusted) tool record plus the seed state. @@ -372,13 +378,11 @@ export function prepareToolSearch(inputs: ToolCatalogInputs): { // Gated on the actual PTC flag, not record presence: a `code_execution` // record entry may be a same-named MCP tool (classified as normal deferred). if (inputs.ptcEnabled === true) { - const { [TOOL_SEARCH_TOOL_NAME]: _removed, ...rest } = inputs.tools; - return { tools: rest }; + return { tools: withoutToolSearch(inputs.tools) }; } const classification = buildToolCatalog(inputs); if (classification.deferredToolNames.size === 0) { - const { [TOOL_SEARCH_TOOL_NAME]: _removed, ...rest } = inputs.tools; - return { tools: rest }; + return { tools: withoutToolSearch(inputs.tools) }; } // Advertise the deferred surface area up front: append a compact per-server // index of deferred tool names to tool_catalog_search's description so the From 4210aaea29516a0c08b9285120b1445de5cb812a Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:07:33 +0000 Subject: [PATCH 05/94] refactor: dedupe anthropic cache-create token extraction in usageHelpers accumulateProviderMetadata inlined the same verbose (metadata.anthropic as { cacheCreationInputTokens?: number }).cacheCreationInputTokens ?? 0 cast twice. Extracted a module-private getAnthropicCacheCreateTokens(metadata) helper. Behavior-preserving; the displayUsage.ts site is intentionally left alone because its chain has an extra usage.inputTokenDetails.cacheWriteTokens fallback. --- src/common/utils/tokens/usageHelpers.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/common/utils/tokens/usageHelpers.ts b/src/common/utils/tokens/usageHelpers.ts index c81840cd1f..a665eb8202 100644 --- a/src/common/utils/tokens/usageHelpers.ts +++ b/src/common/utils/tokens/usageHelpers.ts @@ -104,6 +104,17 @@ export function addUsage( }; } +/** + * Read Anthropic cache-creation input tokens from a provider-metadata record. + * Returns 0 when the field is absent so callers can sum without null checks. + */ +function getAnthropicCacheCreateTokens(metadata: Record | undefined): number { + return ( + (metadata?.anthropic as { cacheCreationInputTokens?: number } | undefined) + ?.cacheCreationInputTokens ?? 0 + ); +} + /** * Accumulate provider metadata across steps for additive billing metadata. * @@ -118,12 +129,8 @@ export function accumulateProviderMetadata( if (!existing) return step; // Extract cache creation tokens from both - const existingCacheCreate = - (existing.anthropic as { cacheCreationInputTokens?: number } | undefined) - ?.cacheCreationInputTokens ?? 0; - const stepCacheCreate = - (step.anthropic as { cacheCreationInputTokens?: number } | undefined) - ?.cacheCreationInputTokens ?? 0; + const existingCacheCreate = getAnthropicCacheCreateTokens(existing); + const stepCacheCreate = getAnthropicCacheCreateTokens(step); const totalCacheCreate = existingCacheCreate + stepCacheCreate; const existingXaiCostTicks = From c2c890eefe1370b89c48d1e9277e404822c630f7 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:46:56 +0000 Subject: [PATCH 06/94] refactor: dedupe capability-model thinking policy resolution Both getThinkingPolicyForModel and hasExplicitThinkingPolicy inlined the identical getExplicitThinkingPolicy(resolveModelForMetadata(model, providersConfig ?? null)) call after #3708 added alias resolution to each. Extract a private getExplicitThinkingPolicyForModel helper; behavior-preserving. --- src/common/utils/thinking/policy.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/common/utils/thinking/policy.ts b/src/common/utils/thinking/policy.ts index 511d17da99..808c48b230 100644 --- a/src/common/utils/thinking/policy.ts +++ b/src/common/utils/thinking/policy.ts @@ -89,8 +89,7 @@ export function getThinkingPolicyForModel( modelString: string, providersConfig?: ProvidersConfigMap | null ): ThinkingPolicy { - const capabilityModel = resolveModelForMetadata(modelString, providersConfig ?? null); - return getExplicitThinkingPolicy(capabilityModel) ?? DEFAULT_THINKING_POLICY; + return getExplicitThinkingPolicyForModel(modelString, providersConfig) ?? DEFAULT_THINKING_POLICY; } /** @@ -204,6 +203,20 @@ function getExplicitThinkingPolicy(modelString: string): ThinkingPolicy | null { return null; } +/** + * Resolve a model to its capability model (following `mappedToModel` aliases via + * {@link resolveModelForMetadata}) and return its explicit reasoning policy, or + * `null` when none matches. Shared by {@link getThinkingPolicyForModel} and + * {@link hasExplicitThinkingPolicy}, which must resolve aliases identically + * before rule matching so a mapped alias inherits its target's policy. + */ +function getExplicitThinkingPolicyForModel( + modelString: string, + providersConfig?: ProvidersConfigMap | null +): ThinkingPolicy | null { + return getExplicitThinkingPolicy(resolveModelForMetadata(modelString, providersConfig ?? null)); +} + /** Canonical ordering index for a level (off=0 … max=5). */ function thinkingLevelIndex(level: ThinkingLevel): number { return THINKING_LEVELS.indexOf(level); @@ -245,10 +258,7 @@ export function hasExplicitThinkingPolicy( modelString: string, providersConfig?: ProvidersConfigMap | null ): boolean { - return ( - getExplicitThinkingPolicy(resolveModelForMetadata(modelString, providersConfig ?? null)) !== - null - ); + return getExplicitThinkingPolicyForModel(modelString, providersConfig) !== null; } /** From 2e94627b49351c7f9a355c47c27b108d9f2252ef Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:24:53 +0000 Subject: [PATCH 07/94] refactor: dedupe queue entry clear-callback projection in MessageQueue --- src/node/services/messageQueue.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index 2bc02977d6..b0935d111d 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -725,6 +725,20 @@ export class MessageQueue { return this.getVisibleEntries().some((entry) => isCompactionMetadata(entry.muxMetadata)); } + /** + * Project a single entry's cancellation callbacks into the clear-callback shape. + * Shared by full-queue clears and targeted workspace-turn removal so both notify + * the same callback set. + */ + private entryClearCallbacks(entry: QueueEntry): QueueClearCallbacks { + return { + ...(entry.onCanceled != null ? { onCanceled: entry.onCanceled } : {}), + ...(entry.onAcceptedPreStreamFailure != null + ? { onAcceptedPreStreamFailure: entry.onAcceptedPreStreamFailure } + : {}), + }; + } + /** * Cancellation callbacks for every pending entry, in queue order. * Callers must notify each one when clearing the queue. @@ -732,12 +746,7 @@ export class MessageQueue { getClearCallbacks(): QueueClearCallbacks[] { return this.entries .filter((entry) => entry.onCanceled != null || entry.onAcceptedPreStreamFailure != null) - .map((entry) => ({ - ...(entry.onCanceled != null ? { onCanceled: entry.onCanceled } : {}), - ...(entry.onAcceptedPreStreamFailure != null - ? { onAcceptedPreStreamFailure: entry.onAcceptedPreStreamFailure } - : {}), - })); + .map((entry) => this.entryClearCallbacks(entry)); } /** @@ -757,12 +766,7 @@ export class MessageQueue { return null; } const [entry] = this.entries.splice(index, 1); - return { - ...(entry.onCanceled != null ? { onCanceled: entry.onCanceled } : {}), - ...(entry.onAcceptedPreStreamFailure != null - ? { onAcceptedPreStreamFailure: entry.onAcceptedPreStreamFailure } - : {}), - }; + return this.entryClearCallbacks(entry); } /** Remove queued entries carrying a dedupe key with the given prefix. */ From 0cdab9479a85ee3cffe357e735ce368d968a2229 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:25:51 +0000 Subject: [PATCH 08/94] refactor: dedupe OpenAI-origin model check in cacheStrategy Both eligibility gates in openaiExplicitPromptCachingAvailable inlined the identical split(":", 2) + `origin !== "openai" || !modelName` check (once for the request model, once for the resolved capability target). The destructured origin/name locals were unused past their guard. Extracted into a module-private isOpenAIOriginModel(canonical) helper. Behavior-preserving. --- src/common/utils/ai/cacheStrategy.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/common/utils/ai/cacheStrategy.ts b/src/common/utils/ai/cacheStrategy.ts index 6d176d4728..8a24f1f429 100644 --- a/src/common/utils/ai/cacheStrategy.ts +++ b/src/common/utils/ai/cacheStrategy.ts @@ -235,6 +235,16 @@ function isOfficialOpenAIBaseUrl(baseUrl: string): boolean { ); } +/** + * Whether a canonical `provider:model` string has an `openai` origin with a + * non-empty model name. Used to gate both the request model and its resolved + * capability target in openaiExplicitPromptCachingAvailable. + */ +function isOpenAIOriginModel(canonical: string): boolean { + const [origin, modelName] = canonical.split(":", 2); + return origin === "openai" && !!modelName; +} + /** * Route-aware eligibility for GPT-5.6 explicit prompt cache breakpoints. * @@ -270,16 +280,14 @@ export function openaiExplicitPromptCachingAvailable( } const normalized = normalizeToCanonical(modelString); - const [origin, modelName] = normalized.split(":", 2); - if (origin !== "openai" || !modelName) { + if (!isOpenAIOriginModel(normalized)) { return false; } // Mapped aliases inherit eligibility only when the resolved capability // target is also an OpenAI GPT-5.6-family model. const capabilityModel = resolveModelForMetadata(normalized, providersConfig); - const [capabilityOrigin, capabilityModelName] = capabilityModel.split(":", 2); - if (capabilityOrigin !== "openai" || !capabilityModelName) { + if (!isOpenAIOriginModel(capabilityModel)) { return false; } if (!isGpt56FamilyModel(capabilityModel)) { From e82180afdc6b49e3ca5b50b813a1f73a072d2418 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:52:46 +0000 Subject: [PATCH 09/94] refactor: dedupe tool-call-execution-start emit in StreamManager --- src/node/services/streamManager.ts | 41 ++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 864b093ceb..42d0c299c9 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -1018,6 +1018,26 @@ export class StreamManager { return false; } + /** + * Emit the tool-call-execution-start chat event that tells the UI a tool's execute() + * has begun running. Shared by applyToolExecutionStart (part already stored) and the + * "tool-call" case that consumes a pending start recorded before the part landed. + */ + private emitToolCallExecutionStart( + workspaceId: WorkspaceId, + streamInfo: WorkspaceStreamInfo, + toolCallId: string, + timestamp: number + ): void { + this.emitTurnEvent({ + type: "tool-call-execution-start", + workspaceId: workspaceId as string, + messageId: streamInfo.messageId, + toolCallId, + timestamp, + } satisfies ToolCallExecutionStartEvent); + } + /** * Record on the dynamic-tool part when its execute() actually began running and notify * the UI. Returns false when the part has not landed in streamInfo.parts yet. @@ -1039,13 +1059,7 @@ export class StreamManager { assert(part.type === "dynamic-tool", "applyToolExecutionStart matched a non-tool part"); streamInfo.parts[partIndex] = { ...part, executionStartedAt: timestamp }; - this.emitTurnEvent({ - type: "tool-call-execution-start", - workspaceId: workspaceId as string, - messageId: streamInfo.messageId, - toolCallId, - timestamp, - } satisfies ToolCallExecutionStartEvent); + this.emitToolCallExecutionStart(workspaceId, streamInfo, toolCallId, timestamp); return true; } @@ -1663,13 +1677,12 @@ export class StreamManager { } streamInfo.parts.push(partToPersist); if (pendingExecutionStart !== undefined && part.type === "dynamic-tool") { - this.emitTurnEvent({ - type: "tool-call-execution-start", - workspaceId: workspaceId as string, - messageId: streamInfo.messageId, - toolCallId: part.toolCallId, - timestamp: pendingExecutionStart, - } satisfies ToolCallExecutionStartEvent); + this.emitToolCallExecutionStart( + workspaceId, + streamInfo, + part.toolCallId, + pendingExecutionStart + ); } if (bufferedNestedCalls !== undefined && part.type === "dynamic-tool") { if (pendingAttachment == null) { From 6625b867fe8852cc42ba262ea7703a9617e05b97 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:28:21 +0000 Subject: [PATCH 10/94] refactor: unify legacy tool_search part rename helper in toolCatalog renameLegacyToolSearchCallPart and renameLegacyToolSearchResultPart were byte-identical except for their part type. Collapse them into a single generic renameLegacyToolSearchPart and drop the now-unused ToolCallPart/ToolResultPart imports. Behavior-preserving. --- src/common/utils/tools/toolCatalog.ts | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/common/utils/tools/toolCatalog.ts b/src/common/utils/tools/toolCatalog.ts index febcfa9207..eddf031cb4 100644 --- a/src/common/utils/tools/toolCatalog.ts +++ b/src/common/utils/tools/toolCatalog.ts @@ -12,13 +12,7 @@ * aiService or streamText. */ -import type { - AssistantModelMessage, - Tool, - ToolCallPart, - ToolModelMessage, - ToolResultPart, -} from "ai"; +import type { AssistantModelMessage, Tool, ToolModelMessage } from "ai"; import type { ModelMessage, MuxMessage } from "@/common/types/message"; import { buildRequiredToolPatterns, type ToolPolicy } from "@/common/utils/tools/toolPolicy"; @@ -577,13 +571,10 @@ function isMuxToolSearchOutput(output: unknown): boolean { ); } -function renameLegacyToolSearchCallPart(part: ToolCallPart): ToolCallPart { - return part.toolName === LEGACY_TOOL_SEARCH_TOOL_NAME - ? { ...part, toolName: TOOL_SEARCH_TOOL_NAME } - : part; -} - -function renameLegacyToolSearchResultPart(part: ToolResultPart): ToolResultPart { +// Generic over the part shape so the assistant tool-call and tool-result paths +// share one implementation: both parts carry a `toolName`, and the rename is +// identical regardless of which kind of part is being rewritten. +function renameLegacyToolSearchPart(part: T): T { return part.toolName === LEGACY_TOOL_SEARCH_TOOL_NAME ? { ...part, toolName: TOOL_SEARCH_TOOL_NAME } : part; @@ -663,10 +654,10 @@ export function normalizeLegacyToolSearchMessages(messages: ModelMessage[]): Mod return part; } if (part.type === "tool-call") { - return renameLegacyToolSearchCallPart(part); + return renameLegacyToolSearchPart(part); } if (part.type === "tool-result") { - return renameLegacyToolSearchResultPart(part); + return renameLegacyToolSearchPart(part); } return part; } @@ -678,7 +669,7 @@ export function normalizeLegacyToolSearchMessages(messages: ModelMessage[]): Mod if (message.role === "tool") { const content: ToolModelMessage["content"] = message.content.map((part) => { if (part.type === "tool-result" && legacyCallIds.has(part.toolCallId)) { - return renameLegacyToolSearchResultPart(part); + return renameLegacyToolSearchPart(part); } return part; }); From 03435e9b6e4d7b45e46bbb58e51f99cec6edba13 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:30:58 +0000 Subject: [PATCH 11/94] refactor: drop duplicated context-cap rationale comment in codexOAuth The CODEX_OAUTH_CONTEXT_WINDOW_OVERRIDES doc comment already explains that these caps are kept separate from model metadata so API-key requests retain the public window. #3724 rewrote the inline comment to restate the same point, so trim the duplicated sentence and keep only the tier-specific rationale. Comment-only; behavior-preserving. --- src/common/constants/codexOAuth.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/common/constants/codexOAuth.ts b/src/common/constants/codexOAuth.ts index 08ba45958e..b3dbbe4bcd 100644 --- a/src/common/constants/codexOAuth.ts +++ b/src/common/constants/codexOAuth.ts @@ -139,7 +139,6 @@ export const CODEX_OAUTH_REQUIRED_MODELS = new Set([ const CODEX_OAUTH_CONTEXT_WINDOW_OVERRIDES: Record = { // The public API exposes a 1.05M window for these models, but the ChatGPT/Codex // model catalog publishes smaller context windows (372K for the GPT-5.6 family). - // Keep auth-route caps separate so API-key requests retain the full public window. "gpt-5.5": 272_000, "gpt-5.6": 372_000, "gpt-5.6-sol": 372_000, From 3f722c75020f1e4115d6e152bf9ca60bd17eb330 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:51:20 +0000 Subject: [PATCH 12/94] refactor: dedupe flat-section pinned block resolution in pinnedReorder --- src/browser/utils/ui/pinnedReorder.ts | 33 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/browser/utils/ui/pinnedReorder.ts b/src/browser/utils/ui/pinnedReorder.ts index 74e4e47dc3..945c117eb6 100644 --- a/src/browser/utils/ui/pinnedReorder.ts +++ b/src/browser/utils/ui/pinnedReorder.ts @@ -47,6 +47,24 @@ function collectFlatSectionRows( return orderMultiProjectSectionRows(Array.from(byId.values())); } +/** + * Resolve the pinned block for a flat-rendered section (multi-project or + * scratch). Both render as a single block regardless of source bucket, so the + * block's `fullOrder` and `blockIds` are identical: every pinned id of the + * section, in rendered order. Returns null when `meta` is not one of them. + */ +function locateFlatSectionPinnedBlock( + meta: FrontendWorkspaceMetadata, + sortedWorkspacesByProject: Map, + includeRow: (row: FrontendWorkspaceMetadata) => boolean +): PinnedBlock | null { + const pinnedIds = collectFlatSectionRows(sortedWorkspacesByProject, includeRow) + .filter(isWorkspacePinned) + .map((row) => row.id); + if (!pinnedIds.includes(meta.id)) return null; + return { fullOrder: pinnedIds, blockIds: pinnedIds }; +} + /** * Resolve the pinned block containing `meta`, mirroring the sidebar renderer: * multi-project rows form one flat block; regular rows partition by their @@ -65,22 +83,15 @@ export function locatePinnedBlock( // partitioning below would isolate every row into a block of one and // swallow reorders. Treat them like the multi-project section instead. if (meta.kind === "scratch") { - const pinnedIds = collectFlatSectionRows( + return locateFlatSectionPinnedBlock( + meta, sortedWorkspacesByProject, (row) => row.kind === "scratch" - ) - .filter(isWorkspacePinned) - .map((row) => row.id); - if (!pinnedIds.includes(meta.id)) return null; - return { fullOrder: pinnedIds, blockIds: pinnedIds }; + ); } if (isMultiProject(meta)) { - const pinnedIds = collectFlatSectionRows(sortedWorkspacesByProject, isMultiProject) - .filter(isWorkspacePinned) - .map((row) => row.id); - if (!pinnedIds.includes(meta.id)) return null; - return { fullOrder: pinnedIds, blockIds: pinnedIds }; + return locateFlatSectionPinnedBlock(meta, sortedWorkspacesByProject, isMultiProject); } const rows = sortedWorkspacesByProject.get(meta.projectPath) ?? []; From ef65608436f108db27a5f892c7a2fe2090338145 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:30:53 +0000 Subject: [PATCH 13/94] refactor: dedupe JSON-wrapped tool-output unwrap in workflowRunMessages --- src/common/utils/workflowRunMessages.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/common/utils/workflowRunMessages.ts b/src/common/utils/workflowRunMessages.ts index 66a2b3b5fa..2a80f0c3d1 100644 --- a/src/common/utils/workflowRunMessages.ts +++ b/src/common/utils/workflowRunMessages.ts @@ -25,6 +25,15 @@ function isRecordValue(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * Tool outputs may be wrapped in a { type: "json", value } container (UI parts and SDK + * ToolResultPart outputs share this shape). Both the terminal-status probe and the + * record-stripping pass unwrap this the same way before inspecting the inner value. + */ +function isJsonWrappedOutput(output: Record): boolean { + return output.type === "json" && "value" in output; +} + /** * workflow_run / workflow_resume outputs embed the full run record (script source, event * log, step snapshots) solely for the UI run card. The model only needs status/runId/result — @@ -41,7 +50,7 @@ export function isTerminalWorkflowRunToolOutput( if (!isWorkflowRunEmittingToolName(toolName) || !isRecordValue(output)) { return false; } - if (output.type === "json" && "value" in output) { + if (isJsonWrappedOutput(output)) { return isTerminalWorkflowRunToolOutput(toolName, output.value, runId); } const status = output.status; @@ -55,9 +64,7 @@ export function stripWorkflowRunRecordForModel(toolName: string, output: unknown if (!isWorkflowRunEmittingToolName(toolName) || !isRecordValue(output)) { return output; } - // Tool outputs may be wrapped in a { type: "json", value } container (UI parts and - // SDK ToolResultPart outputs share this shape). - if (output.type === "json" && "value" in output) { + if (isJsonWrappedOutput(output)) { const strippedValue = stripWorkflowRunRecordForModel(toolName, output.value); return strippedValue === output.value ? output : { ...output, value: strippedValue }; } From f1950e3f7d61b70d9235147e0e1d9e834e5c244f Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:34:22 +0000 Subject: [PATCH 14/94] refactor: hoist errorType local in finalizeWorkspaceTurnFromStreamError Dedupe the repeated event.errorType member access and the duplicated event.errorType != null guard introduced by #3729 into a single local const. Pure behavior-preserving simplification; ErrorEvent.errorType is a plain Zod-inferred data property with no side effects. --- src/node/services/taskService.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 282d2867c9..02468ebfa9 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -15787,11 +15787,12 @@ export class TaskService implements AgentTaskIntegration { // Explicit in-session recovery cases (aborted, context_exceeded) may // continue through queued/preparing turns; auto-retryable errors require a // pending auto-retry of the same turn. + const errorType = event.errorType; const explicitRecovery = - event.errorType != null && WORKSPACE_TURN_RECOVERABLE_STREAM_ERRORS.has(event.errorType); + errorType != null && WORKSPACE_TURN_RECOVERABLE_STREAM_ERRORS.has(errorType); if ( - event.errorType != null && - isWorkspaceTurnRecoverableStreamError(event.errorType) && + errorType != null && + isWorkspaceTurnRecoverableStreamError(errorType) && (await this.hasRecoverableWorkspaceTurnRetryInFlight(record.workspaceId, event.messageId, { requireAutoRetry: !explicitRecovery, })) From 0538fee098f8bf6c8825c6d832275bed25fe4436 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:28:04 +0000 Subject: [PATCH 15/94] refactor: extract buildSkillDescriptor helper for skill discovery --- src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/agentSkill.ts | 23 +++++++++++++++++++ .../agentSkills/agentSkillsService.ts | 12 ++-------- src/node/services/tools/agent_skill_list.ts | 16 ++++--------- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 17e054d6d6..194b22a3bf 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -103,6 +103,7 @@ export { AgentSkillPackageSchema, AgentSkillScopeSchema, SkillNameSchema, + buildSkillDescriptor, resolveSkillAdvertise, resolveSkillUserInvocable, resolveSkillWhenToUse, diff --git a/src/common/orpc/schemas/agentSkill.ts b/src/common/orpc/schemas/agentSkill.ts index 7403338ec6..cb96ad898b 100644 --- a/src/common/orpc/schemas/agentSkill.ts +++ b/src/common/orpc/schemas/agentSkill.ts @@ -113,6 +113,29 @@ export const AgentSkillDescriptorSchema = z.object({ pluginName: z.string().min(1).optional(), }); +/** + * Map validated SKILL.md frontmatter to the normalized AgentSkillDescriptor shape. + * + * Centralizes the frontmatter→descriptor field mapping (advertise / user-invocable / + * argument-hint / when-to-use normalization) so every discovery path produces byte-identical + * descriptors. Callers still validate the result with AgentSkillDescriptorSchema, since they + * handle validation failures differently (tool listing vs. diagnostics-collecting discovery). + */ +export function buildSkillDescriptor( + frontmatter: z.infer, + scope: z.infer +): z.infer { + return { + name: frontmatter.name, + description: frontmatter.description, + scope, + advertise: resolveSkillAdvertise(frontmatter), + userInvocable: resolveSkillUserInvocable(frontmatter), + argumentHint: frontmatter["argument-hint"], + whenToUse: resolveSkillWhenToUse(frontmatter), + }; +} + export const AgentSkillPackageSchema = z .object({ scope: AgentSkillScopeSchema, diff --git a/src/node/services/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts index 2b2a6164c4..3406e370a7 100644 --- a/src/node/services/agentSkills/agentSkillsService.ts +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -22,9 +22,7 @@ import { AgentSkillDescriptorSchema, AgentSkillPackageSchema, SkillNameSchema, - resolveSkillAdvertise, - resolveSkillUserInvocable, - resolveSkillWhenToUse, + buildSkillDescriptor, } from "@/common/orpc/schemas"; import type { AgentSkillDescriptor, @@ -536,13 +534,7 @@ async function readSkillDescriptorFromDir( }); const descriptor: AgentSkillDescriptor = { - name: parsed.frontmatter.name, - description: parsed.frontmatter.description, - scope, - advertise: resolveSkillAdvertise(parsed.frontmatter), - userInvocable: resolveSkillUserInvocable(parsed.frontmatter), - argumentHint: parsed.frontmatter["argument-hint"], - whenToUse: resolveSkillWhenToUse(parsed.frontmatter), + ...buildSkillDescriptor(parsed.frontmatter, scope), ...(options?.pluginName !== undefined ? { pluginName: options.pluginName } : {}), }; diff --git a/src/node/services/tools/agent_skill_list.ts b/src/node/services/tools/agent_skill_list.ts index 9c50507bd5..a626c8e15f 100644 --- a/src/node/services/tools/agent_skill_list.ts +++ b/src/node/services/tools/agent_skill_list.ts @@ -7,9 +7,7 @@ import { listProjectMetadataRelativePaths } from "@/common/compat/legacyMux"; import { AgentSkillDescriptorSchema, SkillNameSchema, - resolveSkillAdvertise, - resolveSkillUserInvocable, - resolveSkillWhenToUse, + buildSkillDescriptor, } from "@/common/orpc/schemas"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { AgentSkillListToolResult } from "@/common/types/tools"; @@ -128,15 +126,9 @@ async function readSkillDescriptor( directoryName, }); - const descriptorResult = AgentSkillDescriptorSchema.safeParse({ - name: parsed.frontmatter.name, - description: parsed.frontmatter.description, - scope, - advertise: resolveSkillAdvertise(parsed.frontmatter), - userInvocable: resolveSkillUserInvocable(parsed.frontmatter), - argumentHint: parsed.frontmatter["argument-hint"], - whenToUse: resolveSkillWhenToUse(parsed.frontmatter), - }); + const descriptorResult = AgentSkillDescriptorSchema.safeParse( + buildSkillDescriptor(parsed.frontmatter, scope) + ); if (!descriptorResult.success) { log.warn( From 3620c76a9910d0cd6627d51ce6dc4ecb6174a9a6 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:34:14 +0000 Subject: [PATCH 16/94] refactor: extract awaitPendingLoad helper in DevToolsService --- src/node/services/devToolsService.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/node/services/devToolsService.ts b/src/node/services/devToolsService.ts index 5824864ff5..c924d0bb64 100644 --- a/src/node/services/devToolsService.ts +++ b/src/node/services/devToolsService.ts @@ -349,10 +349,7 @@ export class DevToolsService extends EventEmitter { // Wait for any in-flight load to finish before clearing, otherwise the // pending loadFromDisk can repopulate stale data after the clear. - const pendingLoad = this.loadingPromises.get(workspaceId); - if (pendingLoad) { - await pendingLoad; - } + await this.awaitPendingLoad(workspaceId); const data = this.getOrCreateWorkspaceData(workspaceId); data.runs.clear(); @@ -388,10 +385,7 @@ export class DevToolsService extends EventEmitter { // Wait for any in-flight load to finish so it cannot repopulate state // after the removal below. - const pendingLoad = this.loadingPromises.get(workspaceId); - if (pendingLoad) { - await pendingLoad; - } + await this.awaitPendingLoad(workspaceId); // Deleting the entry (rather than clearing it in place) makes stale queued // appends no-ops via the existence guard in appendToFile. @@ -406,6 +400,18 @@ export class DevToolsService extends EventEmitter { this.emitWorkspaceEvent(workspaceId, { type: "cleared" }); } + /** + * Wait for any in-flight load for this workspace to finish before mutating + * its state, otherwise a pending loadFromDisk can repopulate stale data after + * the mutation. + */ + private async awaitPendingLoad(workspaceId: string): Promise { + const pendingLoad = this.loadingPromises.get(workspaceId); + if (pendingLoad) { + await pendingLoad; + } + } + private emitWorkspaceEvent(workspaceId: string, event: DevToolsEvent): void { this.emit(`update:${workspaceId}`, event); } From 873195259eae109558f61c5690250a398296feef Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:40:06 +0000 Subject: [PATCH 17/94] refactor: extract getTotalTokens helper for total-token sums --- .../features/RightSidebar/CostsTab.tsx | 8 ++------ src/browser/stores/WorkspaceStore.ts | 10 ++-------- src/cli/run.ts | 19 +++--------------- src/common/utils/tokens/tokenMeterUtils.ts | 9 ++------- .../utils/tokens/usageAggregator.test.ts | 20 ++++++++++++++++++- src/common/utils/tokens/usageAggregator.ts | 15 ++++++++++++++ src/node/services/sessionUsageService.ts | 9 ++------- 7 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/browser/features/RightSidebar/CostsTab.tsx b/src/browser/features/RightSidebar/CostsTab.tsx index 68e6666d8e..cf5896d39c 100644 --- a/src/browser/features/RightSidebar/CostsTab.tsx +++ b/src/browser/features/RightSidebar/CostsTab.tsx @@ -4,6 +4,7 @@ import { sumUsageHistory, formatCostWithDollar, getTotalCost, + getTotalTokens, type ChatUsageDisplay, } from "@/common/utils/tokens/usageAggregator"; import { formatModelStringForDisplay } from "@/common/utils/ai/models"; @@ -66,12 +67,7 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { return Array.from(merged.entries()) .map(([model, entry]) => ({ model, - tokens: - entry.input.tokens + - entry.cached.tokens + - entry.cacheCreate.tokens + - entry.output.tokens + - entry.reasoning.tokens, + tokens: getTotalTokens(entry), cost: getTotalCost(entry), })) .sort((a, b) => (b.cost ?? 0) - (a.cost ?? 0) || b.tokens - a.tokens); diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 4198349201..1549c28159 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -80,7 +80,7 @@ import { computeProvidersConfigFingerprint } from "@/common/utils/providers/conf import { isDurableCompactionBoundaryMarker } from "@/common/utils/messages/compactionBoundary"; import { WorkspaceConsumerManager } from "./WorkspaceConsumerManager"; import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; -import { sumUsageHistory } from "@/common/utils/tokens/usageAggregator"; +import { sumUsageHistory, getTotalTokens } from "@/common/utils/tokens/usageAggregator"; import type { TokenConsumer } from "@/common/types/chatStats"; import { normalizeUsageModelKey } from "@/common/utils/providers/modelEntries"; import type { z } from "zod"; @@ -2816,13 +2816,7 @@ export class WorkspaceStore { const lastRequest = sessionData?.lastRequest; // Calculate total tokens from session total - const totalTokens = sessionTotal - ? sessionTotal.input.tokens + - sessionTotal.cached.tokens + - sessionTotal.cacheCreate.tokens + - sessionTotal.output.tokens + - sessionTotal.reasoning.tokens - : 0; + const totalTokens = getTotalTokens(sessionTotal); const messages = aggregator.getAllMessages(); if (messages.length === 0) { diff --git a/src/cli/run.ts b/src/cli/run.ts index 973ddc48fb..7dc584c49d 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -44,6 +44,7 @@ import type { ServiceTier } from "../common/config/schemas/providersConfig"; import { createDisplayUsage } from "../common/utils/tokens/displayUsage"; import { getTotalCost, + getTotalTokens, formatCostWithDollar, sumUsageHistory, type ChatUsageDisplay, @@ -1300,14 +1301,7 @@ async function main(): Promise { if (budget !== undefined && !budgetExceeded) { const totalUsage = sumUsageHistory(usageHistory); const cost = getTotalCost(totalUsage); - const hasTokens = totalUsage - ? totalUsage.input.tokens + - totalUsage.output.tokens + - totalUsage.cached.tokens + - totalUsage.cacheCreate.tokens + - totalUsage.reasoning.tokens > - 0 - : false; + const hasTokens = getTotalTokens(totalUsage) > 0; if (hasTokens && cost === undefined) { const errMsg = `Cannot enforce budget: unknown pricing for model "${payload.metadata.model ?? model}"`; @@ -1390,14 +1384,7 @@ async function main(): Promise { // Reject if model has unknown pricing: displayUsage exists with tokens but cost is undefined // (createDisplayUsage doesn't set hasUnknownCosts; that's only set by sumUsageHistory) // Include all token types: input, output, cached, cacheCreate, and reasoning - const hasTokens = - displayUsage && - displayUsage.input.tokens + - displayUsage.output.tokens + - displayUsage.cached.tokens + - displayUsage.cacheCreate.tokens + - displayUsage.reasoning.tokens > - 0; + const hasTokens = getTotalTokens(displayUsage) > 0; if (hasTokens && cost === undefined) { const errMsg = `Cannot enforce budget: unknown pricing for model "${model}"`; emitJsonLine({ type: "budget-error", error: errMsg, model }); diff --git a/src/common/utils/tokens/tokenMeterUtils.ts b/src/common/utils/tokens/tokenMeterUtils.ts index 77b542d9e4..74a3ba0b50 100644 --- a/src/common/utils/tokens/tokenMeterUtils.ts +++ b/src/common/utils/tokens/tokenMeterUtils.ts @@ -1,6 +1,6 @@ import type { ProvidersConfigMap } from "@/common/orpc/types"; import { getEffectiveContextLimit } from "@/common/utils/compaction/contextLimit"; -import type { ChatUsageDisplay } from "./usageAggregator"; +import { getTotalTokens, type ChatUsageDisplay } from "./usageAggregator"; // NOTE: Provide theme-matching fallbacks so token meters render consistently // even if a host environment doesn't define the CSS variables (e.g., an embedded UI). @@ -69,12 +69,7 @@ export function calculateTokenMeterData( // Total tokens used in the request. // For Anthropic prompt caching, cacheCreate tokens are reported separately but still // count toward total input tokens for the request. - const totalUsed = - usage.input.tokens + - usage.cached.tokens + - usage.cacheCreate.tokens + - usage.output.tokens + - usage.reasoning.tokens; + const totalUsed = getTotalTokens(usage); const toPercentage = (tokens: number) => { if (verticalProportions) { diff --git a/src/common/utils/tokens/usageAggregator.test.ts b/src/common/utils/tokens/usageAggregator.test.ts index 0f4f72602d..b63b5c3825 100644 --- a/src/common/utils/tokens/usageAggregator.test.ts +++ b/src/common/utils/tokens/usageAggregator.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { getTotalCost, sumUsageHistory } from "./usageAggregator"; +import { getTotalCost, getTotalTokens, sumUsageHistory } from "./usageAggregator"; describe("sumUsageHistory", () => { test("preserves hasUnknownCosts when an entry is approximate but still has numeric costs", () => { @@ -27,3 +27,21 @@ describe("sumUsageHistory", () => { expect(getTotalCost(result)).toBeCloseTo(0.544); }); }); + +describe("getTotalTokens", () => { + test("sums tokens across all five components", () => { + expect( + getTotalTokens({ + input: { tokens: 100 }, + cached: { tokens: 20 }, + cacheCreate: { tokens: 5 }, + output: { tokens: 10 }, + reasoning: { tokens: 3 }, + }) + ).toBe(138); + }); + + test("returns 0 for undefined usage", () => { + expect(getTotalTokens(undefined)).toBe(0); + }); +}); diff --git a/src/common/utils/tokens/usageAggregator.ts b/src/common/utils/tokens/usageAggregator.ts index 50ca39a6d9..8908e3aecd 100644 --- a/src/common/utils/tokens/usageAggregator.ts +++ b/src/common/utils/tokens/usageAggregator.ts @@ -116,6 +116,21 @@ export function getTotalCost(usage: ChatUsageDisplay | undefined): number | unde return hasAnyCost ? total : undefined; } +/** + * Sum the token counts across every usage component (input, cached, + * cacheCreate, output, reasoning) into a single total. Mirrors the component + * iteration used by getTotalCost so token totals stay consistent everywhere. + */ +export function getTotalTokens(usage: ChatUsageDisplay | undefined): number { + if (!usage) return 0; + const components = ["input", "cached", "cacheCreate", "output", "reasoning"] as const; + let total = 0; + for (const key of components) { + total += usage[key].tokens; + } + return total; +} + /** * Format cost for display with dollar sign. * Returns "~$0.00" for very small values, "$X.XX" otherwise. diff --git a/src/node/services/sessionUsageService.ts b/src/node/services/sessionUsageService.ts index 135c7e1915..67bc8fa724 100644 --- a/src/node/services/sessionUsageService.ts +++ b/src/node/services/sessionUsageService.ts @@ -8,7 +8,7 @@ import { isWorkspaceRemovalTombstoned } from "@/node/services/workspaceRemoval"; import { withTargetMutationLock } from "@/node/services/refinement/targetMutationLocks"; import { workspaceFileLocks } from "@/node/utils/concurrency/workspaceFileLocks"; import type { ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; -import { sumUsageHistory } from "@/common/utils/tokens/usageAggregator"; +import { sumUsageHistory, getTotalTokens } from "@/common/utils/tokens/usageAggregator"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { normalizeUsage, @@ -524,12 +524,7 @@ export class SessionUsageService { cachedTokens += usage.cached.tokens; cacheCreateTokens += usage.cacheCreate.tokens; - totalTokens += - usage.input.tokens + - usage.output.tokens + - usage.reasoning.tokens + - usage.cached.tokens + - usage.cacheCreate.tokens; + totalTokens += getTotalTokens(usage); contextTokens += usage.input.tokens + usage.cached.tokens + usage.cacheCreate.tokens; for (const bucket of [ From 7b7d08ffa627a2d5d00a13e0045ad6a04035feb0 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:35:24 +0000 Subject: [PATCH 18/94] refactor: hoist duplicated dedupeKeys snapshot in removeByDedupeKeyPrefix --- src/node/services/messageQueue.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index b0935d111d..977029e05d 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -780,9 +780,11 @@ export class MessageQueue { let removedCount = 0; const removedCallbacks: QueueClearCallbacks[] = []; this.entries = this.entries.flatMap((entry) => { - const matchingKeys = [...entry.dedupeKeys].filter((dedupeKey) => - dedupeKey.startsWith(prefix) - ); + // Snapshot the dedupe keys once: the message-index lookup below would otherwise + // re-spread the Set on every message iteration. The Set is not mutated until after + // keptMessages is computed, so both reads observe the same ordered snapshot. + const dedupeKeyList = [...entry.dedupeKeys]; + const matchingKeys = dedupeKeyList.filter((dedupeKey) => dedupeKey.startsWith(prefix)); if (matchingKeys.length === 0) { return [entry]; } @@ -792,7 +794,7 @@ export class MessageQueue { // preserve unrelated keys/messages that share the same entry. const matchingKeySet = new Set(matchingKeys); const keptMessages = entry.messages.filter((_message, index) => { - const key = [...entry.dedupeKeys][index]; + const key = dedupeKeyList[index]; return key == null || !matchingKeySet.has(key); }); if (keptMessages.length > 0) { From cfc8606a4a9f1fd65827f6f8e702cd740c8ee66d Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:12:28 +0000 Subject: [PATCH 19/94] refactor: dedupe settled workspace-turn reconciliation guard Extract the byte-identical 'reload under lock and bail unless the record is still the exact one we reconciled (same status AND updatedAt)' guard shared by persistRepairedSettledWorkspaceTurn and reviveRetryingWorkspaceTurn (both added in #3738) into a module-level isReconciledWorkspaceTurnUnchanged type guard. The guard narrows current to non-null for the revive path, and the generic 'compare updatedAt too' rationale now lives in one doc comment while each call site keeps its situational note. Behavior-preserving. --- src/node/services/taskService.ts | 38 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 02468ebfa9..8dfed87832 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -1061,6 +1061,23 @@ function isSelfHealEligibleSettledWorkspaceTurn( ); } +/** + * Under the settlement lock, confirm a reloaded handle is still the exact record a read-time + * reconciliation resolved against before mutating it. Comparing updatedAt as well as status + * matters: a concurrent settlement can produce a NEWER record with the same status, and a + * stale read must not clobber it. Callers pass a `null` / non-matching `current` straight + * through so the concurrent winner is reported. Typed as a guard so the matched branch narrows + * `current` to a non-null record. + */ +function isReconciledWorkspaceTurnUnchanged( + current: WorkspaceTurnTaskHandleRecord | null, + record: WorkspaceTurnTaskHandleRecord +): current is WorkspaceTurnTaskHandleRecord { + return ( + current != null && current.status === record.status && current.updatedAt === record.updatedAt + ); +} + /** * A workspace-turn stream error may resolve without parent intervention when * the child can still make progress on its own. The caller must still confirm @@ -11720,13 +11737,7 @@ export class TaskService implements AgentTaskIntegration { record.handleId ); // A concurrent settlement/repair wins; only replace the exact record we reconciled. - // Comparing updatedAt (not just status) matters: a concurrent settlement can produce - // a NEWER record with the same status that must not be clobbered by our stale read. - if ( - current == null || - current.status !== record.status || - current.updatedAt !== record.updatedAt - ) { + if (!isReconciledWorkspaceTurnUnchanged(current, record)) { // If this direct parent's task_await will return that concurrent terminal winner, // consume it here so its post-lock delivery cannot append the same outcome too. return await this.markDirectParentWorkspaceTurnResultConsumedUnlocked( @@ -11791,15 +11802,10 @@ export class TaskService implements AgentTaskIntegration { record.ownerWorkspaceId, record.handleId ); - // A concurrent transition wins; only revive the exact record we reconciled against. - // Comparing updatedAt (not just status) matters: the live retry itself can fail and - // settle a NEWER record with the same status (e.g. error → error) between our read - // and this lock — reviving that fresh terminal failure would strand task_await. - if ( - current == null || - current.status !== record.status || - current.updatedAt !== record.updatedAt - ) { + // A concurrent transition wins; only revive the exact record we reconciled against — the + // live retry can itself fail into a NEWER error record between our read and this lock, and + // reviving that fresh terminal failure would strand task_await. + if (!isReconciledWorkspaceTurnUnchanged(current, record)) { return current; } // Another turn already owns the child workspace; the activity is not this turn's retry. From a30226835d4fd8ff341587b48d01927ec2237718 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:29:14 +0000 Subject: [PATCH 20/94] refactor: drop duplicated Kimi K3 max-effort rationale in providerOptions The isKimiK3Model docstring already explains that Kimi K3 always reasons and supports only the max reasoning effort, and that the provider-options branches key off the predicate. #3737 restated that same sentence verbatim in both the Moonshot and OpenRouter branches of buildProviderOptions, so trim the duplicated lead sentence and keep only each branch's site-specific "send it explicitly" rationale. Comment-only; behavior-preserving. --- src/common/utils/ai/providerOptions.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/common/utils/ai/providerOptions.ts b/src/common/utils/ai/providerOptions.ts index 04b863be7e..8b4a1f48c6 100644 --- a/src/common/utils/ai/providerOptions.ts +++ b/src/common/utils/ai/providerOptions.ts @@ -581,8 +581,8 @@ export function buildProviderOptions( // Build Moonshot-specific options if (formatProvider === "moonshotai") { - // Kimi K3 always reasons and supports only the max reasoning effort. Send it - // explicitly rather than relying on the API default. + // Send the max reasoning effort explicitly rather than relying on the API + // default (see isKimiK3Model for why K3 only accepts "max"). if (isKimiK3Model(capabilityModel)) { const options = { moonshotai: { reasoningEffort: "max" }, @@ -618,9 +618,9 @@ export function buildProviderOptions( // Build OpenRouter-specific options if (formatProvider === "openrouter") { - // Kimi K3 always reasons and supports only the max reasoning effort. Send it - // explicitly: `enabled: true` alone falls back to OpenRouter's default (medium) - // effort, which the model does not support. + // Send the max reasoning effort explicitly: `enabled: true` alone falls back + // to OpenRouter's default (medium) effort, which K3 does not support (see + // isKimiK3Model for why K3 only accepts "max"). const reasoningEffort = isKimiK3Model(capabilityModel) ? "max" : OPENROUTER_REASONING_EFFORT[effectiveThinking]; From 8012da79b2ac6c0f1842194e52972076ef5f8d01 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:33:31 +0000 Subject: [PATCH 21/94] refactor: drop redundant structuredOutput guard at subagent report call sites formatSubagentReportUserMessage already omits structuredOutput from the envelope when it is undefined, so the two call sites re-implemented that exact guard. Forward report.structuredOutput directly instead. Behavior- preserving: the helper's internal !== undefined check yields byte-identical envelope output whether the key is absent or explicitly undefined. --- src/node/services/taskService.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 8dfed87832..f5b36a663a 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -377,6 +377,8 @@ function formatSubagentReportUserMessage(params: { ...(params.executionId != null ? { executionId: params.executionId } : {}), ...(params.model != null ? { model: params.model } : {}), ...(params.thinkingLevel != null ? { thinkingLevel: params.thinkingLevel } : {}), + // Omit structuredOutput entirely when absent so callers can forward the value directly + // without re-implementing the undefined guard at each call site. ...(params.structuredOutput !== undefined ? { structuredOutput: params.structuredOutput } : {}), }); } @@ -10352,9 +10354,7 @@ export class TaskService implements AgentTaskIntegration { ...(childEntry.workspace.taskThinkingLevel != null ? { thinkingLevel: childEntry.workspace.taskThinkingLevel } : {}), - ...(report.structuredOutput !== undefined - ? { structuredOutput: report.structuredOutput } - : {}), + structuredOutput: report.structuredOutput, }); // A progress report is itself the wake-up message. Unlike terminal attention, it must be // allowed through while this child is still active so review findings and other incremental @@ -17811,9 +17811,7 @@ export class TaskService implements AgentTaskIntegration { status: "completed", ...(childModelString != null ? { model: childModelString } : {}), ...(childThinkingLevel != null ? { thinkingLevel: childThinkingLevel } : {}), - ...(report.structuredOutput !== undefined - ? { structuredOutput: report.structuredOutput } - : {}), + structuredOutput: report.structuredOutput, }); const workspaceTurnMuxMetadata = From 68191d8154baaa27afa1a9ccbaec9bad62d68265 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:36:44 +0000 Subject: [PATCH 22/94] refactor: extract isZipMediaType helper for staged attachment media-type checks --- .../attachments/supportedAttachmentMediaTypes.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/common/utils/attachments/supportedAttachmentMediaTypes.ts b/src/common/utils/attachments/supportedAttachmentMediaTypes.ts index f8d3fb0321..e3230b9de8 100644 --- a/src/common/utils/attachments/supportedAttachmentMediaTypes.ts +++ b/src/common/utils/attachments/supportedAttachmentMediaTypes.ts @@ -78,6 +78,12 @@ export function getSupportedAttachmentMediaType(args: { return isSupportedAttachmentMediaType(normalized) ? normalized : null; } +// Membership check against the accepted ZIP media types, isolating the +// `as const` tuple cast that both staged-attachment paths would otherwise repeat. +function isZipMediaType(normalized: string): boolean { + return ZIP_MEDIA_TYPES.includes(normalized as (typeof ZIP_MEDIA_TYPES)[number]); +} + function sanitizeStagedAttachmentMediaType(mediaType: string): string | null { const normalized = normalizeAttachmentMediaType(mediaType); if ( @@ -92,10 +98,7 @@ function sanitizeStagedAttachmentMediaType(mediaType: string): string | null { export function isSupportedStagedAttachmentMediaType(mediaType: string): boolean { const normalized = normalizeAttachmentMediaType(mediaType); - return ( - ZIP_MEDIA_TYPES.includes(normalized as (typeof ZIP_MEDIA_TYPES)[number]) || - sanitizeStagedAttachmentMediaType(normalized) != null - ); + return isZipMediaType(normalized) || sanitizeStagedAttachmentMediaType(normalized) != null; } export function getSupportedStagedAttachmentMediaType(args: { @@ -105,7 +108,7 @@ export function getSupportedStagedAttachmentMediaType(args: { const trimmedMediaType = args.mediaType?.trim(); if (trimmedMediaType != null && trimmedMediaType.length > 0) { const normalized = normalizeAttachmentMediaType(trimmedMediaType); - if (ZIP_MEDIA_TYPES.includes(normalized as (typeof ZIP_MEDIA_TYPES)[number])) { + if (isZipMediaType(normalized)) { return ZIP_MEDIA_TYPE; } return sanitizeStagedAttachmentMediaType(normalized) ?? DEFAULT_STAGED_MEDIA_TYPE; From 4c84f77b0879404515d6cc19715f699c1834bd13 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:01:55 +0000 Subject: [PATCH 23/94] refactor: hoist duplicated goal-bypass attachment check in ChatInput --- src/browser/features/ChatInput/index.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index ff58313208..41bc682285 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -1860,13 +1860,16 @@ const ChatInputInner: React.FC = (props) => { } const combinedMcpPromptRefs = mcpPromptRefsResult.refs; + // The initial /goal path sets a goal without sending a user message, so + // attachments would be silently dropped. With attachments present, skip + // command processing and send the raw text as a normal message instead. + // Shared by the creation route below and the workspace path (transferred + // creation drafts retried in the composer), which resolve `parsed` and + // `attachments` identically. + const goalCommandBypassedForAttachments = parsed?.type === "goal-set" && attachments.length > 0; + // Route to creation handler for creation variant if (variant === "creation") { - // The initial /goal path sets a goal without sending a user message, so - // attachments would be silently dropped. With attachments present, skip - // command processing and send the raw text as a normal message instead. - const goalCommandBypassedForAttachments = - parsed?.type === "goal-set" && attachments.length > 0; const initialSlashCommand = parsed?.type === "goal-set" && !goalCommandBypassedForAttachments ? parsed : undefined; if ( @@ -1971,12 +1974,6 @@ const ChatInputInner: React.FC = (props) => { try { const modelOneShot = parsed?.type === "model-oneshot" ? parsed : null; - // Mirror the creation-composer /goal bypass: with attachments present, - // send the raw text as a normal message instead of processing the - // command, which would drop the files. Transferred staging-failure - // drafts (raw /goal text + staged/pending chips) retry through here. - const goalCommandBypassedForAttachments = - parsed?.type === "goal-set" && attachments.length > 0; const commandHandled = modelOneShot || goalCommandBypassedForAttachments ? false From 943b1143273b0113b8c089b437bb926e86a821b4 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:34:12 +0000 Subject: [PATCH 24/94] refactor: dedupe anchored Anthropic model-id regex construction The native/beta 1M-context pattern lists repeated the same `new RegExp(`^${OPTIONAL_VERSION_SUFFIX}$`, "i")` construction ten times, so each new model entry had to restate the anchoring and flags. Extract anthropicModelIdPattern() and map base model ids through it; generated regex sources and flags are unchanged. --- src/common/utils/ai/models.ts | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/common/utils/ai/models.ts b/src/common/utils/ai/models.ts index f4b4d614ea..277a5f48e7 100644 --- a/src/common/utils/ai/models.ts +++ b/src/common/utils/ai/models.ts @@ -176,21 +176,30 @@ export function getModelProvider(modelString: string): string { export type Anthropic1MContextMode = "none" | "beta" | "native"; const OPTIONAL_VERSION_SUFFIX = String.raw`(?:-(?:\d{8}|\d{4}-\d{2}-\d{2}))?`; + +/** + * Build a case-insensitive, fully anchored matcher for a base Anthropic model id + * plus its optional dated snapshot suffix (e.g. `claude-opus-5-20260724`). Base + * ids are literal model names, so no regex escaping is needed. + */ +function anthropicModelIdPattern(baseModelId: string): RegExp { + return new RegExp(`^${baseModelId}${OPTIONAL_VERSION_SUFFIX}$`, "i"); +} + const ANTHROPIC_NATIVE_1M_PATTERNS = [ // Mythos-class models (Fable 5 / Mythos 5) ship 1M context as standard metadata. - new RegExp(`^claude-fable-5${OPTIONAL_VERSION_SUFFIX}$`, "i"), - new RegExp(`^claude-mythos-5${OPTIONAL_VERSION_SUFFIX}$`, "i"), - new RegExp(`^claude-opus-5${OPTIONAL_VERSION_SUFFIX}$`, "i"), - new RegExp(`^claude-opus-4-8${OPTIONAL_VERSION_SUFFIX}$`, "i"), - new RegExp(`^claude-opus-4-7${OPTIONAL_VERSION_SUFFIX}$`, "i"), - new RegExp(`^claude-opus-4-6${OPTIONAL_VERSION_SUFFIX}$`, "i"), - new RegExp(`^claude-sonnet-5${OPTIONAL_VERSION_SUFFIX}$`, "i"), - new RegExp(`^claude-sonnet-4-6${OPTIONAL_VERSION_SUFFIX}$`, "i"), -]; -const ANTHROPIC_BETA_1M_PATTERNS = [ - new RegExp(`^claude-sonnet-4-5${OPTIONAL_VERSION_SUFFIX}$`, "i"), - new RegExp(`^claude-sonnet-4-20250514${OPTIONAL_VERSION_SUFFIX}$`, "i"), -]; + "claude-fable-5", + "claude-mythos-5", + "claude-opus-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-5", + "claude-sonnet-4-6", +].map(anthropicModelIdPattern); +const ANTHROPIC_BETA_1M_PATTERNS = ["claude-sonnet-4-5", "claude-sonnet-4-20250514"].map( + anthropicModelIdPattern +); function matchesAnthropicPattern(modelName: string, patterns: readonly RegExp[]): boolean { return patterns.some((pattern) => pattern.test(modelName)); From fd77fb6d2861c77bff93fc558eb6565e0b16fd3a Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:45:33 +0000 Subject: [PATCH 25/94] refactor: extract _child_dirs helper for job folder discovery --- .../prepare_leaderboard_submission.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/benchmarks/terminal_bench/prepare_leaderboard_submission.py b/benchmarks/terminal_bench/prepare_leaderboard_submission.py index 4b94e293b8..e379b25e5a 100755 --- a/benchmarks/terminal_bench/prepare_leaderboard_submission.py +++ b/benchmarks/terminal_bench/prepare_leaderboard_submission.py @@ -216,6 +216,11 @@ def get_model_from_config(config_path: Path) -> str | None: return None +def _child_dirs(path: Path) -> list[Path]: + """List the immediate subdirectories of a directory, skipping plain files.""" + return [child for child in path.iterdir() if child.is_dir()] + + def _is_job_folder(path: Path) -> bool: """Check if a directory looks like a job folder (contains trial dirs with config.json).""" if not path.is_dir(): @@ -249,20 +254,14 @@ def find_job_folders(artifacts_dir: Path) -> list[Path]: # Check for direct jobs/ folder direct_jobs = artifacts_dir / "jobs" if direct_jobs.exists(): - for item in direct_jobs.iterdir(): - if item.is_dir(): - job_folders.append(item) + job_folders.extend(_child_dirs(direct_jobs)) return job_folders # Check for per-artifact structure - for artifact_dir in artifacts_dir.iterdir(): - if not artifact_dir.is_dir(): - continue + for artifact_dir in _child_dirs(artifacts_dir): jobs_dir = artifact_dir / "jobs" if jobs_dir.exists(): - for item in jobs_dir.iterdir(): - if item.is_dir(): - job_folders.append(item) + job_folders.extend(_child_dirs(jobs_dir)) return job_folders From 60f59345bd66e06374d4b752b66f625d457bc66a Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:36:53 +0000 Subject: [PATCH 26/94] refactor: drop redundant "exec" fallback duplication for normalizeAgentId normalizeAgentId's fallback parameter already defaults to WORKSPACE_DEFAULTS.agentId ("exec"), so the four call sites that passed "exec" explicitly were re-hardcoding the centralized default. Dropping the literal also makes the workspaceModeAi wrapper (whose only job was supplying that fallback) dead, so it and its aliased import are removed. --- .../components/WorkspaceModeAISync/WorkspaceModeAISync.tsx | 2 +- src/browser/features/ChatInput/index.tsx | 4 ++-- src/browser/utils/workspaceModeAi.ts | 6 +----- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx index 8fd18e8992..ca68a4cfb6 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx @@ -43,7 +43,7 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { const modelKey = getModelKey(workspaceId); const thinkingKey = getThinkingLevelKey(workspaceId); - const normalizedAgentId = normalizeAgentId(agentId, "exec"); + const normalizedAgentId = normalizeAgentId(agentId); const isExplicitAgentSwitch = prevAgentIdRef.current !== null && diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 41bc682285..9d1001e6df 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -672,7 +672,7 @@ const ChatInputInner: React.FC = (props) => { return; } - const normalizedAgentId = normalizeAgentId(agentId, "exec"); + const normalizedAgentId = normalizeAgentId(agentId); updatePersistedState( getWorkspaceAISettingsByAgentKey(workspaceId), @@ -1001,7 +1001,7 @@ const ChatInputInner: React.FC = (props) => { const fallbackModel = defaultModel; - const normalizedAgentId = normalizeAgentId(agentId, "exec"); + const normalizedAgentId = normalizeAgentId(agentId); const isExplicitAgentSwitch = prevCreationAgentIdRef.current !== null && diff --git a/src/browser/utils/workspaceModeAi.ts b/src/browser/utils/workspaceModeAi.ts index 24f52a9273..b20be9fbcd 100644 --- a/src/browser/utils/workspaceModeAi.ts +++ b/src/browser/utils/workspaceModeAi.ts @@ -6,7 +6,7 @@ import { type OpenAIReasoningMode, type ThinkingLevel, } from "@/common/types/thinking"; -import { normalizeAgentId as normalizeWorkspaceAgentId } from "@/common/utils/agentIds"; +import { normalizeAgentId } from "@/common/utils/agentIds"; import { collectDeclaredAncestorLayers } from "@/common/utils/ai/agentAncestorLayers"; import { resolveAgentAiSettings } from "@/common/utils/ai/resolveAgentAiSettings"; @@ -17,10 +17,6 @@ export type WorkspaceAISettingsCache = Partial< > >; -function normalizeAgentId(agentId: string): string { - return normalizeWorkspaceAgentId(agentId, "exec"); -} - /** * Field-wise configured defaults for an agent through its declared base chain, * delegating precedence to the shared resolver. Values are "configured" only From 4fcea3d556170a49eb791c0188ac16e6eff20379 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:24:28 +0000 Subject: [PATCH 27/94] refactor: dedupe defensive unknown-field reads in the timeline mapper --- src/node/services/timelineMapper.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/node/services/timelineMapper.ts b/src/node/services/timelineMapper.ts index 7720f12ba1..287950894b 100644 --- a/src/node/services/timelineMapper.ts +++ b/src/node/services/timelineMapper.ts @@ -85,16 +85,20 @@ const MACHINE_AUTHORED_TURN_TYPES = new Set([ WORKFLOW_RESULT_METADATA_TYPE, ]); +// Only a non-null object has fields to read, so every other shape yields undefined rather than +// throwing. Callers narrow the returned `unknown` to the type they expect. +function readObjectField(value: unknown, field: string): unknown { + return typeof value === "object" && value !== null + ? (value as Record)[field] + : undefined; +} + // muxMetadata crosses the oRPC boundary as `any`, so read its string fields defensively. function readMuxMetadataField( metadata: Extract["metadata"], field: "type" | "source" | "runId" ): string | undefined { - const muxMetadata: unknown = metadata?.muxMetadata; - if (typeof muxMetadata !== "object" || muxMetadata === null) { - return undefined; - } - const value = (muxMetadata as Record)[field]; + const value = readObjectField(metadata?.muxMetadata, field); return typeof value === "string" ? value : undefined; } @@ -136,20 +140,13 @@ function isUnloggedMachineTurn( function readMonitorWakeProcesses( metadata: Extract["metadata"] ): string | undefined { - const muxMetadata: unknown = metadata?.muxMetadata; - const records: unknown = - typeof muxMetadata === "object" && muxMetadata !== null - ? (muxMetadata as Record).records - : undefined; + const records = readObjectField(metadata?.muxMetadata, "records"); if (!Array.isArray(records)) { return undefined; } const names = new Set(); for (const record of records) { - const displayName: unknown = - typeof record === "object" && record !== null - ? (record as Record).displayName - : undefined; + const displayName = readObjectField(record, "displayName"); if (typeof displayName === "string" && displayName !== "") { names.add(displayName); } From aa1e49166f8315c69fe96a29745e057e583e8f8d Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:55:50 +0000 Subject: [PATCH 28/94] refactor: share the mobile-touch media query constant The `(max-width: 768px) and (pointer: coarse)` literal that gates Mux's mobile affordances was copied into seven `window.matchMedia` callsites across five renderer files, each independently responsible for staying in sync with the matching `@media` block in globals.css. Hoist it to `MOBILE_TOUCH_MEDIA_QUERY` in `src/constants/layout.ts`, alongside `MOBILE_TOUCH_TARGET_PX`, which already documents the same coarse-pointer environment. Behavior-preserving: every callsite passed a byte-identical string, so each `matchMedia` call receives exactly the value it did before. --- src/browser/App.tsx | 4 ++-- .../components/WorkspaceMenuBar/WorkspaceMenuBar.tsx | 6 +++--- src/browser/components/WorkspaceShell/WorkspaceShell.tsx | 3 ++- src/browser/features/ChatInput/index.tsx | 7 +++---- src/browser/features/Messages/UserMessage.tsx | 4 ++-- src/constants/layout.ts | 6 ++++++ 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index f599e83d55..296a8b866b 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -45,6 +45,7 @@ import { LEFT_SIDEBAR_DEFAULT_WIDTH_PX, LEFT_SIDEBAR_MAX_WIDTH_PX, LEFT_SIDEBAR_MIN_WIDTH_PX, + MOBILE_TOUCH_MEDIA_QUERY, } from "@/constants/layout"; import { XUM_PRODUCT_SLUG } from "@/common/constants/product"; import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources"; @@ -233,8 +234,7 @@ function AppInner() { // because the sidebar width is controlled by CSS and shouldn't rewrite the user's desktop // width preference. const isMobileTouch = - typeof window !== "undefined" && - window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches; + typeof window !== "undefined" && window.matchMedia(MOBILE_TOUCH_MEDIA_QUERY).matches; if (isMobileTouch) { return Number.POSITIVE_INFINITY; } diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index c30d98b8b8..5afc086d4f 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -55,6 +55,7 @@ import { SCRATCH_PROJECT_CONFIG_KEY, SCRATCH_PROJECT_NAME } from "@/common/const import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities"; import { stopKeyboardPropagation } from "@/browser/utils/events"; import { + MOBILE_TOUCH_MEDIA_QUERY, NARROW_VIEWPORT_MAX_WIDTH_PX, WORKSPACE_MENU_BAR_LEFT_SIDEBAR_COLLAPSED_PADDING_PX, } from "@/constants/layout"; @@ -184,7 +185,7 @@ export const WorkspaceMenuBar: React.FC = ({ const handleOpenTerminal = useCallback(() => { // On mobile touch devices, always use popout since the right sidebar is hidden - const isMobileTouch = window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches; + const isMobileTouch = window.matchMedia(MOBILE_TOUCH_MEDIA_QUERY).matches; if (onOpenTerminal && !isMobileTouch) { onOpenTerminal(); } else { @@ -194,8 +195,7 @@ export const WorkspaceMenuBar: React.FC = ({ }, [workspaceId, openTerminalPopout, runtimeConfig, onOpenTerminal]); const isTouchMobileScreen = - typeof window !== "undefined" && - window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches; + typeof window !== "undefined" && window.matchMedia(MOBILE_TOUCH_MEDIA_QUERY).matches; // The right sidebar (home of the Timeline tab) is CSS-hidden by two independent // rules: a viewport media query (<=768px, any pointer) and the workspace-shell diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx index 10b2a336be..b340fb1cc2 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx @@ -24,6 +24,7 @@ import { LEFT_SIDEBAR_DEFAULT_WIDTH_PX, LEFT_SIDEBAR_MAX_WIDTH_PX, LEFT_SIDEBAR_MIN_WIDTH_PX, + MOBILE_TOUCH_MEDIA_QUERY, } from "@/constants/layout"; import { ChatPane } from "../ChatPane/ChatPane"; @@ -157,7 +158,7 @@ export const WorkspaceShell: React.FC = (props) => { const handleOpenTerminal = useCallback( (options?: TerminalSessionCreateOptions) => { // On mobile touch devices, always use popout since the right sidebar is hidden - const isMobileTouch = window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches; + const isMobileTouch = window.matchMedia(MOBILE_TOUCH_MEDIA_QUERY).matches; if (isMobileTouch) { void openTerminalPopout(props.workspaceId, props.runtimeConfig, options); } else { diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 9d1001e6df..330c4f2c0d 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -202,6 +202,7 @@ import { COMPOSER_WORKSPACE_ICON_ONLY_HIDE_CLASS, CHAT_DOCK_GUTTER_CLASS, CREATION_COLUMN_MAX_WIDTH_CLASS, + MOBILE_TOUCH_MEDIA_QUERY, } from "@/constants/layout"; import { useChatDockColumnWidthClass } from "@/browser/components/ChatPane/chatDockColumn"; import { prepareMessagePayload } from "./prepareMessagePayload"; @@ -294,14 +295,12 @@ const ChatInputInner: React.FC = (props) => { const isStreamStarting = variant === "workspace" ? (props.isStreamStarting ?? false) : false; const isCompacting = variant === "workspace" ? (props.isCompacting ?? false) : false; const [isMobileTouch, setIsMobileTouch] = useState( - () => - typeof window !== "undefined" && - window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches + () => typeof window !== "undefined" && window.matchMedia(MOBILE_TOUCH_MEDIA_QUERY).matches ); useEffect(() => { if (typeof window === "undefined") return; - const mobileTouchMediaQuery = window.matchMedia("(max-width: 768px) and (pointer: coarse)"); + const mobileTouchMediaQuery = window.matchMedia(MOBILE_TOUCH_MEDIA_QUERY); const handleMobileTouchChange = () => { setIsMobileTouch(mobileTouchMediaQuery.matches); }; diff --git a/src/browser/features/Messages/UserMessage.tsx b/src/browser/features/Messages/UserMessage.tsx index 285438beaa..2d5da0380d 100644 --- a/src/browser/features/Messages/UserMessage.tsx +++ b/src/browser/features/Messages/UserMessage.tsx @@ -18,6 +18,7 @@ import { } from "./SubagentReportMessageContent"; import { TerminalOutput } from "./TerminalOutput"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; +import { MOBILE_TOUCH_MEDIA_QUERY } from "@/constants/layout"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import { copyToClipboard } from "@/browser/utils/clipboard"; import { createDownloadRetryCache } from "@/browser/utils/downloadFile"; @@ -99,8 +100,7 @@ export const UserMessage: React.FC = ({ : visibleContent; const [vimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true }); const isMobileTouch = - typeof window !== "undefined" && - window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches; + typeof window !== "undefined" && window.matchMedia(MOBILE_TOUCH_MEDIA_QUERY).matches; const apiState = React.useContext(APIContext); const api = apiState?.api ?? null; diff --git a/src/constants/layout.ts b/src/constants/layout.ts index e0a8acf259..213da2e1c5 100644 --- a/src/constants/layout.ts +++ b/src/constants/layout.ts @@ -12,6 +12,12 @@ export const CREATION_COLUMN_MAX_WIDTH_CLASS = "max-w-[67rem]"; // inside it re-apply this gutter to land on the same edges as transcript rows. Tailwind scans source // text, so this has to stay a literal class string. export const CHAT_DOCK_GUTTER_CLASS = "px-[15px]"; + +// The viewport/pointer combination that gates Mux's mobile affordances. Must stay in sync with the +// matching `@media` block in globals.css: the renderer branches on this same environment through +// `window.matchMedia`, so a per-callsite copy of the literal can silently desync JS from CSS. +export const MOBILE_TOUCH_MEDIA_QUERY = "(max-width: 768px) and (pointer: coarse)"; + // Minimum height globals.css gives touch targets on coarse-pointer viewports. Shared so tests can // reproduce that environment, which Storybook and Pixel cannot: neither emulates `pointer: coarse`. export const MOBILE_TOUCH_TARGET_PX = 44; From 877146d76616009b8bbda32bd876bfe840de3b30 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:36:39 +0000 Subject: [PATCH 29/94] refactor: share the docked toast overlay placement class Three toast hosts hard-coded the same absolute overlay box, with a doc comment asserting they stay identical. Hoist it into src/constants/layout.ts so the invariant is enforced by the shared constant instead of by copy-paste. --- .../ConnectionStatusToast/ConnectionStatusToast.tsx | 8 +++----- src/browser/features/ChatInput/ChatInputToast.tsx | 6 ++---- src/browser/features/ChatInput/index.tsx | 3 ++- src/constants/layout.ts | 7 +++++++ 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/browser/components/ConnectionStatusToast/ConnectionStatusToast.tsx b/src/browser/components/ConnectionStatusToast/ConnectionStatusToast.tsx index c1adfaaa72..ce8e2b3c28 100644 --- a/src/browser/components/ConnectionStatusToast/ConnectionStatusToast.tsx +++ b/src/browser/components/ConnectionStatusToast/ConnectionStatusToast.tsx @@ -1,8 +1,6 @@ import React from "react"; import { useAPI } from "@/browser/contexts/API"; - -const wrapperClassName = - "pointer-events-none absolute right-[15px] bottom-full left-[15px] z-[1000] mb-2 [&>*]:pointer-events-auto"; +import { CHAT_DOCK_TOAST_OVERLAY_CLASS } from "@/constants/layout"; /** * Connection status banner that uses the same *overlay placement* as ChatInputToast. @@ -55,7 +53,7 @@ export const ConnectionStatusToast: React.FC = ({ wr if (!wrap) return content; - return
{content}
; + return
{content}
; } if (apiState.status === "error") { @@ -75,7 +73,7 @@ export const ConnectionStatusToast: React.FC = ({ wr if (!wrap) return content; - return
{content}
; + return
{content}
; } return null; diff --git a/src/browser/features/ChatInput/ChatInputToast.tsx b/src/browser/features/ChatInput/ChatInputToast.tsx index cbdbcb3adf..635fea44b6 100644 --- a/src/browser/features/ChatInput/ChatInputToast.tsx +++ b/src/browser/features/ChatInput/ChatInputToast.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { AlertTriangle, Check } from "lucide-react"; import React, { useEffect, useCallback } from "react"; import { cn } from "@/common/lib/utils"; +import { CHAT_DOCK_TOAST_OVERLAY_CLASS } from "@/constants/layout"; const toastTypeStyles: Record<"success" | "error", string> = { success: "bg-toast-success-bg border border-accent-dark text-toast-success-text", @@ -31,9 +32,6 @@ export const SolutionLabel: React.FC<{ children: ReactNode }> = ({ children }) =
{children}
); -const wrapperClassName = - "pointer-events-none absolute right-[15px] bottom-full left-[15px] z-[1000] mb-2 [&>*]:pointer-events-auto"; - export const ChatInputToast: React.FC = ({ toast, onDismiss, @@ -149,5 +147,5 @@ export const ChatInputToast: React.FC = ({ if (!wrap) return content; - return
{content}
; + return
{content}
; }; diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 330c4f2c0d..cd65d1c34f 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -201,6 +201,7 @@ import { COMPOSER_ICON_ONLY_HIDE_CLASS, COMPOSER_WORKSPACE_ICON_ONLY_HIDE_CLASS, CHAT_DOCK_GUTTER_CLASS, + CHAT_DOCK_TOAST_OVERLAY_CLASS, CREATION_COLUMN_MAX_WIDTH_CLASS, MOBILE_TOUCH_MEDIA_QUERY, } from "@/constants/layout"; @@ -2520,7 +2521,7 @@ const ChatInputInner: React.FC = (props) => { >
{/* Toasts (overlay) */} -
+
*]:pointer-events-auto"; + // The viewport/pointer combination that gates Mux's mobile affordances. Must stay in sync with the // matching `@media` block in globals.css: the renderer branches on this same environment through // `window.matchMedia`, so a per-callsite copy of the literal can silently desync JS from CSS. From 6e180798d7606c02f0b5b431c9eff2860f30e5b4 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:28:27 +0000 Subject: [PATCH 30/94] refactor: share the primary mouse button guard The composer dock focus handler added in #3759 repeated the bare `button !== 0` magic number already used by the diff review drag-select handler. Name the check once in browser/utils/events so both call sites read as intent. --- src/browser/features/Shared/DiffRenderer.tsx | 4 ++-- src/browser/utils/events.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/browser/features/Shared/DiffRenderer.tsx b/src/browser/features/Shared/DiffRenderer.tsx index e61dcdf46d..341366fb6f 100644 --- a/src/browser/features/Shared/DiffRenderer.tsx +++ b/src/browser/features/Shared/DiffRenderer.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { stopKeyboardPropagation } from "@/browser/utils/events"; +import { isPrimaryMouseButton, stopKeyboardPropagation } from "@/browser/utils/events"; import { cn } from "@/common/lib/utils"; import { getLanguageFromPath } from "@/common/utils/git/languageDetector"; import { useOverflowDetection } from "@/browser/hooks/useOverflowDetection"; @@ -1643,7 +1643,7 @@ export const SelectableDiffRenderer = React.memo( isInteractive={Boolean(onReviewNote ?? onLineIndexSelect)} onMouseDown={(e) => { if (!onReviewNote) return; - if (e.button !== 0) return; + if (!isPrimaryMouseButton(e)) return; e.preventDefault(); e.stopPropagation(); startDragSelection(displayIndex, e.shiftKey); diff --git a/src/browser/utils/events.ts b/src/browser/utils/events.ts index 3ed143808a..767a332a21 100644 --- a/src/browser/utils/events.ts +++ b/src/browser/utils/events.ts @@ -7,6 +7,17 @@ export function isEventFromDialogPortal(target: EventTarget | null): boolean { return target instanceof Element && target.closest('[role="dialog"]') != null; } +/** + * `MouseEvent.button === 0` is the primary button (left button under a default layout); + * every other value is a secondary or auxiliary press. Click-style mousedown handlers + * have to gate on it so right-click, middle-click, and back/forward presses don't run + * primary-click behavior. Shared so call sites state that intent instead of repeating + * the bare magic number. + */ +export function isPrimaryMouseButton(event: React.MouseEvent | MouseEvent): boolean { + return event.button === 0; +} + /** * Stop keyboard event propagation for both React synthetic events and native KeyboardEvents. * From 33e1597d01bdc571da5aeb944bac2c155e82b985 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:34:09 +0000 Subject: [PATCH 31/94] refactor: name ModelSelector row selection/highlight state The dropdown option row recomputed `value === model` four times and `index === highlightedIndex` twice across the option class, ARIA state, and accent styling. Extract them as `isSelected`/`isHighlighted` locals so the row's state is named once and can't drift apart, matching the naming already used by the sibling AgentModePicker. Behavior-preserving: identical expressions, same evaluation per row. --- .../components/ModelSelector/ModelSelector.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/browser/components/ModelSelector/ModelSelector.tsx b/src/browser/components/ModelSelector/ModelSelector.tsx index 3a1ef1cca6..5f4b62bd96 100644 --- a/src/browser/components/ModelSelector/ModelSelector.tsx +++ b/src/browser/components/ModelSelector/ModelSelector.tsx @@ -433,33 +433,34 @@ export const ModelSelector = forwardRef( const modelProvider = getModelProvider(model); const showProviderLabel = modelProvider.length > 0 && duplicateModelNames.has(modelName); + // Name the row's selection/highlight state once so the option class, ARIA state, + // and accent styling below can't drift apart (mirrors AgentModePicker). + const isHighlighted = index === highlightedIndex; + const isSelected = value === model; return (
setHighlightedIndex(index)} className={composerPickerOptionClass( - { - isHighlighted: index === highlightedIndex, - isSelected: value === model, - }, + { isHighlighted, isSelected }, "py-1", hiddenSet.has(model) && "opacity-50" )} onClick={() => handleSelectModel(model)} role="option" - aria-selected={value === model} + aria-selected={isSelected} > - + {formatModelDisplayName(modelName)} {showProviderLabel && ( From b401c6c81aa24d9305a3d1921b165c7d85cca117 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:36:34 +0000 Subject: [PATCH 32/94] refactor: share the workspace footer pill class The repository link added in #3762 duplicated the 'Last prompt' pill styling verbatim; extract it so the two footer affordances cannot drift apart. --- src/browser/components/ChatPane/WorkspaceFooterBar.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/browser/components/ChatPane/WorkspaceFooterBar.tsx b/src/browser/components/ChatPane/WorkspaceFooterBar.tsx index 1847a2a4d9..50411456eb 100644 --- a/src/browser/components/ChatPane/WorkspaceFooterBar.tsx +++ b/src/browser/components/ChatPane/WorkspaceFooterBar.tsx @@ -151,6 +151,11 @@ function WorkspaceBranchControls(props: { ); } +// Shared by the footer's interactive pills (repository link, "Last prompt") so they keep reading as +// one affordance family: restyling one silently drifting from the other is the failure mode here. +const FOOTER_PILL_CLASS = + "text-muted hover:bg-hover hover:text-foreground focus-visible:ring-accent flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 transition-colors focus-visible:ring-1"; + function FooterRepositoryLabel(props: { workspaceId: string; projectLabel: string }) { const workspacePR = useWorkspacePR(props.workspaceId); @@ -169,7 +174,7 @@ function FooterRepositoryLabel(props: { workspaceId: string; projectLabel: strin target="_blank" rel="noopener noreferrer" data-testid="workspace-footer-repository" - className="text-muted hover:bg-hover hover:text-foreground focus-visible:ring-accent flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 transition-colors focus-visible:ring-1" + className={FOOTER_PILL_CLASS} >
); } @@ -881,18 +887,8 @@ export function TimelinePanelView(props: TimelinePanelViewProps) { /> ); } - if (isRuleKind(getTimelineEventKind(item))) { - return ( - - ); - } return ( - Date: Sun, 16 Aug 2026 12:12:09 +0000 Subject: [PATCH 60/94] refactor: extract buildCommandReplacement for slash suggestion trailing space --- .../utils/slashCommands/suggestions.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/browser/utils/slashCommands/suggestions.ts b/src/browser/utils/slashCommands/suggestions.ts index 5d5545124c..13bf7c8835 100644 --- a/src/browser/utils/slashCommands/suggestions.ts +++ b/src/browser/utils/slashCommands/suggestions.ts @@ -32,6 +32,15 @@ function filterAndMapSuggestions( .map((definition) => build(definition)); } +/** + * Command replacements end in a space so the caret lands where arguments go; a definition opts out + * with `appendSpace: false` when the command is complete on its own. + */ +function buildCommandReplacement(base: string, definition: SuggestionDefinition): string { + const appendSpace = definition.appendSpace ?? true; + return `${base}${appendSpace ? " " : ""}`; +} + function buildTopLevelSuggestions( partial: string, context: SlashSuggestionContext @@ -40,13 +49,12 @@ function buildTopLevelSuggestions( COMMAND_DEFINITIONS, partial, (definition) => { - const appendSpace = definition.appendSpace ?? true; - const replacement = `/${definition.key}${appendSpace ? " " : ""}`; + const display = `/${definition.key}`; return { id: `command:${definition.key}`, - display: `/${definition.key}`, + display, description: definition.description, - replacement, + replacement: buildCommandReplacement(display, definition), }; }, (definition) => isSlashCommandVisible(definition, context) @@ -165,14 +173,13 @@ function buildSubcommandSuggestions( subcommands, partial, (definition) => { - const appendSpace = definition.appendSpace ?? true; const replacementTokens = [...prefixTokens, definition.key]; const replacementBase = `/${replacementTokens.join(" ")}`; return { id: `command:${replacementTokens.join(":")}`, display: definition.key, description: definition.description, - replacement: `${replacementBase}${appendSpace ? " " : ""}`, + replacement: buildCommandReplacement(replacementBase, definition), }; }, (definition) => isSlashCommandVisible(definition, context) From 64fa271d2751e63aaab372581269b5ad1a8b6706 Mon Sep 17 00:00:00 2001 From: mux Date: Sun, 16 Aug 2026 20:14:25 +0000 Subject: [PATCH 61/94] refactor: dedupe the pending patch-artifact seed in generate() Four writers in GitPatchArtifactService.generate() built the same "existing artifact, or a freshly seeded pending one" expression inline: the three workspace-shape guards (path/runtimeConfig/name missing) and ensureProjectArtifact. All four copies were byte-identical. Extract seedPendingArtifact(existing) plus failGeneration(error), which collapses each guard from a 20-line updateArtifact call to one line. The `??` short-circuit is preserved, so buildPendingProjectArtifacts is still only evaluated when no artifact has been persisted yet. The `!entry` guard and the outer catch keep their inline empty-project seeds: neither has a workspace entry in scope to enumerate projects from. --- src/node/services/gitPatchArtifactService.ts | 107 +++++++------------ 1 file changed, 36 insertions(+), 71 deletions(-) diff --git a/src/node/services/gitPatchArtifactService.ts b/src/node/services/gitPatchArtifactService.ts index fc4967cf48..d73c6ee32c 100644 --- a/src/node/services/gitPatchArtifactService.ts +++ b/src/node/services/gitPatchArtifactService.ts @@ -586,78 +586,56 @@ export class GitPatchArtifactService { const ws = entry.workspace; - const workspacePath = coerceNonEmptyString(ws.path); - if (!workspacePath) { + // Once the workspace entry is known, every writer below seeds the same + // pending artifact when none has been persisted yet: one pending entry + // per project repo in the task workspace. The `!entry` path above cannot + // use this because it has no workspace to enumerate projects from, and + // the outer catch seeds an empty list for the same reason. + const seedPendingArtifact = ( + existing: SubagentGitPatchArtifact | null + ): SubagentGitPatchArtifact => + existing ?? + buildPendingPatchArtifact({ + childTaskId: childWorkspaceId, + parentWorkspaceId, + createdAtMs: nowMs, + updatedAtMs: nowMs, + projectArtifacts: buildPendingProjectArtifacts({ + projectPath: entry.projectPath, + projects: ws.projects, + taskBaseCommitSha: coerceNonEmptyString(ws.taskBaseCommitSha) ?? undefined, + taskBaseCommitShaByProjectPath: ws.taskBaseCommitShaByProjectPath, + }), + }); + + // Marks every still-pending project artifact failed with the same reason. + // Used by the workspace-shape guards below, which cannot proceed to + // per-project patch generation at all. + const failGeneration = async (error: string): Promise => { await updateArtifact((existing) => failPendingProjectArtifacts({ - artifact: - existing ?? - buildPendingPatchArtifact({ - childTaskId: childWorkspaceId, - parentWorkspaceId, - createdAtMs: nowMs, - updatedAtMs: nowMs, - projectArtifacts: buildPendingProjectArtifacts({ - projectPath: entry.projectPath, - projects: ws.projects, - taskBaseCommitSha: coerceNonEmptyString(ws.taskBaseCommitSha) ?? undefined, - taskBaseCommitShaByProjectPath: ws.taskBaseCommitShaByProjectPath, - }), - }), - error: "Task workspace path missing.", + artifact: seedPendingArtifact(existing), + error, updatedAtMs: nowMs, }) ); + }; + + const workspacePath = coerceNonEmptyString(ws.path); + if (!workspacePath) { + await failGeneration("Task workspace path missing."); return; } if (!ws.runtimeConfig) { - await updateArtifact((existing) => - failPendingProjectArtifacts({ - artifact: - existing ?? - buildPendingPatchArtifact({ - childTaskId: childWorkspaceId, - parentWorkspaceId, - createdAtMs: nowMs, - updatedAtMs: nowMs, - projectArtifacts: buildPendingProjectArtifacts({ - projectPath: entry.projectPath, - projects: ws.projects, - taskBaseCommitSha: coerceNonEmptyString(ws.taskBaseCommitSha) ?? undefined, - taskBaseCommitShaByProjectPath: ws.taskBaseCommitShaByProjectPath, - }), - }), - error: "Task runtimeConfig missing.", - updatedAtMs: nowMs, - }) - ); + await failGeneration("Task runtimeConfig missing."); return; } const fallbackName = workspacePath.split("/").pop() ?? workspacePath.split("\\").pop() ?? ""; const workspaceName = coerceNonEmptyString(ws.name) ?? coerceNonEmptyString(fallbackName); if (!workspaceName) { - await updateArtifact((existing) => - failPendingProjectArtifacts({ - artifact: - existing ?? - buildPendingPatchArtifact({ - childTaskId: childWorkspaceId, - parentWorkspaceId, - createdAtMs: nowMs, - updatedAtMs: nowMs, - projectArtifacts: buildPendingProjectArtifacts({ - projectPath: entry.projectPath, - projects: ws.projects, - taskBaseCommitSha: coerceNonEmptyString(ws.taskBaseCommitSha) ?? undefined, - taskBaseCommitShaByProjectPath: ws.taskBaseCommitShaByProjectPath, - }), - }), - error: "Task workspace name missing.", - updatedAtMs: nowMs, - }) - ); + await failGeneration("Task workspace name missing."); return; } @@ -692,20 +670,7 @@ export class GitPatchArtifactService { nextProjectArtifact: SubagentGitProjectPatchArtifact ): Promise => { await updateArtifact((existing) => { - const pendingArtifact = - existing ?? - buildPendingPatchArtifact({ - childTaskId: childWorkspaceId, - parentWorkspaceId, - createdAtMs: nowMs, - updatedAtMs: nowMs, - projectArtifacts: buildPendingProjectArtifacts({ - projectPath: entry.projectPath, - projects: ws.projects, - taskBaseCommitSha: coerceNonEmptyString(ws.taskBaseCommitSha) ?? undefined, - taskBaseCommitShaByProjectPath: ws.taskBaseCommitShaByProjectPath, - }), - }); + const pendingArtifact = seedPendingArtifact(existing); return upsertProjectArtifact({ artifact: pendingArtifact, nextProjectArtifact, From 4fb9d4917eaf00ea70262e8dcad591ebf0613a74 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:12:45 +0000 Subject: [PATCH 62/94] refactor: dedupe blob content-address computation in BlobStore put() and get() each inlined the identical sha256 digest + `sha256:` prefix construction to derive a blob's content address. Extracted into a module-level blobRefFor() helper so both paths derive the ref through one expression. --- src/node/utils/journal/blobStore.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/node/utils/journal/blobStore.ts b/src/node/utils/journal/blobStore.ts index 633108eb25..14357983af 100644 --- a/src/node/utils/journal/blobStore.ts +++ b/src/node/utils/journal/blobStore.ts @@ -20,6 +20,16 @@ import type { BlobRef } from "@/common/types/durableEvent"; import { BlobRefSchema } from "@/common/types/durableEvent"; import { log } from "@/node/services/log"; +/** + * The content address (BlobRef) naming a buffer's bytes. put() uses it to name + * new content and get() uses it to verify bytes read back, and the two MUST + * derive it identically or a freshly written blob would fail its own hash + * check — so the digest and the `sha256:` prefix live in one place. + */ +function blobRefFor(content: Buffer): BlobRef { + return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; +} + export class BlobStore { constructor(private readonly dir: string) { assert(dir.length > 0, "BlobStore requires a directory"); @@ -37,8 +47,7 @@ export class BlobStore { ): Promise<{ ref: BlobRef; size: number; created: boolean }> { const buffer = typeof content === "string" ? Buffer.from(content, "utf-8") : Buffer.from(content); - const hash = crypto.createHash("sha256").update(buffer).digest("hex"); - const ref: BlobRef = `sha256:${hash}`; + const ref = blobRefFor(buffer); const blobPath = this.pathFor(ref); let existed = false; @@ -80,8 +89,7 @@ export class BlobStore { } throw error; } - const hash = crypto.createHash("sha256").update(buffer).digest("hex"); - if (`sha256:${hash}` !== ref) { + if (blobRefFor(buffer) !== ref) { log.warn(`BlobStore: hash mismatch for ${ref} (corrupted blob); treating as missing`); return null; } From e0817aa36417f97333b178866e506aa252b3964f Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:33:02 +0000 Subject: [PATCH 63/94] refactor: dedupe sub-agent status presentations into one table The taskExecutionStatus and taskStatus switches each carried their own copies of the Queued/Running/Completed/Interrupted presentation literals, so a label or icon class could drift between the two paths. Both now read from a single outcome-keyed table. --- .../SubAgentTasksDecoration.tsx | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/src/browser/components/SubAgentTasksDecoration/SubAgentTasksDecoration.tsx b/src/browser/components/SubAgentTasksDecoration/SubAgentTasksDecoration.tsx index cc2a64cf2f..3a22b2dc76 100644 --- a/src/browser/components/SubAgentTasksDecoration/SubAgentTasksDecoration.tsx +++ b/src/browser/components/SubAgentTasksDecoration/SubAgentTasksDecoration.tsx @@ -221,46 +221,63 @@ export function mergeActiveWorkflowGroups( return merged; } -export function getSubAgentStatusPresentation(workspace: FrontendWorkspaceMetadata): { +interface SubAgentStatusPresentation { label: string; icon: typeof Clock3; iconClassName: string; -} { +} + +/** + * Presentations are keyed by outcome rather than by status because the two status sources below + * (taskExecutionStatus for a reawakened run, taskStatus for the retained base report) map several + * distinct statuses onto the same outcome. Keeping one table avoids the duplicated object literals + * the two switches used to carry, where a label or class could silently drift between them. + */ +const SUB_AGENT_STATUS_PRESENTATIONS = { + queued: { label: "Queued", icon: Clock3, iconClassName: "text-muted" }, + starting: { label: "Starting", icon: LoaderCircle, iconClassName: "text-warning animate-spin" }, + running: { label: "Running", icon: LoaderCircle, iconClassName: "text-success animate-spin" }, + finishing: { label: "Finishing", icon: LoaderCircle, iconClassName: "text-warning animate-spin" }, + completed: { label: "Completed", icon: CheckCircle2, iconClassName: "text-success" }, + interrupted: { label: "Interrupted", icon: CircleSlash2, iconClassName: "text-muted" }, + failed: { label: "Failed", icon: CircleX, iconClassName: "text-danger" }, + inactive: { label: "Inactive", icon: CheckCircle2, iconClassName: "text-muted" }, +} satisfies Record; + +export function getSubAgentStatusPresentation( + workspace: FrontendWorkspaceMetadata +): SubAgentStatusPresentation { // No execution status (never-reawakened sub-agent) falls through to taskStatus. if (workspace.taskExecutionStatus !== undefined) { switch (workspace.taskExecutionStatus) { case "queued": - return { label: "Queued", icon: Clock3, iconClassName: "text-muted" }; + return SUB_AGENT_STATUS_PRESENTATIONS.queued; case "starting": case "running": - return { - label: "Running", - icon: LoaderCircle, - iconClassName: "text-success animate-spin", - }; + return SUB_AGENT_STATUS_PRESENTATIONS.running; case "completed": - return { label: "Completed", icon: CheckCircle2, iconClassName: "text-success" }; + return SUB_AGENT_STATUS_PRESENTATIONS.completed; case "interrupted": - return { label: "Interrupted", icon: CircleSlash2, iconClassName: "text-muted" }; + return SUB_AGENT_STATUS_PRESENTATIONS.interrupted; case "error": - return { label: "Failed", icon: CircleX, iconClassName: "text-danger" }; + return SUB_AGENT_STATUS_PRESENTATIONS.failed; } } switch (workspace.taskStatus) { case "queued": - return { label: "Queued", icon: Clock3, iconClassName: "text-muted" }; + return SUB_AGENT_STATUS_PRESENTATIONS.queued; case "starting": - return { label: "Starting", icon: LoaderCircle, iconClassName: "text-warning animate-spin" }; + return SUB_AGENT_STATUS_PRESENTATIONS.starting; case "running": - return { label: "Running", icon: LoaderCircle, iconClassName: "text-success animate-spin" }; + return SUB_AGENT_STATUS_PRESENTATIONS.running; case "awaiting_report": - return { label: "Finishing", icon: LoaderCircle, iconClassName: "text-warning animate-spin" }; + return SUB_AGENT_STATUS_PRESENTATIONS.finishing; case "reported": - return { label: "Completed", icon: CheckCircle2, iconClassName: "text-success" }; + return SUB_AGENT_STATUS_PRESENTATIONS.completed; case "interrupted": - return { label: "Interrupted", icon: CircleSlash2, iconClassName: "text-muted" }; + return SUB_AGENT_STATUS_PRESENTATIONS.interrupted; default: - return { label: "Inactive", icon: CheckCircle2, iconClassName: "text-muted" }; + return SUB_AGENT_STATUS_PRESENTATIONS.inactive; } } From f04cb1ddf3ccf95c21f9e3018a1e231a9706f8bf Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:33:54 +0000 Subject: [PATCH 64/94] refactor: dedupe the replay/lookup/watermark prelude in SessionTimingService Five per-event handlers (stream delta, reasoning delta, tool-call start/delta/end) each opened with the same four lines: drop replayed events, look up the workspace's active stream, bail when there is none, then extend lastEventTimestampMs. Extract touchActiveStream() so that shared bookkeeping lives in one place and the five handlers cannot drift apart. handleStreamStart keeps its own replay guard because it creates the state rather than looking it up; handleStreamAbort/handleStreamEnd are untouched because they never had the replay guard or the watermark bump. --- src/node/services/sessionTimingService.ts | 42 +++++++++++++---------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/node/services/sessionTimingService.ts b/src/node/services/sessionTimingService.ts index b43a30a914..454683b36c 100644 --- a/src/node/services/sessionTimingService.ts +++ b/src/node/services/sessionTimingService.ts @@ -749,12 +749,29 @@ export class SessionTimingService { this.emitChange(data.workspaceId); } - handleStreamDelta(data: StreamDeltaEvent): void { - if (data.replay === true) return; + /** + * Resolve the live stream state a real (non-replay) event belongs to, advancing the stream's + * last-event watermark. + * + * Every per-event handler below opens with this same bookkeeping: replayed events re-deliver + * history and must never re-time a stream, an event for a workspace with no active stream has + * nothing to update, and anything that survives both checks still counts as activity. Returns + * `null` when the caller should drop the event. + */ + private touchActiveStream( + data: Pick + ): ActiveStreamState | null { + if (data.replay === true) return null; const state = this.activeStreams.get(data.workspaceId); - if (!state) return; + if (!state) return null; state.lastEventTimestampMs = Math.max(state.lastEventTimestampMs, data.timestamp); + return state; + } + + handleStreamDelta(data: StreamDeltaEvent): void { + const state = this.touchActiveStream(data); + if (!state) return; const isFirstToken = data.delta.length > 0 && state.firstTokenTimeMs === null; if (isFirstToken) { @@ -773,12 +790,9 @@ export class SessionTimingService { } handleReasoningDelta(data: ReasoningDeltaEvent): void { - if (data.replay === true) return; - const state = this.activeStreams.get(data.workspaceId); + const state = this.touchActiveStream(data); if (!state) return; - state.lastEventTimestampMs = Math.max(state.lastEventTimestampMs, data.timestamp); - const isFirstToken = data.delta.length > 0 && state.firstTokenTimeMs === null; if (isFirstToken) { state.firstTokenTimeMs = data.timestamp; @@ -800,12 +814,9 @@ export class SessionTimingService { } handleToolCallStart(data: ToolCallStartEvent): void { - if (data.replay === true) return; - const state = this.activeStreams.get(data.workspaceId); + const state = this.touchActiveStream(data); if (!state) return; - state.lastEventTimestampMs = Math.max(state.lastEventTimestampMs, data.timestamp); - // Defensive: ignore duplicate tool-call-start events. if (state.pendingToolStarts.has(data.toolCallId)) { return; @@ -837,11 +848,9 @@ export class SessionTimingService { } handleToolCallDelta(data: ToolCallDeltaEvent): void { - if (data.replay === true) return; - const state = this.activeStreams.get(data.workspaceId); + const state = this.touchActiveStream(data); if (!state) return; - state.lastEventTimestampMs = Math.max(state.lastEventTimestampMs, data.timestamp); state.deltaStorage.addDelta({ tokens: data.tokens, timestamp: data.timestamp, @@ -852,12 +861,9 @@ export class SessionTimingService { } handleToolCallEnd(data: ToolCallEndEvent): void { - if (data.replay === true) return; - const state = this.activeStreams.get(data.workspaceId); + const state = this.touchActiveStream(data); if (!state) return; - state.lastEventTimestampMs = Math.max(state.lastEventTimestampMs, data.timestamp); - const start = state.pendingToolStarts.get(data.toolCallId); if (start === undefined) { this.emitChange(data.workspaceId); From 85b34a7ce164f0168371963e7fe0ae0f70370e69 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:22:31 +0000 Subject: [PATCH 65/94] refactor: dedupe analytics date-filter epoch round-trip The nine analytics hooks each repeated the same two-step conversion: narrow the Date filters to epoch milliseconds for a stable dependency array, then rebuild Dates inside the effect. Extract toEpochMs/fromEpochMs so the round-trip (and the reason for it) lives in one place. Behavior-preserving: both helpers are literal extractions of the duplicated expressions. --- src/browser/hooks/useAnalytics.ts | 85 ++++++++++++++++++------------- 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/src/browser/hooks/useAnalytics.ts b/src/browser/hooks/useAnalytics.ts index 346d285080..fbeb78e13e 100644 --- a/src/browser/hooks/useAnalytics.ts +++ b/src/browser/hooks/useAnalytics.ts @@ -41,6 +41,19 @@ interface DateFilterParams { to?: Date | null; } +// Every analytics hook takes `Date` filters but reads them inside an effect. A `Date` is a +// fresh reference on each render, so depending on it directly would re-fire the effect +// constantly. Each hook therefore narrows the filters to epoch milliseconds (a stable +// primitive) for its dependency array and rebuilds the `Date` inside the effect. These two +// helpers keep both halves of that round-trip in one place. +function toEpochMs(date: Date | null | undefined): number | null { + return date?.getTime() ?? null; +} + +function fromEpochMs(epochMs: number | null): Date | null { + return epochMs == null ? null : new Date(epochMs); +} + interface AnalyticsNamespace { getSummary: (input: SummaryInput) => Promise; getSpendOverTime: (input: SpendOverTimeInput) => Promise; @@ -154,8 +167,8 @@ export function useAnalyticsSummary( projectPath?: string | null, dateFilters?: DateFilterParams ): AsyncState { - const fromMs = dateFilters?.from?.getTime() ?? null; - const toMs = dateFilters?.to?.getTime() ?? null; + const fromMs = toEpochMs(dateFilters?.from); + const toMs = toEpochMs(dateFilters?.to); const { api } = useAPI(); const [state, setState] = useState>({ @@ -165,8 +178,8 @@ export function useAnalyticsSummary( }); useEffect(() => { - const fromDate = fromMs == null ? null : new Date(fromMs); - const toDate = toMs == null ? null : new Date(toMs); + const fromDate = fromEpochMs(fromMs); + const toDate = fromEpochMs(toMs); return runAnalyticsEffect(api, setState, (analyticsApi) => analyticsApi.getSummary({ projectPath: projectPath ?? null, from: fromDate, to: toDate }) ); @@ -187,8 +200,8 @@ export function useAnalyticsSpendOverTime(params: { "useAnalyticsSpendOverTime requires a valid granularity" ); - const fromMs = params.from?.getTime() ?? null; - const toMs = params.to?.getTime() ?? null; + const fromMs = toEpochMs(params.from); + const toMs = toEpochMs(params.to); const { api } = useAPI(); const [state, setState] = useState>({ @@ -198,8 +211,8 @@ export function useAnalyticsSpendOverTime(params: { }); useEffect(() => { - const fromDate = fromMs == null ? null : new Date(fromMs); - const toDate = toMs == null ? null : new Date(toMs); + const fromDate = fromEpochMs(fromMs); + const toDate = fromEpochMs(toMs); return runAnalyticsEffect(api, setState, (analyticsApi) => analyticsApi.getSpendOverTime({ projectPath: params.projectPath ?? null, @@ -217,8 +230,8 @@ export function useAnalyticsSpendOverTime(params: { export function useAnalyticsSpendByProject( dateFilters?: DateFilterParams ): AsyncState { - const fromMs = dateFilters?.from?.getTime() ?? null; - const toMs = dateFilters?.to?.getTime() ?? null; + const fromMs = toEpochMs(dateFilters?.from); + const toMs = toEpochMs(dateFilters?.to); const { api } = useAPI(); const [state, setState] = useState>({ @@ -228,8 +241,8 @@ export function useAnalyticsSpendByProject( }); useEffect(() => { - const fromDate = fromMs == null ? null : new Date(fromMs); - const toDate = toMs == null ? null : new Date(toMs); + const fromDate = fromEpochMs(fromMs); + const toDate = fromEpochMs(toMs); return runAnalyticsEffect(api, setState, (analyticsApi) => analyticsApi.getSpendByProject({ from: fromDate, to: toDate }) ); @@ -242,8 +255,8 @@ export function useAnalyticsSpendByModel( projectPath?: string | null, dateFilters?: DateFilterParams ): AsyncState { - const fromMs = dateFilters?.from?.getTime() ?? null; - const toMs = dateFilters?.to?.getTime() ?? null; + const fromMs = toEpochMs(dateFilters?.from); + const toMs = toEpochMs(dateFilters?.to); const { api } = useAPI(); const [state, setState] = useState>({ @@ -253,8 +266,8 @@ export function useAnalyticsSpendByModel( }); useEffect(() => { - const fromDate = fromMs == null ? null : new Date(fromMs); - const toDate = toMs == null ? null : new Date(toMs); + const fromDate = fromEpochMs(fromMs); + const toDate = fromEpochMs(toMs); return runAnalyticsEffect(api, setState, (analyticsApi) => analyticsApi.getSpendByModel({ projectPath: projectPath ?? null, from: fromDate, to: toDate }) ); @@ -267,8 +280,8 @@ export function useAnalyticsTokensByModel( projectPath?: string | null, dateFilters?: DateFilterParams ): AsyncState { - const fromMs = dateFilters?.from?.getTime() ?? null; - const toMs = dateFilters?.to?.getTime() ?? null; + const fromMs = toEpochMs(dateFilters?.from); + const toMs = toEpochMs(dateFilters?.to); const { api } = useAPI(); const [state, setState] = useState>({ @@ -278,8 +291,8 @@ export function useAnalyticsTokensByModel( }); useEffect(() => { - const fromDate = fromMs == null ? null : new Date(fromMs); - const toDate = toMs == null ? null : new Date(toMs); + const fromDate = fromEpochMs(fromMs); + const toDate = fromEpochMs(toMs); return runAnalyticsEffect(api, setState, (analyticsApi) => analyticsApi.getTokensByModel({ projectPath: projectPath ?? null, @@ -302,8 +315,8 @@ export function useAnalyticsTimingDistribution( "useAnalyticsTimingDistribution requires a valid metric" ); - const fromMs = dateFilters?.from?.getTime() ?? null; - const toMs = dateFilters?.to?.getTime() ?? null; + const fromMs = toEpochMs(dateFilters?.from); + const toMs = toEpochMs(dateFilters?.to); const { api } = useAPI(); const [state, setState] = useState>({ @@ -313,8 +326,8 @@ export function useAnalyticsTimingDistribution( }); useEffect(() => { - const fromDate = fromMs == null ? null : new Date(fromMs); - const toDate = toMs == null ? null : new Date(toMs); + const fromDate = fromEpochMs(fromMs); + const toDate = fromEpochMs(toMs); return runAnalyticsEffect(api, setState, (analyticsApi) => analyticsApi.getTimingDistribution({ metric, @@ -332,8 +345,8 @@ export function useAnalyticsProviderCacheHitRatio( projectPath?: string | null, dateFilters?: DateFilterParams ): AsyncState { - const fromMs = dateFilters?.from?.getTime() ?? null; - const toMs = dateFilters?.to?.getTime() ?? null; + const fromMs = toEpochMs(dateFilters?.from); + const toMs = toEpochMs(dateFilters?.to); const { api } = useAPI(); const [state, setState] = useState>({ @@ -343,8 +356,8 @@ export function useAnalyticsProviderCacheHitRatio( }); useEffect(() => { - const fromDate = fromMs == null ? null : new Date(fromMs); - const toDate = toMs == null ? null : new Date(toMs); + const fromDate = fromEpochMs(fromMs); + const toDate = fromEpochMs(toMs); return runAnalyticsEffect(api, setState, (analyticsApi) => analyticsApi.getCacheHitRatioByProvider({ projectPath: projectPath ?? null, @@ -361,8 +374,8 @@ export function useAnalyticsAgentCostBreakdown( projectPath?: string | null, dateFilters?: DateFilterParams ): AsyncState { - const fromMs = dateFilters?.from?.getTime() ?? null; - const toMs = dateFilters?.to?.getTime() ?? null; + const fromMs = toEpochMs(dateFilters?.from); + const toMs = toEpochMs(dateFilters?.to); const { api } = useAPI(); const [state, setState] = useState>({ @@ -372,8 +385,8 @@ export function useAnalyticsAgentCostBreakdown( }); useEffect(() => { - const fromDate = fromMs == null ? null : new Date(fromMs); - const toDate = toMs == null ? null : new Date(toMs); + const fromDate = fromEpochMs(fromMs); + const toDate = fromEpochMs(toMs); return runAnalyticsEffect(api, setState, (analyticsApi) => analyticsApi.getAgentCostBreakdown({ projectPath: projectPath ?? null, @@ -390,8 +403,8 @@ export function useAnalyticsDelegationSummary( projectPath?: string | null, dateFilters?: DateFilterParams ): AsyncState { - const fromMs = dateFilters?.from?.getTime() ?? null; - const toMs = dateFilters?.to?.getTime() ?? null; + const fromMs = toEpochMs(dateFilters?.from); + const toMs = toEpochMs(dateFilters?.to); const { api } = useAPI(); const [state, setState] = useState>({ @@ -401,8 +414,8 @@ export function useAnalyticsDelegationSummary( }); useEffect(() => { - const fromDate = fromMs == null ? null : new Date(fromMs); - const toDate = toMs == null ? null : new Date(toMs); + const fromDate = fromEpochMs(fromMs); + const toDate = fromEpochMs(toMs); return runAnalyticsEffect(api, setState, (analyticsApi) => analyticsApi.getDelegationSummary({ projectPath: projectPath ?? null, From 847e18d6219c29d4c8b5deacea5e0fbe5b6b66ea Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:30:11 +0000 Subject: [PATCH 66/94] refactor: dedupe budget-exceeded reporting in the run CLI --- src/cli/run.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index 7dc584c49d..dbcf4f2323 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -995,6 +995,17 @@ async function main(): Promise { // Budget tracking state let budgetExceeded = false; + // Three separate budget checks (stream-end, sub-agent usage roll-up, and usage-delta) all + // report an exceeded budget the same way; only whether the stream still needs interrupting + // differs. Share the reporting so the JSON event and the human-readable line cannot drift. + const reportBudgetExceeded = (cost: number, budgetLimit: number): void => { + budgetExceeded = true; + emitJsonLine({ type: "budget-exceeded", spent: cost, budget: budgetLimit }); + writeHumanLineClosed( + `\n${chalk.yellow(`Budget exceeded ($${cost.toFixed(2)} of $${budgetLimit.toFixed(2)}) - stopping`)}` + ); + }; + // Centralized output type tracking for spacing type OutputType = "none" | "text" | "thinking" | "tool"; let lastOutputType: OutputType = "none"; @@ -1315,10 +1326,7 @@ async function main(): Promise { } if (cost !== undefined && cost > budget) { - budgetExceeded = true; - const msg = `Budget exceeded ($${cost.toFixed(2)} of $${budget.toFixed(2)}) - stopping`; - emitJsonLine({ type: "budget-exceeded", spent: cost, budget }); - writeHumanLineClosed(`\n${chalk.yellow(msg)}`); + reportBudgetExceeded(cost, budget); // Don't interrupt - stream is already ending } } @@ -1352,10 +1360,7 @@ async function main(): Promise { } if (cost !== undefined && cost > budget) { - budgetExceeded = true; - const msg = `Budget exceeded ($${cost.toFixed(2)} of $${budget.toFixed(2)}) - stopping`; - emitJsonLine({ type: "budget-exceeded", spent: cost, budget }); - writeHumanLineClosed(`\n${chalk.yellow(msg)}`); + reportBudgetExceeded(cost, budget); void session.interruptStream({ abandonPartial: false }); } } @@ -1393,10 +1398,7 @@ async function main(): Promise { } if (cost !== undefined && cost > budget) { - budgetExceeded = true; - const msg = `Budget exceeded ($${cost.toFixed(2)} of $${budget.toFixed(2)}) - stopping`; - emitJsonLine({ type: "budget-exceeded", spent: cost, budget }); - writeHumanLineClosed(`\n${chalk.yellow(msg)}`); + reportBudgetExceeded(cost, budget); void session.interruptStream({ abandonPartial: false }); } } From 8d802e735142071cb8c7bc8410a8ccf3881b4f77 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:31:17 +0000 Subject: [PATCH 67/94] refactor: dedupe the oversized pass-through gate in transformMCPResult --- src/node/services/mcpResultTransform.ts | 34 ++++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/node/services/mcpResultTransform.ts b/src/node/services/mcpResultTransform.ts index 0ac3fcc45d..045bd1810e 100644 --- a/src/node/services/mcpResultTransform.ts +++ b/src/node/services/mcpResultTransform.ts @@ -182,6 +182,24 @@ function jsonByteLength(value: unknown): number { } } +/** + * Oversize gate shared by the pass-through result shapes (non-standard + * `toolResult`, content-less objects). Returns the serialized size when the + * whole result exceeds the text cap and must be replaced by a bounded notice, + * or null when it fits and can pass through untouched. + */ +function oversizedPassthroughBytes(result: unknown, logMessage: string): number | null { + const size = jsonByteLength(result); + if (size <= MCP_TOOL_RESULT_MAX_TEXT_BYTES) { + return null; + } + log.warn(logMessage, { + size, + cap: MCP_TOOL_RESULT_MAX_TEXT_BYTES, + }); + return size; +} + /** Binary media guard shared by image/audio content and blob resources. */ function toGuardedMediaPart( kind: string, @@ -234,28 +252,20 @@ export function transformMCPResult(result: unknown): unknown { // If it has toolResult (non-standard result shape), pass through as-is when // it fits the cap; otherwise replace it with a bounded notice. if (typed.toolResult !== undefined) { - const size = jsonByteLength(result); - if (size <= MCP_TOOL_RESULT_MAX_TEXT_BYTES) { + const size = oversizedPassthroughBytes(result, "[MCP] toolResult too large, omitting"); + if (size === null) { return result; } - log.warn("[MCP] toolResult too large, omitting", { - size, - cap: MCP_TOOL_RESULT_MAX_TEXT_BYTES, - }); return { toolResult: omittedValueNotice("toolResult", size) }; } // If no content array, pass through when it fits the cap; otherwise replace // with a bounded notice in MCP text shape so toModelOutput surfaces it. if (!typed.content || !Array.isArray(typed.content)) { - const size = jsonByteLength(result); - if (size <= MCP_TOOL_RESULT_MAX_TEXT_BYTES) { + const size = oversizedPassthroughBytes(result, "[MCP] tool result too large, omitting"); + if (size === null) { return result; } - log.warn("[MCP] tool result too large, omitting", { - size, - cap: MCP_TOOL_RESULT_MAX_TEXT_BYTES, - }); return { content: [{ type: "text", text: omittedValueNotice("tool result", size) }] }; } From 33feab5f41df3451b3ac9b066d9551f39c961175 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:21:31 +0000 Subject: [PATCH 68/94] refactor: use centralized isErrnoWithCode in agentSession agentSession.ts open-coded the same errno narrow-and-compare three times: const errno = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; joining the two in loadAutoRetryEnabledPreference and persistAutoRetryState. src/node/utils/fs.ts already exports isErrnoWithCode for exactly this pattern ("Centralised because fs / runtime callers across the node layer need this exact narrow-and-compare pattern and previously each open-coded it"), and it is used at ~20 other node-layer call sites. agentSession was the remaining outlier. Behavior-preserving: isErrnoWithCode(error, "ENOENT") is equivalent to errno === "ENOENT" (the extra `error &&` truthiness guard only excludes null, which the original ruled out via `error !== null`), and !isErrnoWithCode(...) is equivalent to errno !== "ENOENT". The three-way branch in loadAutoRetryEnabledPreference keeps a single local (isMissingPreferenceFile) so the check is still evaluated once. --- src/node/services/agentSession.ts | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index cb3202d755..635ded587a 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -188,6 +188,7 @@ import { } from "@/node/services/branchSummary"; import type { Runtime } from "@/node/runtime/Runtime"; import type { XumToolScope } from "@/common/types/toolScope"; +import { isErrnoWithCode } from "@/node/utils/fs"; import { execBuffered } from "@/node/utils/runtime/helpers"; import { renderAgentSkillSnapshotText } from "@/common/utils/agentSkills/skillSnapshot"; import type { MemorySessionContext } from "@/node/services/memoryService"; @@ -459,11 +460,7 @@ export async function clearProviderConfigFixableAbandonMarkers( try { entries = await readdir(sessionsDir, { withFileTypes: true }); } catch (error) { - const errno = - typeof error === "object" && error !== null && "code" in error - ? (error as { code?: unknown }).code - : undefined; - if (errno === "ENOENT") { + if (isErrnoWithCode(error, "ENOENT")) { return; } throw error; @@ -1412,23 +1409,20 @@ export class AgentSession { } catch (error) { // Missing preference file is the default path. Use any legacy frontend hint // (captured at onChat subscribe time) before falling back to enabled. - const errno = - typeof error === "object" && error !== null && "code" in error - ? (error as { code?: unknown }).code - : undefined; + const isMissingPreferenceFile = isErrnoWithCode(error, "ENOENT"); const defaultEnabled = - errno === "ENOENT" && this.legacyAutoRetryEnabledHint === false ? false : true; + isMissingPreferenceFile && this.legacyAutoRetryEnabledHint === false ? false : true; this.autoRetryEnabledPreference = defaultEnabled; this.legacyAutoRetryEnabledHint = null; this.startupAutoRetryAbandon = null; this.retryManager.setEnabled(defaultEnabled); - if (errno === "ENOENT" && defaultEnabled === false) { + if (isMissingPreferenceFile && defaultEnabled === false) { // Persist migrated legacy opt-out so restart behavior no longer depends // on renderer localStorage keys. await this.persistAutoRetryState(); - } else if (errno !== "ENOENT") { + } else if (!isMissingPreferenceFile) { log.warn("Failed to load auto-retry preference; defaulting to enabled", { workspaceId: this.workspaceId, error: getErrorMessage(error), @@ -1448,11 +1442,7 @@ export class AgentSession { try { await unlink(preferencePath); } catch (error) { - const errno = - typeof error === "object" && error !== null && "code" in error - ? (error as { code?: unknown }).code - : undefined; - if (errno !== "ENOENT") { + if (!isErrnoWithCode(error, "ENOENT")) { log.debug("Failed to clear auto-retry preference file", { workspaceId: this.workspaceId, error: getErrorMessage(error), From 6a96d319a6979356f1e3242df6acde1336d8014e Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:11:18 +0000 Subject: [PATCH 69/94] refactor: dedupe canonical/legacy plan path resolution --- src/node/utils/runtime/helpers.ts | 35 +++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/node/utils/runtime/helpers.ts b/src/node/utils/runtime/helpers.ts index 985fc71811..f31134a28c 100644 --- a/src/node/utils/runtime/helpers.ts +++ b/src/node/utils/runtime/helpers.ts @@ -122,6 +122,23 @@ export interface ReadPlanResult { path: string; } +/** + * Canonical (per-project) and legacy (by workspaceId) plan paths for a workspace. + * Both readers must agree on these locations, so resolve them in one place. + */ +function getPlanFilePaths( + runtime: Runtime, + workspaceName: string, + projectName: string, + workspaceId: string +): { planPath: string; legacyPath: string } { + const xumHome = runtime.getXumHome(); + return { + planPath: getPlanFilePath(workspaceName, projectName, xumHome), + legacyPath: getLegacyPlanFilePath(workspaceId, xumHome), + }; +} + /** * Read plan file content, checking new path first then legacy, migrating if needed. * This handles the transparent migration from {runtimeHome}/plans/{id}.md to @@ -133,9 +150,12 @@ export async function readPlanFile( projectName: string, workspaceId: string ): Promise { - const xumHome = runtime.getXumHome(); - const planPath = getPlanFilePath(workspaceName, projectName, xumHome); - const legacyPath = getLegacyPlanFilePath(workspaceId, xumHome); + const { planPath, legacyPath } = getPlanFilePaths( + runtime, + workspaceName, + projectName, + workspaceId + ); // Resolve tilde to absolute path for client use (editor deep links, etc.) // For local runtimes this expands ~ to /home/user; for SSH it resolves remotely @@ -191,9 +211,12 @@ export async function hasNonEmptyPlanFile( return false; } - const xumHome = runtime.getXumHome(); - const planPath = getPlanFilePath(workspaceName, projectName, xumHome); - const legacyPath = getLegacyPlanFilePath(workspaceId, xumHome); + const { planPath, legacyPath } = getPlanFilePaths( + runtime, + workspaceName, + projectName, + workspaceId + ); for (const candidatePath of [planPath, legacyPath]) { try { From cce024163cbfb781886e8a0ddcf3cff4ce75c155 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:12:29 +0000 Subject: [PATCH 70/94] refactor: name the instruction-file read flags readSingleFile took four trailing positional args (scope, isLocal, projectName, xumOnly), producing call sites like `false, undefined, false` that gave no hint which flag is which. Group them into a named InstructionFileTags object. Behavior-preserving; all call sites are module-private. --- src/node/utils/main/instructionFiles.ts | 86 ++++++++++++------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/src/node/utils/main/instructionFiles.ts b/src/node/utils/main/instructionFiles.ts index d8ba9cd902..a03bb1b8b6 100644 --- a/src/node/utils/main/instructionFiles.ts +++ b/src/node/utils/main/instructionFiles.ts @@ -66,15 +66,27 @@ function createRuntimeFileReader(runtime: Runtime): FileReader { type ReadInstructionFileResult = { exists: false } | { exists: true; file: InstructionFile | null }; +/** + * Metadata stamped onto a file read by {@link readSingleFile}. Named fields keep + * call sites readable: the flags are otherwise indistinguishable positional + * booleans. + */ +interface InstructionFileTags { + scope: InstructionScope; + /** True for `.local.md` companions that layer on top of a base file. */ + isLocal: boolean; + /** Project name (only meaningful for "project" scope). */ + projectName: string | undefined; + /** True when the file is Xum-dedicated, so scoped Model:/Mode: directives apply. */ + xumOnly: boolean; +} + /** Read a single instruction file via the given reader, returning structured info. */ async function readSingleFile( reader: FileReader, directory: string, filename: string, - scope: InstructionScope, - isLocal: boolean, - projectName: string | undefined, - xumOnly: boolean + tags: InstructionFileTags ): Promise { let raw: string; try { @@ -89,10 +101,10 @@ async function readSingleFile( file: { path: path.join(directory, filename), filename, - isLocal, - xumOnly, - scope, - projectName: projectName ?? null, + isLocal: tags.isLocal, + xumOnly: tags.xumOnly, + scope: tags.scope, + projectName: tags.projectName ?? null, content: sanitized, bytes: Buffer.byteLength(sanitized, "utf-8"), tokens: null, @@ -104,20 +116,10 @@ async function readSingleFile( async function readBaseInstructionFile( reader: FileReader, directory: string, - scope: InstructionScope, - projectName: string | undefined, - xumOnly: boolean + tags: Omit ): Promise { for (const filename of INSTRUCTION_FILE_NAMES) { - const result = await readSingleFile( - reader, - directory, - filename, - scope, - false, - projectName, - xumOnly - ); + const result = await readSingleFile(reader, directory, filename, { ...tags, isLocal: false }); // Existence, not post-comment content, decides base-file priority. This // preserves the historical behavior where an AGENTS.md containing only // comments still enables AGENTS.local.md and prevents lower-priority @@ -146,18 +148,19 @@ async function readInstructionSetWith( // are honored there and we must not look for a nested ~/.xum/.xum/AGENTS.md. const isGlobalScope = scope === INSTRUCTION_SCOPE.GLOBAL; - const base = await readBaseInstructionFile(reader, directory, scope, projectName, isGlobalScope); + const base = await readBaseInstructionFile(reader, directory, { + scope, + projectName, + xumOnly: isGlobalScope, + }); const local = base.exists - ? await readSingleFile( - reader, - directory, - LOCAL_INSTRUCTION_FILENAME, + ? await readSingleFile(reader, directory, LOCAL_INSTRUCTION_FILENAME, { scope, - true, + isLocal: true, projectName, - isGlobalScope - ) + xumOnly: isGlobalScope, + }) : ({ exists: false } satisfies ReadInstructionFileResult); // Read one Xum-dedicated companion tree, preferring .xum and falling back @@ -167,24 +170,18 @@ async function readInstructionSetWith( if (!isGlobalScope) { for (const relativeDirectory of listProjectMetadataRelativePaths("")) { const dedicatedDirectory = path.join(directory, relativeDirectory); - dedicatedBase = await readSingleFile( - reader, - dedicatedDirectory, - XUM_INSTRUCTION_FILENAME, + dedicatedBase = await readSingleFile(reader, dedicatedDirectory, XUM_INSTRUCTION_FILENAME, { scope, - false, + isLocal: false, projectName, - true - ); + xumOnly: true, + }); if (!dedicatedBase.exists) continue; dedicatedLocal = await readSingleFile( reader, dedicatedDirectory, LOCAL_INSTRUCTION_FILENAME, - scope, - true, - projectName, - true + { scope, isLocal: true, projectName, xumOnly: true } ); break; } @@ -253,10 +250,13 @@ export async function readClaudeCompatGlobalInstructionSet( createLocalFileReader(), resolvedDirectory, CLAUDE_COMPAT_GLOBAL_INSTRUCTION_FILENAME, - INSTRUCTION_SCOPE.GLOBAL, - false, - undefined, - false + { + scope: INSTRUCTION_SCOPE.GLOBAL, + isLocal: false, + projectName: undefined, + // Shared with Claude Code, so scoped Model:/Mode: headings stay plain markdown. + xumOnly: false, + } ); if (!result.exists || !result.file) return null; From 69969ce8f322555c484c7b2b33226abfd21cd826 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:16:40 +0000 Subject: [PATCH 71/94] refactor: extract configContentSignature helper for corrupt-config backup gate --- src/node/config/index.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/node/config/index.ts b/src/node/config/index.ts index c44bb04cb8..07d7f976cd 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -863,6 +863,18 @@ interface ConfigLoadFailureState { backupSignature: string | null; } +/** + * Hash config bytes for `ConfigLoadFailureState.backupSignature`. + * + * Both producers of that signature must hash identically: the failed load records the + * corrupt bytes it backed up, and the edit gate re-hashes the file on disk and compares + * the two. Sharing one helper keeps that comparison meaningful, since any drift between + * two inlined hashes would silently make every signature mismatch and block edits. + */ +function configContentSignature(bytes: Buffer): string { + return crypto.createHash("sha256").update(bytes).digest("hex"); +} + // Process-scoped, keyed by config file path: production creates short-lived Config // instances (e.g. runtimeFactory per runtime check), so instance-local dedupe would // re-log the same corrupt-config error once per instance. @@ -1011,8 +1023,7 @@ export class Config { const errorMessage = error instanceof Error ? error.message : String(error); // Backup confirmation is keyed on content alone: the same corrupt bytes need only one // sidecar regardless of which error message they produced. - const contentSignature = - rawBytes !== undefined ? crypto.createHash("sha256").update(rawBytes).digest("hex") : null; + const contentSignature = rawBytes !== undefined ? configContentSignature(rawBytes) : null; // Re-verify preservation against the disk on every failed load rather than trusting a // cached confirmation: a sidecar deleted or truncated since the last load must re-block @@ -2444,7 +2455,7 @@ export class Config { let currentSignature: string | null = null; try { const currentBytes = fs.readFileSync(this.configFile); - currentSignature = crypto.createHash("sha256").update(currentBytes).digest("hex"); + currentSignature = configContentSignature(currentBytes); } catch (readError) { if ((readError as NodeJS.ErrnoException).code !== "ENOENT") { rejectEdit( From a342741ec5aae1487e86f22fec50396e8538f898 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:11:33 +0000 Subject: [PATCH 72/94] refactor: extract rollBackInitializedGitDir helper for git-init rollback --- src/node/services/projectService.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 4830dd54ec..df8596d7b5 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -340,6 +340,21 @@ function friendlyFsError(error: unknown, action: string, targetPath: string): st } } +/** + * Best-effort rollback of a `.git` directory this process just created. Every + * git-init failure path must restore the directory to its prior state (so a retry + * is not rejected as non-empty, and a concurrent winner does not inherit a + * repository it never asked for), but the caller is already returning the real + * error, so a cleanup failure is only logged. + */ +async function rollBackInitializedGitDir(normalizedPath: string): Promise { + await fsPromises + .rm(path.join(normalizedPath, ".git"), { recursive: true, force: true }) + .catch((cleanupError: unknown) => { + log.error(`Failed to roll back git init in ${normalizedPath}:`, cleanupError); + }); +} + async function resolveRealProjectPath(projectPath: string): Promise { return stripTrailingSlashes(await fsPromises.realpath(projectPath)); } @@ -727,11 +742,7 @@ export class ProjectService { // .git this losing request created would silently turn the winner's project // into a repository it never asked for, or wrap its checkout in an // unregistered outer repository that changes git discovery. - await fsPromises - .rm(path.join(normalizedPath, ".git"), { recursive: true, force: true }) - .catch((cleanupError: unknown) => { - log.error(`Failed to roll back git init in ${normalizedPath}:`, cleanupError); - }); + await rollBackInitializedGitDir(normalizedPath); } if (createResult.success && !this.config.loadConfigOrDefault().projects.has(normalizedPath)) { // Config persistence (editConfig → private saveConfig) logs-and-continues on @@ -1598,11 +1609,7 @@ export class ProjectService { // the directory returns to its prior state and a retry is not rejected as // non-empty (e.g. when the initial commit fails). if (initializedGitDir) { - await fsPromises - .rm(path.join(normalizedPath, ".git"), { recursive: true, force: true }) - .catch((cleanupError: unknown) => { - log.error(`Failed to roll back git init in ${normalizedPath}:`, cleanupError); - }); + await rollBackInitializedGitDir(normalizedPath); } const message = getErrorMessage(error); log.error("Failed to initialize git repository:", error); From 5c9bde9aa164dbb8c98162ba18aace97d7a59122 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:11:46 +0000 Subject: [PATCH 73/94] refactor: dedupe the magnitude-shift bound check in update-models validation --- src/common/utils/tokens/updateModelsData.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/common/utils/tokens/updateModelsData.ts b/src/common/utils/tokens/updateModelsData.ts index ac2bcd275c..48a288282c 100644 --- a/src/common/utils/tokens/updateModelsData.ts +++ b/src/common/utils/tokens/updateModelsData.ts @@ -287,6 +287,16 @@ function belowBaseline(count: number, baseline: number): boolean { ); } +/** + * Whether a candidate value drifted beyond MAX_MAGNITUDE_SHIFT_FACTOR from a + * positive `baseline` in either direction. A missing candidate (`null`) reads + * as 0, which always trips the lower bound. Callers must pass a baseline > 0. + */ +function exceedsMagnitudeShift(candidate: number | null, baseline: number): boolean { + const ratio = candidate === null ? 0 : candidate / baseline; + return ratio > MAX_MAGNITUDE_SHIFT_FACTOR || ratio < 1 / MAX_MAGNITUDE_SHIFT_FACTOR; +} + /** * Throws when the sanitized upstream data is unfit to replace the vendored * models.json. `baselineCatalog` is the currently vendored catalog (defaults @@ -336,7 +346,7 @@ export function validateModelData( // catalog could retain every field while rescaling values (prices toward // zero or absurdly high, token limits toward 1). A collapsed sample count // (e.g. every price zeroed) fails both the sample shrink and the median - // check, since a zero median is more than MAX_MEDIAN_SHIFT_FACTOR below any + // check, since a zero median is more than MAX_MAGNITUDE_SHIFT_FACTOR below any // positive baseline. for (const [field, baselineStats] of Object.entries(baseline.numericFields)) { if (baselineStats.samples < MIN_BASELINE_FOR_SHRINK_CHECK || baselineStats.median <= 0) { @@ -344,8 +354,7 @@ export function validateModelData( } const candidate = summary.numericFields[field] ?? { samples: 0, median: 0 }; shrinkChecks.push([`${field} positive-value count`, candidate.samples, baselineStats.samples]); - const ratio = candidate.median / baselineStats.median; - if (ratio > MAX_MAGNITUDE_SHIFT_FACTOR || ratio < 1 / MAX_MAGNITUDE_SHIFT_FACTOR) { + if (exceedsMagnitudeShift(candidate.median, baselineStats.median)) { errors.push( `${field} median shifted from ${baselineStats.median} to ${candidate.median} ` + `(more than ${MAX_MAGNITUDE_SHIFT_FACTOR}x from the vendored baseline; possible corruption)` @@ -403,8 +412,7 @@ export function validateModelData( continue; } const candidateNum = parseNum(entry[field]); - const ratio = candidateNum === null ? 0 : candidateNum / baselineNum; - if (ratio > MAX_MAGNITUDE_SHIFT_FACTOR || ratio < 1 / MAX_MAGNITUDE_SHIFT_FACTOR) { + if (exceedsMagnitudeShift(candidateNum, baselineNum)) { entryShifts.push(`${id} ${field}: ${baselineNum} -> ${candidateNum ?? "absent"}`); } } From 23033740b4e1074b885e0276a66e236133e4a8d0 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:10:10 +0000 Subject: [PATCH 74/94] refactor: dedupe app-bundle discovery in mac attach-file smoke check --- scripts/checkMacAttachFileRuntime.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/scripts/checkMacAttachFileRuntime.ts b/scripts/checkMacAttachFileRuntime.ts index 1d9649f6f8..1d83af2897 100644 --- a/scripts/checkMacAttachFileRuntime.ts +++ b/scripts/checkMacAttachFileRuntime.ts @@ -64,15 +64,9 @@ async function findAppBundles(rootDir: string): Promise<{ matches: string[]; see return { matches, seen }; } -async function chooseDefaultAppBundle(): Promise { - const { matches: appBundles, seen } = await findAppBundles(RELEASE_DIR); - assert( - appBundles.length > 0, - `No ${APP_NAME} found under ${RELEASE_DIR}. Run make dist-mac first. Stored .app names: ${ - seen.length > 0 ? seen.join(", ") : "(none)" - }` - ); - +// Takes the already-discovered bundles so callers do not re-walk the release +// tree (and re-run the identical "no bundle found" assert) just to pick one. +function chooseDefaultAppBundle(appBundles: readonly string[]): string { const preferredSuffixes = process.arch === "arm64" ? [ @@ -94,7 +88,7 @@ async function chooseDefaultAppBundle(): Promise { } } - return appBundles.sort()[0]!; + return [...appBundles].sort()[0]!; } async function findFileMatching(rootDir: string, pattern: RegExp): Promise { @@ -304,7 +298,7 @@ async function main(): Promise { }` ); appBundles = matches; - smokeAppBundle = await chooseDefaultAppBundle(); + smokeAppBundle = chooseDefaultAppBundle(matches); } const verifiedArchitectures = new Set(); From dbab3c72280f1ac4dd4d96713e4da3de5d037d4c Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:22:16 +0000 Subject: [PATCH 75/94] refactor: dedupe terminal badge config patching in GeneralSection The five terminal-badge handlers added by #3937 each repeated the same `setTerminalBadgeConfig((prev) => ({ ...normalizeTerminalBadgeConfig(prev), }))` dance. Funnel them through one patchTerminalBadgeConfig helper so the normalize-then-spread step is stated once and cannot drift between fields. --- .../Settings/Sections/GeneralSection.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 5eaf67cb33..8929d380f4 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -480,16 +480,25 @@ export function GeneralSection() { setEditorConfig((prev) => ({ ...normalizeEditorConfig(prev), customCommand })); }; + // Every badge edit patches a single field onto the normalized previous + // config. Funnel them through one helper so the normalize-then-spread step + // is stated once and cannot drift between fields — persisted configs may + // predate a field, so normalizing before the patch is what keeps the stored + // value whole. + const patchTerminalBadgeConfig = (patch: Partial) => { + setTerminalBadgeConfig((prev) => ({ ...normalizeTerminalBadgeConfig(prev), ...patch })); + }; + const handleTerminalBadgeEnabledChange = (enabled: boolean) => { - setTerminalBadgeConfig((prev) => ({ ...normalizeTerminalBadgeConfig(prev), enabled })); + patchTerminalBadgeConfig({ enabled }); }; const handleTerminalBadgeTemplateChange = (template: string) => { - setTerminalBadgeConfig((prev) => ({ ...normalizeTerminalBadgeConfig(prev), template })); + patchTerminalBadgeConfig({ template }); }; const handleTerminalBadgePositionChange = (position: TerminalBadgePosition) => { - setTerminalBadgeConfig((prev) => ({ ...normalizeTerminalBadgeConfig(prev), position })); + patchTerminalBadgeConfig({ position }); }; const handleTerminalBadgeOpacityChange = (rawValue: string) => { @@ -498,10 +507,7 @@ export function GeneralSection() { return; } - setTerminalBadgeConfig((prev) => ({ - ...normalizeTerminalBadgeConfig(prev), - opacity: parsed / 100, - })); + patchTerminalBadgeConfig({ opacity: parsed / 100 }); }; const handleTerminalBadgeFontSizeChange = (rawValue: string) => { @@ -510,7 +516,7 @@ export function GeneralSection() { return; } - setTerminalBadgeConfig((prev) => ({ ...normalizeTerminalBadgeConfig(prev), fontSize: parsed })); + patchTerminalBadgeConfig({ fontSize: parsed }); }; const handleSshHostChange = useCallback( From b23485ea0a7f4b43910af4a0bf344ffaf5b5c277 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:13:55 +0000 Subject: [PATCH 76/94] refactor: drop vestigial handleJumpToBottom alias in ChatPane #3795 removed the wrapper's only body statement (clearActiveSideQuestionScrollHold), leaving `const handleJumpToBottom = jumpToBottom` as a pure identity alias. #3948 then added a cancel-edit call that used jumpToBottom() directly, so the file mixed both spellings of the same reference. Use jumpToBottom everywhere and delete the alias. --- src/browser/components/ChatPane/ChatPane.tsx | 26 +++++++++----------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 508c714b89..495af9787f 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -740,8 +740,6 @@ const ChatPaneContent: React.FC = (props) => { [handleScrollContainerKeyDown, isComposerDockEvent] ); - const handleJumpToBottom = jumpToBottom; - // Handler to navigate (scroll) to a specific message by historyId const handleNavigateToMessage = useCallback( (historyId: string) => { @@ -1013,8 +1011,8 @@ const ChatPaneContent: React.FC = (props) => { // send success can be too late because the backend may not resolve until the // stream has already produced rows, leaving the first deltas offscreen when the // user had previously scrolled up. - handleJumpToBottom(); - }, [handleJumpToBottom]); + jumpToBottom(); + }, [jumpToBottom]); const handleMessageSent = useCallback( (dispatchMode: QueueDispatchMode = "tool-end") => { @@ -1027,15 +1025,15 @@ const ChatPaneContent: React.FC = (props) => { // Slash-command send paths still report after backend success; keep this // harmless duplicate pin so those paths also re-arm auto-scroll. - handleJumpToBottom(); + jumpToBottom(); }, - [autoBackgroundOnSend, handleJumpToBottom] + [autoBackgroundOnSend, jumpToBottom] ); const handleClearHistory = useCallback( async (percentage = 1.0) => { // Re-arm the tail before clearing so the empty/starting state owns the bottom. - handleJumpToBottom(); + jumpToBottom(); // Truncate history in backend const result = await api?.workspace.truncateHistory({ workspaceId, percentage }); @@ -1047,18 +1045,18 @@ const ChatPaneContent: React.FC = (props) => { throw new Error(result.error); } }, - [workspaceId, handleJumpToBottom, api] + [workspaceId, jumpToBottom, api] ); const handleResetContext = useCallback(async (): Promise<"reset" | "noop"> => { - handleJumpToBottom(); + jumpToBottom(); const result = await api?.workspace.resetContext({ workspaceId }); if (!result?.success) { throw new Error(result?.error ?? "Failed to reset context"); } return result.data; - }, [workspaceId, handleJumpToBottom, api]); + }, [workspaceId, jumpToBottom, api]); const openInEditor = useOpenInEditor(); const handleOpenInEditor = useCallback(() => { @@ -1077,8 +1075,8 @@ const ChatPaneContent: React.FC = (props) => { // the ref-backed auto-scroll flag and pins any cached rows before paint; if rows are still // hydrating, the next content resize owns the tail instead of showing the prior workspace's state. useLayoutEffect(() => { - handleJumpToBottom(); - }, [hasLoadedTranscriptRows, handleJumpToBottom, workspaceId]); + jumpToBottom(); + }, [hasLoadedTranscriptRows, jumpToBottom, workspaceId]); // Compute showRetryBarrier once for both keybinds and UI. // Track if last message was interrupted or errored (for RetryBarrier). @@ -1233,7 +1231,7 @@ const ChatPaneContent: React.FC = (props) => { (workspaceState?.canInterrupt ?? false) || (workspaceState?.isStreamStarting ?? false), showRetryBarrier, chatInputAPI, - jumpToBottom: handleJumpToBottom, + jumpToBottom, loadOlderHistory: shouldRenderLoadOlderMessagesButton ? handleLoadOlderHistory : null, handleOpenTerminal: onOpenTerminal, handleOpenInEditor, @@ -1678,7 +1676,7 @@ const ChatPaneContent: React.FC = (props) => { > {!autoScroll && (