From 4a6a975437d1b402139bb4d013ac1fb93d4a7463 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/91] =?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 d607b1749758f28ea45986c461c9b07259a6ab81 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/91] =?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 62f15692bf..05ecc33f70 100644 --- a/src/node/services/memoryService.ts +++ b/src/node/services/memoryService.ts @@ -718,6 +718,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( @@ -974,12 +989,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). @@ -1453,12 +1463,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 31a4f63b7b9105f6759452ff1dbb13cf085e9e0f 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/91] 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 e22d60ac6b..f2a1c8c657 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 9800d0e2772ba511f7a07febd7bfdb5a7cd2043c 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/91] 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 9aba25f33345ab034daf657a21826213438be837 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/91] 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 c38f2c49802a3c5bf092c296f91210042e04fabb 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/91] 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 0fd9e7a2f0..12a5ed9c47 100644 --- a/src/common/utils/thinking/policy.ts +++ b/src/common/utils/thinking/policy.ts @@ -87,8 +87,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; } /** @@ -197,6 +196,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); @@ -238,10 +251,7 @@ export function hasExplicitThinkingPolicy( modelString: string, providersConfig?: ProvidersConfigMap | null ): boolean { - return ( - getExplicitThinkingPolicy(resolveModelForMetadata(modelString, providersConfig ?? null)) !== - null - ); + return getExplicitThinkingPolicyForModel(modelString, providersConfig) !== null; } /** From 775c58815cf5f7aa28276c492b3501905a69673b 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/91] 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 88d1e1a34c0496d466645e415cea3c837d9779b0 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/91] 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 56f1a45482..aef9d87774 100644 --- a/src/common/utils/ai/cacheStrategy.ts +++ b/src/common/utils/ai/cacheStrategy.ts @@ -207,6 +207,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. * @@ -242,16 +252,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 e6fbded5c4f88191e0ae412b110a1d904f2aeb8a 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/91] 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 5ee4e80a33..e282efbedc 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -814,6 +814,26 @@ export class StreamManager extends EventEmitter { return true; } + /** + * 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.emit("tool-call-execution-start", { + 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. @@ -835,13 +855,7 @@ export class StreamManager extends EventEmitter { assert(part.type === "dynamic-tool", "applyToolExecutionStart matched a non-tool part"); streamInfo.parts[partIndex] = { ...part, executionStartedAt: timestamp }; - this.emit("tool-call-execution-start", { - type: "tool-call-execution-start", - workspaceId: workspaceId as string, - messageId: streamInfo.messageId, - toolCallId, - timestamp, - } satisfies ToolCallExecutionStartEvent); + this.emitToolCallExecutionStart(workspaceId, streamInfo, toolCallId, timestamp); return true; } @@ -1378,13 +1392,12 @@ export class StreamManager extends EventEmitter { } streamInfo.parts.push(partToPersist); if (pendingExecutionStart !== undefined && part.type === "dynamic-tool") { - this.emit("tool-call-execution-start", { - 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 (pendingAttachment != null && part.type === "dynamic-tool") { await this.flushPartialWrite(workspaceId, streamInfo); From 9aaff81a5e062a232317d06ccfe6a8fffa0e0adf Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:53:13 +0000 Subject: [PATCH 10/91] refactor: dedupe model-parameter extras merge in aiService --- src/node/services/aiService.ts | 71 +++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index d0d192fd51..61a777bc8b 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -354,6 +354,36 @@ function mergeProviderExtrasUnderMux( return merged; } +/** + * Builds a merger that folds user-provided provider extras (from + * providers.jsonc model-parameter overrides) UNDER a Mux-built provider-options + * object within the given provider namespace. Returns the input unchanged when + * there are no extras, or when the caller's wire-compat gate rejects them + * (extras are shaped for the override block's own SDK namespace, so a + * type-derived identity whose native provider differs from the wire SDK must + * not merge them). Shared by the initial-model build and the fallback-model + * build (and their mid-turn thinking-level rebuilds) so every request shape + * stays identical regardless of which model produced it. + */ +function makeModelParameterExtrasMerger( + namespaceKey: string, + providerExtras: Record | undefined, + wireCompatible: boolean +): (builtOptions: Record) => Record { + return (builtOptions) => { + if (!providerExtras || !wireCompatible) { + return builtOptions; + } + const muxProviderNamespace = builtOptions[namespaceKey]; + return { + ...builtOptions, + [namespaceKey]: isPlainObject(muxProviderNamespace) + ? mergeProviderExtrasUnderMux(providerExtras, muxProviderNamespace) + : providerExtras, + }; + }; +} + function markProviderMetadataCostsIncluded( providerMetadata: Record | undefined, costsIncluded: boolean | undefined @@ -3205,20 +3235,11 @@ export class AIService extends EventEmitter { const extrasWireCompatible = !overridesIdentity.coderDerived || overridesIdentity.providerName === providerOptionsNamespaceKey; - const mergeModelParameterExtras = ( - builtOptions: Record - ): Record => { - if (!resolvedOverrides.providerExtras || !extrasWireCompatible) { - return builtOptions; - } - const muxProviderNamespace = builtOptions[providerOptionsNamespaceKey]; - return { - ...builtOptions, - [providerOptionsNamespaceKey]: isPlainObject(muxProviderNamespace) - ? mergeProviderExtrasUnderMux(resolvedOverrides.providerExtras, muxProviderNamespace) - : resolvedOverrides.providerExtras, - }; - }; + const mergeModelParameterExtras = makeModelParameterExtrasMerger( + providerOptionsNamespaceKey, + resolvedOverrides.providerExtras, + extrasWireCompatible + ); const mergedProviderOptions = mergeModelParameterExtras( providerOptions as Record ); @@ -3755,23 +3776,11 @@ export class AIService extends EventEmitter { nextOverridesIdentity.providerName === nextNamespaceKey; // Mirrors mergeModelParameterExtras for the fallback model; // shared by this baseline build and mid-turn rebuilds below. - const mergeNextModelParameterExtras = ( - builtOptions: Record - ): Record => { - if (!nextOverrides.providerExtras || !nextExtrasWireCompatible) { - return builtOptions; - } - const nextMuxNamespace = builtOptions[nextNamespaceKey]; - return { - ...builtOptions, - [nextNamespaceKey]: isPlainObject(nextMuxNamespace) - ? mergeProviderExtrasUnderMux( - nextOverrides.providerExtras, - nextMuxNamespace - ) - : nextOverrides.providerExtras, - }; - }; + const mergeNextModelParameterExtras = makeModelParameterExtrasMerger( + nextNamespaceKey, + nextOverrides.providerExtras, + nextExtrasWireCompatible + ); const nextMergedProviderOptions = mergeNextModelParameterExtras( nextProviderOptions as Record ); From d6e60e7beb43b4051131b596b9a3f7c22f0b747c 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 11/91] 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 82593ee3ea20c96a55b313be66970fcbfddb4a23 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 12/91] 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 09699b858120ce66480565b53e6d90421acc93a0 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 13/91] 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 7e565b33569ee79c246acb0cecf161094605c1ea 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 14/91] 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 9fc393e9f7..ffc290a062 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 e038e2aff3079ab1174a82484bf46ff2aece9fec 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 15/91] 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 4edddac2c7..8328895464 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -15115,11 +15115,12 @@ export class TaskService { // 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 131660c88723c30a208849a1ca1b9c96ab622354 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 16/91] 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 485fd979d0..1ee599f777 100644 --- a/src/node/services/agentSkills/agentSkillsService.ts +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -13,9 +13,7 @@ import { AgentSkillDescriptorSchema, AgentSkillPackageSchema, SkillNameSchema, - resolveSkillAdvertise, - resolveSkillUserInvocable, - resolveSkillWhenToUse, + buildSkillDescriptor, } from "@/common/orpc/schemas"; import type { AgentSkillDescriptor, @@ -527,13 +525,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 4bfe7d460af9ff0882870894548b529d791eecc6 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 17/91] 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 8d6c4c919b..2ea63cfdba 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 cf7671e6aed3c23d63b49561160c103f9dd9910e Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:31:37 +0000 Subject: [PATCH 18/91] refactor: dedupe MCP OAuth redirect URI resolution in router --- src/node/orpc/router.ts | 86 ++++++++++++++++++----------------------- 1 file changed, 38 insertions(+), 48 deletions(-) diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c425ea2be0..170278b63a 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -817,6 +817,40 @@ async function getCurrentServerAuthSessionId(context: ORPCContext): Promise { // Use mux home as a stable fallback so existing flow codepaths remain unchanged. const projectPath = input.projectPath ?? context.config.rootDir; - const headers = context.headers; - - const origin = typeof headers?.origin === "string" ? headers.origin.trim() : ""; - if (origin) { - try { - const redirectUri = new URL("/auth/mcp-oauth/callback", origin).toString(); - return context.mcpOauthService.startServerFlow({ - ...input, - projectPath, - redirectUri, - }); - } catch { - // Fall back to Host header. - } - } - - const hostHeader = headers?.["x-forwarded-host"] ?? headers?.host; - const host = typeof hostHeader === "string" ? hostHeader.split(",")[0]?.trim() : ""; - if (!host) { + const redirectUri = resolveMcpOauthRedirectUri(context.headers); + if (!redirectUri) { return Err("Missing Host header"); } - const protoHeader = headers?.["x-forwarded-proto"]; - const forwardedProto = - typeof protoHeader === "string" ? protoHeader.split(",")[0]?.trim() : ""; - const proto = forwardedProto.length ? forwardedProto : "http"; - - const redirectUri = `${proto}://${host}/auth/mcp-oauth/callback`; - return context.mcpOauthService.startServerFlow({ ...input, projectPath, @@ -4042,31 +4052,11 @@ export const router = (authToken?: string) => { .input(schemas.projects.mcpOauth.startServerFlow.input) .output(schemas.projects.mcpOauth.startServerFlow.output) .handler(async ({ context, input }) => { - const headers = context.headers; - - const origin = typeof headers?.origin === "string" ? headers.origin.trim() : ""; - if (origin) { - try { - const redirectUri = new URL("/auth/mcp-oauth/callback", origin).toString(); - return context.mcpOauthService.startServerFlow({ ...input, redirectUri }); - } catch { - // Fall back to Host header. - } - } - - const hostHeader = headers?.["x-forwarded-host"] ?? headers?.host; - const host = typeof hostHeader === "string" ? hostHeader.split(",")[0]?.trim() : ""; - if (!host) { + const redirectUri = resolveMcpOauthRedirectUri(context.headers); + if (!redirectUri) { return Err("Missing Host header"); } - const protoHeader = headers?.["x-forwarded-proto"]; - const forwardedProto = - typeof protoHeader === "string" ? protoHeader.split(",")[0]?.trim() : ""; - const proto = forwardedProto.length ? forwardedProto : "http"; - - const redirectUri = `${proto}://${host}/auth/mcp-oauth/callback`; - return context.mcpOauthService.startServerFlow({ ...input, redirectUri }); }), waitForServerFlow: t From 3a6b21e8ee4f61cbafeb80270e8a400338bbaea1 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 19/91] 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 324f8a400f..6850443b41 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -71,7 +71,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"; @@ -2732,13 +2732,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 62b2c9dc6a..f9858dcd19 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, @@ -1285,14 +1286,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}"`; @@ -1375,14 +1369,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 d59b59d0a4..bee267cb2b 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 286e9afe910e3c76bc37cba75de1c85fc85d7565 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 20/91] 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 75bbea2258cdcb61b76dd7c689e52139d98a0257 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 21/91] 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 8328895464..f97a55c6e3 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 @@ -11059,13 +11076,7 @@ export class TaskService { 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( @@ -11130,15 +11141,10 @@ export class TaskService { 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 cb77f864b79c54f7925cf33ca64125368a1eae4c 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 22/91] 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 0748d7d3be..6349a0901b 100644 --- a/src/common/utils/ai/providerOptions.ts +++ b/src/common/utils/ai/providerOptions.ts @@ -578,8 +578,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" }, @@ -592,9 +592,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 e8af59d3d69266f081a37dac84847bb097a837f2 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 23/91] 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 f97a55c6e3..2327fd9640 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -367,6 +367,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 } : {}), }); } @@ -9598,9 +9600,7 @@ export class TaskService { ...(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 @@ -17139,9 +17139,7 @@ export class TaskService { status: "completed", ...(childModelString != null ? { model: childModelString } : {}), ...(childThinkingLevel != null ? { thinkingLevel: childThinkingLevel } : {}), - ...(report.structuredOutput !== undefined - ? { structuredOutput: report.structuredOutput } - : {}), + structuredOutput: report.structuredOutput, }); const workspaceTurnMuxMetadata = From 55b296c252bcb9a8942e9cae8aba15a9a50b8a87 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 24/91] 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 f9329e017f6c44f6076f327e91142f27f91f0140 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 25/91] 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 909ed92d32..9beaee4b21 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -2819,13 +2819,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 ( @@ -2930,12 +2933,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 1a6a7e2193123f77fcfc1c75457f3919e9dee87d 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 26/91] 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 402b29f2c51c226c947da460cffa74df041b4dd0 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:33:47 +0000 Subject: [PATCH 27/91] refactor: dedupe MCP header telemetry flag derivation in router --- src/node/orpc/router.ts | 125 +++++++++++++++------------------------- 1 file changed, 45 insertions(+), 80 deletions(-) diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 170278b63a..dfc7d5acfc 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -34,6 +34,7 @@ import type { import type { TimelineSubscriptionEvent } from "@/common/orpc/schemas/timeline"; import { TIMELINE_DEFAULT_PAGE_LIMIT } from "@/node/services/timelineService"; import type { WorkspaceMetadata } from "@/common/types/workspace"; +import type { MCPHeaderValue, MCPServerInfo } from "@/common/types/mcp"; import type { SshPromptEvent, SshPromptRequest } from "@/common/orpc/schemas/ssh"; import { createAuthMiddleware, @@ -851,6 +852,42 @@ function resolveMcpOauthRedirectUri(headers: ORPCContext["headers"]): string | u return `${proto}://${host}${callbackPath}`; } +/** + * Derive the `has_headers` / `uses_secret_headers` flags reported alongside + * `mcp_server_config_changed` telemetry from an MCP header map. + * + * A header counts as "secret" when it is stored as a `{ secret: }` + * reference instead of a literal string, which lets us measure secret-indirection + * usage without ever capturing header names or values. + */ +function describeMcpHeaderTelemetry(headers: Record | undefined): { + hasHeaders: boolean; + usesSecretHeaders: boolean; +} { + return { + hasHeaders: Boolean(headers && Object.keys(headers).length > 0), + usesSecretHeaders: Boolean( + headers && + Object.values(headers).some((v) => typeof v === "object" && v !== null && "secret" in v) + ), + }; +} + +/** + * Same flags, but for an already-persisted server config. stdio servers cannot + * carry headers at all (the field only exists on the HTTP-ish variants), so they + * always report `false` — matching the `transport !== "stdio" && …` guards that + * every `mcp_server_config_changed` capture site used to repeat inline. + */ +function describeMcpServerHeaderTelemetry(server: MCPServerInfo): { + hasHeaders: boolean; + usesSecretHeaders: boolean; +} { + return server.transport === "stdio" + ? { hasHeaders: false, usesSecretHeaders: false } + : describeMcpHeaderTelemetry(server.headers); +} + /** * Translate goal-board service errors (`WorkspaceGoalTransitionError`, * `WorkspaceGoalChildWorkspaceError`) into `ORPCError("BAD_REQUEST", …)` @@ -3097,13 +3134,7 @@ export const router = (authToken?: string) => { } } - const hasHeaders = Boolean(input.headers && Object.keys(input.headers).length > 0); - const usesSecretHeaders = Boolean( - input.headers && - Object.values(input.headers).some( - (v) => typeof v === "object" && v !== null && "secret" in v - ) - ); + const { hasHeaders, usesSecretHeaders } = describeMcpHeaderTelemetry(input.headers); const action = (() => { if (!existingServer) { @@ -3160,17 +3191,7 @@ export const router = (authToken?: string) => { const result = await context.mcpConfigService.removeServer(input.name); if (result.success && server) { - const hasHeaders = - server.transport !== "stdio" && - Boolean(server.headers && Object.keys(server.headers).length > 0); - const usesSecretHeaders = - server.transport !== "stdio" && - Boolean( - server.headers && - Object.values(server.headers).some( - (v) => typeof v === "object" && v !== null && "secret" in v - ) - ); + const { hasHeaders, usesSecretHeaders } = describeMcpServerHeaderTelemetry(server); context.telemetryService.capture({ event: "mcp_server_config_changed", @@ -3292,17 +3313,7 @@ export const router = (authToken?: string) => { const result = await context.mcpConfigService.setServerEnabled(input.name, input.enabled); if (result.success && server) { - const hasHeaders = - server.transport !== "stdio" && - Boolean(server.headers && Object.keys(server.headers).length > 0); - const usesSecretHeaders = - server.transport !== "stdio" && - Boolean( - server.headers && - Object.values(server.headers).some( - (v) => typeof v === "object" && v !== null && "secret" in v - ) - ); + const { hasHeaders, usesSecretHeaders } = describeMcpServerHeaderTelemetry(server); context.telemetryService.capture({ event: "mcp_server_config_changed", @@ -3336,17 +3347,7 @@ export const router = (authToken?: string) => { ); if (result.success && server) { - const hasHeaders = - server.transport !== "stdio" && - Boolean(server.headers && Object.keys(server.headers).length > 0); - const usesSecretHeaders = - server.transport !== "stdio" && - Boolean( - server.headers && - Object.values(server.headers).some( - (v) => typeof v === "object" && v !== null && "secret" in v - ) - ); + const { hasHeaders, usesSecretHeaders } = describeMcpServerHeaderTelemetry(server); context.telemetryService.capture({ event: "mcp_server_config_changed", @@ -3776,13 +3777,7 @@ export const router = (authToken?: string) => { return { success: false, error: "MCP transport is disabled by policy" }; } } - const hasHeaders = Boolean(input.headers && Object.keys(input.headers).length > 0); - const usesSecretHeaders = Boolean( - input.headers && - Object.values(input.headers).some( - (v) => typeof v === "object" && v !== null && "secret" in v - ) - ); + const { hasHeaders, usesSecretHeaders } = describeMcpHeaderTelemetry(input.headers); const action = (() => { if (!existingServer) { @@ -3839,17 +3834,7 @@ export const router = (authToken?: string) => { const result = await context.mcpConfigService.removeServer(input.name); if (result.success && server) { - const hasHeaders = - server.transport !== "stdio" && - Boolean(server.headers && Object.keys(server.headers).length > 0); - const usesSecretHeaders = - server.transport !== "stdio" && - Boolean( - server.headers && - Object.values(server.headers).some( - (v) => typeof v === "object" && v !== null && "secret" in v - ) - ); + const { hasHeaders, usesSecretHeaders } = describeMcpServerHeaderTelemetry(server); context.telemetryService.capture({ event: "mcp_server_config_changed", @@ -3956,17 +3941,7 @@ export const router = (authToken?: string) => { ); if (result.success && server) { - const hasHeaders = - server.transport !== "stdio" && - Boolean(server.headers && Object.keys(server.headers).length > 0); - const usesSecretHeaders = - server.transport !== "stdio" && - Boolean( - server.headers && - Object.values(server.headers).some( - (v) => typeof v === "object" && v !== null && "secret" in v - ) - ); + const { hasHeaders, usesSecretHeaders } = describeMcpServerHeaderTelemetry(server); context.telemetryService.capture({ event: "mcp_server_config_changed", @@ -4000,17 +3975,7 @@ export const router = (authToken?: string) => { ); if (result.success && server) { - const hasHeaders = - server.transport !== "stdio" && - Boolean(server.headers && Object.keys(server.headers).length > 0); - const usesSecretHeaders = - server.transport !== "stdio" && - Boolean( - server.headers && - Object.values(server.headers).some( - (v) => typeof v === "object" && v !== null && "secret" in v - ) - ); + const { hasHeaders, usesSecretHeaders } = describeMcpServerHeaderTelemetry(server); context.telemetryService.capture({ event: "mcp_server_config_changed", From 31bc7a395a3fc8e32417e462d269a99ed1f68e09 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 28/91] 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 81077c09b127234d7ebf6e303407a77d0090cd7e 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 29/91] 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 9beaee4b21..c027bd2b58 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -932,7 +932,7 @@ const ChatInputInner: React.FC = (props) => { return; } - const normalizedAgentId = normalizeAgentId(agentId, "exec"); + const normalizedAgentId = normalizeAgentId(agentId); updatePersistedState( getWorkspaceAISettingsByAgentKey(workspaceId), @@ -1279,7 +1279,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 63ecee851e108fe58492fa649754013f57cb5add 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 30/91] 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 0a15caa22faa11eca0d854ccd92b3215696055eb 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 31/91] 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 c027bd2b58..600166e205 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -244,6 +244,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"; @@ -375,14 +376,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 5dfc270840760192784b571c9ef47144d0e75503 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 32/91] 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 600166e205..c8ee4a095c 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -243,6 +243,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"; @@ -3551,7 +3552,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 7c88a44002342d5252b9954aaf0b563cc2956782 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 33/91] 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 fefef4020b9e6a995c21aea5eb20a58b848762e6 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 34/91] 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 f34012d58dcebfa22e3a61fcd7704521eab3aa78 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 35/91] 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 64/91] 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 8a7ac2e09ed27b49fc1e6d4bc048b20c2d1c89f2 Mon Sep 17 00:00:00 2001 From: mux Date: Sun, 16 Aug 2026 20:14:25 +0000 Subject: [PATCH 65/91] 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 0e108d3001..248d7bfdec 100644 --- a/src/node/services/gitPatchArtifactService.ts +++ b/src/node/services/gitPatchArtifactService.ts @@ -614,78 +614,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; } @@ -720,20 +698,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 cac825fb7232552acd128a141ead56c008dfd34e 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 66/91] 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 7f2dd8c88b10037fa251a6c0850a25e811d2e2c9 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 67/91] 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 b371437fb443f5d0569d234356eabf7f0269ea72 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 68/91] 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 5ff8da885c..173efd56ab 100644 --- a/src/node/services/sessionTimingService.ts +++ b/src/node/services/sessionTimingService.ts @@ -739,12 +739,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) { @@ -763,12 +780,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; @@ -790,12 +804,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; @@ -827,11 +838,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, @@ -842,12 +851,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 56ae627582b508aa44fdd1690dc893831ddc941c 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 69/91] 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 a53cea1560c4993ec46d502be310cc459bfa34a5 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 70/91] 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 f9858dcd19..deb9110310 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -980,6 +980,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"; @@ -1300,10 +1311,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 } } @@ -1337,10 +1345,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 }); } } @@ -1378,10 +1383,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 50b423895992b15fd13ca256ed2b785dcfb35ce3 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 71/91] 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 d4652f696731dc655d1cf32dd1d2bb530a6470f6 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 72/91] 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; #3907 added the newest copy in clearProviderConfigFixableAbandonMarkers, 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 bad5263688..dec3777763 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -184,6 +184,7 @@ import { runInlineAbandonedBranchSummary, } from "@/node/services/branchSummary"; import type { Runtime } from "@/node/runtime/Runtime"; +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"; @@ -446,11 +447,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; @@ -1354,23 +1351,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), @@ -1390,11 +1384,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 38bafa02252b1a667e1224f946be8e66e2c67ece 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 73/91] 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 b61889610fbd04b0b9fa08248dab1d448184bc87 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 74/91] 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 e08629fa4d3c63622cf34aa1aa67d3b43f0a392e 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 75/91] refactor: extract configContentSignature helper for corrupt-config backup gate --- src/node/config.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/node/config.ts b/src/node/config.ts index 5d2a408ea0..de8cf7de81 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -867,6 +867,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. @@ -1006,8 +1018,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 @@ -2143,7 +2154,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 0c7fa254fc37bc587f6a508fd118c49344174106 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 76/91] 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 041c68e812..3d5235bde0 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -328,6 +328,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)); } @@ -702,11 +717,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 @@ -1573,11 +1584,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 02835423421e7fd37a3b553898fddec44171d653 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 77/91] 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 b2cd7fad52bdb12fc6b14be606e282d096962672 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 78/91] 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 290e9ad2755613794547dd5dfc2692aed2209563 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 79/91] 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 fd54b9eb138f5716a9810e35f312f7a42d180a8e 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 80/91] 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 && (