From 0c0c53e01a6671ec280f415fbf895084748e636d Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 05:41:36 +0000 Subject: [PATCH 01/10] refactor: dedupe edit-mode attachment toast message in ChatInput Extract the thrice-repeated "Attachments cannot be added while editing a message." toast string into a module-level EDIT_MODE_ATTACHMENT_ERROR_MESSAGE const so the three guards surface identical copy and cannot drift. Behavior-preserving. --- src/browser/features/ChatInput/index.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 7d5213f57a..ae8d2136c7 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -232,6 +232,10 @@ function estimateBase64DataUrlBytes(dataUrl: string): number | null { } const MAX_PERSISTED_ATTACHMENT_DRAFT_CHARS = 4_000_000; +// Shared so the three "blocked while editing a message" attachment guards surface identical copy +// and can't drift if one is reworded. +const EDIT_MODE_ATTACHMENT_ERROR_MESSAGE = "Attachments cannot be added while editing a message."; + export type { ChatInputProps, ChatInputAPI }; interface SendOverrides { @@ -2002,7 +2006,7 @@ const ChatInputInner: React.FC = (props) => { if (editingMessageForUi) { pushToast({ type: "error", - message: "Attachments cannot be added while editing a message.", + message: EDIT_MODE_ATTACHMENT_ERROR_MESSAGE, }); return; } @@ -2041,7 +2045,7 @@ const ChatInputInner: React.FC = (props) => { if (editingMessageForUi) { pushToast({ type: "error", - message: "Attachments cannot be added while editing a message.", + message: EDIT_MODE_ATTACHMENT_ERROR_MESSAGE, }); return; } @@ -2219,7 +2223,7 @@ const ChatInputInner: React.FC = (props) => { if (editingMessageForUi) { pushToast({ type: "error", - message: "Attachments cannot be added while editing a message.", + message: EDIT_MODE_ATTACHMENT_ERROR_MESSAGE, }); return; } From 700be5b914f772da4b76793977330ee9b67a057f Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:32:59 +0000 Subject: [PATCH 02/10] refactor: dedupe parallelAgents child-failure abort handling in WorkflowRunner --- src/node/services/workflows/WorkflowRunner.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/node/services/workflows/WorkflowRunner.ts b/src/node/services/workflows/WorkflowRunner.ts index 5f2669cb59..ebf289cd10 100644 --- a/src/node/services/workflows/WorkflowRunner.ts +++ b/src/node/services/workflows/WorkflowRunner.ts @@ -952,6 +952,20 @@ export class WorkflowRunner { // Preserve the original child failure; workflow failure handling will surface that cause. } }; + // A child-task failure aborts the batch the same way regardless of which + // launch path raised it: a foreground-backgrounded wait flips the batch to + // backgrounded and aborts it (so the abort guards drain queued work), + // while any other failure interrupts the still-running siblings unless the + // batch was already backgrounded. Shared by the per-run and bulk-create + // catch blocks so the two stay in lockstep. + const applyChildFailureToBatch = async (error: unknown): Promise => { + if (isForegroundWaitBackgroundedError(error)) { + foregroundBackgrounded = true; + abortBatch(); + } else if (!foregroundBackgrounded) { + await interruptRemainingTasks(); + } + }; const batchWaitOptions: WorkflowAgentWaitOptions = { ...options.waitOptions, abortSignal: batchAbortController.signal, @@ -1029,12 +1043,7 @@ export class WorkflowRunner { return { runIndex, step, runResult }; } catch (error) { batchFailed = true; - if (isForegroundWaitBackgroundedError(error)) { - foregroundBackgrounded = true; - abortBatch(); - } else if (!foregroundBackgrounded) { - await interruptRemainingTasks(); - } + await applyChildFailureToBatch(error); return { runIndex, step, error }; } })(); @@ -1099,12 +1108,7 @@ export class WorkflowRunner { bulkCreatableSteps[index].taskId = createdTask.taskId; } } catch (error) { - if (isForegroundWaitBackgroundedError(error)) { - foregroundBackgrounded = true; - abortBatch(); - } else if (!foregroundBackgrounded) { - await interruptRemainingTasks(); - } + await applyChildFailureToBatch(error); throw error; } } From 3cffa282d81f18364dd7681db54633112b1e6b55 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:07:40 +0000 Subject: [PATCH 03/10] refactor: dedupe send-queued-immediately in-flight guard clear in ChatPane --- src/browser/components/ChatPane/ChatPane.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index 1bc5b9f374..bb21029e1c 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -858,6 +858,13 @@ const ChatPaneContent: React.FC = (props) => { } sendQueuedImmediatelyInFlightRef.current = queuedMessage.id; + // Release the duplicate-send guard only if it still points at this attempt; a + // newer queued message (or a clear) may have already reset it in the meantime. + const clearInFlightGuardIfCurrent = () => { + if (sendQueuedImmediatelyInFlightRef.current === queuedMessage.id) { + sendQueuedImmediatelyInFlightRef.current = null; + } + }; try { // Set "interrupting" state immediately so UI shows "interrupting..." without flash. storeRaw.setInterrupting(workspaceId); @@ -865,16 +872,11 @@ const ChatPaneContent: React.FC = (props) => { workspaceId, options: { sendQueuedImmediately: true }, }); - if ( - !interruptResult.success && - sendQueuedImmediatelyInFlightRef.current === queuedMessage.id - ) { - sendQueuedImmediatelyInFlightRef.current = null; + if (!interruptResult.success) { + clearInFlightGuardIfCurrent(); } } catch (error) { - if (sendQueuedImmediatelyInFlightRef.current === queuedMessage.id) { - sendQueuedImmediatelyInFlightRef.current = null; - } + clearInFlightGuardIfCurrent(); throw error; } }, [api, workspaceId, workspaceState?.queuedMessage, workspaceState?.canInterrupt, storeRaw]); From 7caf1ff2043fc156c3c2b5b2b94a77c5074e6961 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:02:16 +0000 Subject: [PATCH 04/10] refactor: dedupe startup recovery candidate listing in TaskService.initialize Extract the awaiting_report/running candidate filters used by the startup inactive-workflow-owner recovery pass into a single listStartupRecoveryCandidates closure, mirroring the existing listQueuedTasks pattern, so the initial computation and the post-interrupt refresh reuse the exact same status filters. Behavior-preserving cleanup of #3594. --- src/node/services/taskService.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 6b4cf734f2..ce4d18ae52 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -1698,12 +1698,22 @@ export class TaskService { let config = this.config.loadConfigOrDefault(); let taskIndex = this.buildAgentTaskIndex(config); - let awaitingReportTasks = this.listAgentTaskWorkspaces(config).filter( - (t) => t.taskStatus === "awaiting_report" - ); - let runningTasks = this.listAgentTaskWorkspaces(config).filter( - (t) => t.taskStatus === "running" - ); + // Recompute the startup recovery candidate lists from a config snapshot. Hoisted into a + // closure so the post-interrupt refresh below reuses the exact same status filters. + const listStartupRecoveryCandidates = ( + sourceConfig: ProjectsConfig + ): { + awaitingReportTasks: AgentTaskWorkspaceEntry[]; + runningTasks: AgentTaskWorkspaceEntry[]; + } => ({ + awaitingReportTasks: this.listAgentTaskWorkspaces(sourceConfig).filter( + (t) => t.taskStatus === "awaiting_report" + ), + runningTasks: this.listAgentTaskWorkspaces(sourceConfig).filter( + (t) => t.taskStatus === "running" + ), + }); + let { awaitingReportTasks, runningTasks } = listStartupRecoveryCandidates(config); let interruptedInactiveWorkflowOwnerAtStartup = false; for (const task of [...awaitingReportTasks, ...runningTasks]) { @@ -1725,10 +1735,7 @@ export class TaskService { // blocked by a child that this startup pass just interrupted. config = this.config.loadConfigOrDefault(); taskIndex = this.buildAgentTaskIndex(config); - awaitingReportTasks = this.listAgentTaskWorkspaces(config).filter( - (t) => t.taskStatus === "awaiting_report" - ); - runningTasks = this.listAgentTaskWorkspaces(config).filter((t) => t.taskStatus === "running"); + ({ awaitingReportTasks, runningTasks } = listStartupRecoveryCandidates(config)); } let resumedAwaitingReportCount = 0; From 2fd532106f35376aaeba2ac33f7fa86c073a68a8 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:11:58 +0000 Subject: [PATCH 05/10] refactor: dedupe heartbeat tool success result construction --- src/node/services/tools/heartbeat.ts | 46 +++++++++++++++------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/node/services/tools/heartbeat.ts b/src/node/services/tools/heartbeat.ts index 6330271b1a..376c5591e1 100644 --- a/src/node/services/tools/heartbeat.ts +++ b/src/node/services/tools/heartbeat.ts @@ -1,6 +1,10 @@ import { tool } from "ai"; import assert from "@/common/utils/assert"; -import type { ToolFactory, WorkspaceHeartbeatSettingsUpdate } from "@/common/utils/tools/tools"; +import type { + ToolFactory, + WorkspaceHeartbeatSettings, + WorkspaceHeartbeatSettingsUpdate, +} from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; import type { HeartbeatToolArgs, HeartbeatToolResult } from "@/common/types/tools"; import { getErrorMessage } from "@/common/utils/errors"; @@ -51,6 +55,22 @@ function summarize( return `Heartbeat is ${status} for this workspace at ${formatInterval(settings.intervalMs)}.`; } +// Build the shared success payload for every heartbeat action so the get/set/unset branches +// don't each re-assemble the same { action, configured, settings, summary } object. `configured` +// is derived from settings: null only for unset and an unconfigured get, non-null otherwise. +function buildSuccessResult( + action: HeartbeatToolArgs["action"], + settings: WorkspaceHeartbeatSettings | null +): HeartbeatToolResult { + return { + success: true, + action, + configured: settings != null, + settings, + summary: summarize({ action, settings }), + }; +} + export const createHeartbeatTool: ToolFactory = (config) => tool({ description: TOOL_DEFINITIONS.heartbeat.description, @@ -66,13 +86,7 @@ export const createHeartbeatTool: ToolFactory = (config) => if (args.action === "get") { const settings = heartbeatService.getHeartbeatSettings(workspaceId); - return { - success: true, - action: args.action, - configured: settings != null, - settings, - summary: summarize({ action: args.action, settings }), - }; + return buildSuccessResult(args.action, settings); } if (args.action === "unset") { @@ -80,13 +94,7 @@ export const createHeartbeatTool: ToolFactory = (config) => if (!unsetResult.success) { return { success: false, error: unsetResult.error }; } - return { - success: true, - action: args.action, - configured: false, - settings: null, - summary: summarize({ action: args.action, settings: null }), - }; + return buildSuccessResult(args.action, null); } const settingsUpdate: WorkspaceHeartbeatSettingsUpdate = {}; @@ -109,13 +117,7 @@ export const createHeartbeatTool: ToolFactory = (config) => } const settings = setResult.data; - return { - success: true, - action: args.action, - configured: true, - settings, - summary: summarize({ action: args.action, settings }), - }; + return buildSuccessResult(args.action, settings); } catch (error) { return { success: false, error: getErrorMessage(error) }; } From 7668609e5c56c73fb2ec549747f8e40196b0849b Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:52:27 +0000 Subject: [PATCH 06/10] refactor: extract pluralizeTurns helper for goal toolcards --- src/browser/features/Tools/Goal/goalToolUtils.tsx | 11 ++++++++--- src/browser/features/Tools/SetGoalToolCall.tsx | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/browser/features/Tools/Goal/goalToolUtils.tsx b/src/browser/features/Tools/Goal/goalToolUtils.tsx index 7e22685057..61f1b3f4b4 100644 --- a/src/browser/features/Tools/Goal/goalToolUtils.tsx +++ b/src/browser/features/Tools/Goal/goalToolUtils.tsx @@ -22,10 +22,15 @@ export function formatGoalElapsed(startedAtMs: number, nowMs: number = Date.now( return `${hours}h ${minutes % 60}m`; } +// Shared turn-count pluralization so the goal toolcards render "1 turn" / +// "N turns" consistently (was open-coded in formatGoalTurns and the set_goal +// toolcard's requested-turns formatter). +export function pluralizeTurns(count: number): string { + return `${count} turn${count === 1 ? "" : "s"}`; +} + export function formatGoalTurns(turnsUsed: number, turnCap: number | null): string { - return turnCap == null - ? `${turnsUsed} turn${turnsUsed === 1 ? "" : "s"}` - : `${turnsUsed} / ${turnCap} turns`; + return turnCap == null ? pluralizeTurns(turnsUsed) : `${turnsUsed} / ${turnCap} turns`; } export function formatGoalBudgetSummary(costCents: number, budgetCents: number | null): string { diff --git a/src/browser/features/Tools/SetGoalToolCall.tsx b/src/browser/features/Tools/SetGoalToolCall.tsx index a8fa242309..2b4853a3e5 100644 --- a/src/browser/features/Tools/SetGoalToolCall.tsx +++ b/src/browser/features/Tools/SetGoalToolCall.tsx @@ -20,6 +20,7 @@ import { extractGoalFromResult, formatGoalCents, formatGoalTurns, + pluralizeTurns, } from "./Goal/goalToolUtils"; interface SetGoalToolCallProps { @@ -43,7 +44,7 @@ function formatAppliedBudget(budgetCents: number | null): string { } function formatOptionalTurnCap(turnCap: number | null | undefined): string { - return turnCap == null ? "Workspace default" : `${turnCap} turn${turnCap === 1 ? "" : "s"}`; + return turnCap == null ? "Workspace default" : pluralizeTurns(turnCap); } export const SetGoalToolCall: React.FC = ({ From 1206be0e24a9956aa9a6c0882982a0bd013d1e4d Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:36:43 +0000 Subject: [PATCH 07/10] refactor: dedupe workspace-turn error fallback in task_await --- src/node/services/tools/task_await.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/node/services/tools/task_await.ts b/src/node/services/tools/task_await.ts index 81932237b2..da27555df5 100644 --- a/src/node/services/tools/task_await.ts +++ b/src/node/services/tools/task_await.ts @@ -38,6 +38,9 @@ import { const DEFAULT_TASK_AWAIT_TIMEOUT_MS = 600_000; const WORKFLOW_AWAIT_POLL_INTERVAL_MS = 250; +// Shared fallback used when a workspace-turn record errored without a specific message, +// so every error-status await result path reports identical text. +const WORKSPACE_TURN_DEFAULT_ERROR = "Workspace turn failed"; // Status values for which task_await still treats an agent task as live and // should surface the live status (plus an `elapsed_ms` field) instead of @@ -549,7 +552,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { return { status: "error" as const, taskId, - error: snapshot.error ?? "Workspace turn failed", + error: snapshot.error ?? WORKSPACE_TURN_DEFAULT_ERROR, }; } } @@ -620,7 +623,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { return { status: "error" as const, taskId, - error: latest.error ?? "Workspace turn failed", + error: latest.error ?? WORKSPACE_TURN_DEFAULT_ERROR, }; } return { @@ -654,7 +657,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { return { status: "error" as const, taskId, - error: latest.error ?? "Workspace turn failed", + error: latest.error ?? WORKSPACE_TURN_DEFAULT_ERROR, }; } if (latest.status === "interrupted") { From 19e9c8b6ba3af2744187d17b7622ae992b270a92 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:03:34 +0000 Subject: [PATCH 08/10] refactor: dedupe plan-agent explore-only task error in task.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'In the plan agent you may only spawn agentId: "explore" tasks.' error string was open-coded twice in task.ts — once in the workspace-turn guard and once in the per-launch agent-id guard. Both now reference a single module-level PLAN_AGENT_EXPLORE_ONLY_ERROR constant. Behavior-preserving (identical string). --- src/node/services/tools/task.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/node/services/tools/task.ts b/src/node/services/tools/task.ts index 8c116bbf88..fc17296dad 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -36,6 +36,11 @@ import { import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; import { coerceNonEmptyString } from "@/node/services/taskUtils"; +// Plan agent is read-only: only `explore` sub-agent tasks may be spawned. Shared by both the +// workspace-turn guard and the per-launch agent-id guard so the message can't drift between them. +const PLAN_AGENT_EXPLORE_ONLY_ERROR = + 'In the plan agent you may only spawn agentId: "explore" tasks.'; + const BUILT_IN_TASK_TOOL_MARKER = Symbol("muxBuiltInTaskTool"); export function markBuiltInTaskTool( @@ -402,7 +407,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { const parentRuntimeAiSettings = buildParentRuntimeAiSettings(config); if (config.planFileOnly && kind === "workspace") { - throw new Error('In the plan agent you may only spawn agentId: "explore" tasks.'); + throw new Error(PLAN_AGENT_EXPLORE_ONLY_ERROR); } if (kind === "workspace") { @@ -504,7 +509,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { // Plan agent is explicitly non-executing. Allow only read-only exploration tasks. if (config.planFileOnly && requestedAgentId !== "explore") { - throw new Error('In the plan agent you may only spawn agentId: "explore" tasks.'); + throw new Error(PLAN_AGENT_EXPLORE_ONLY_ERROR); } // Parent runtime model and thinking are forwarded as a low-priority fallback so From ba8a8ca9869e3b8c4e33ce9e5a2785675cf1ac2c Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:55:11 +0000 Subject: [PATCH 09/10] refactor: dedupe goal-service session file path resolution --- src/node/services/workspaceGoalService.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 1bea338b4c..64e81ad702 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -583,14 +583,21 @@ export class WorkspaceGoalService { return true; } - private getFilePath(workspaceId: string): string { + // Shared resolver for the goal service's per-workspace session files + // (goal.json / goal-history.jsonl / goal-board.json). Centralizes the + // non-empty workspaceId guard and session-dir join so each file accessor + // doesn't re-assert and re-join the same way. + private resolveSessionFilePath(workspaceId: string, fileName: string): string { assert(workspaceId.trim().length > 0, "WorkspaceGoalService requires non-empty workspaceId"); - return path.join(this.config.getSessionDir(workspaceId), GOAL_FILE); + return path.join(this.config.getSessionDir(workspaceId), fileName); + } + + private getFilePath(workspaceId: string): string { + return this.resolveSessionFilePath(workspaceId, GOAL_FILE); } private getHistoryFilePath(workspaceId: string): string { - assert(workspaceId.trim().length > 0, "WorkspaceGoalService requires non-empty workspaceId"); - return path.join(this.config.getSessionDir(workspaceId), GOAL_HISTORY_FILE); + return this.resolveSessionFilePath(workspaceId, GOAL_HISTORY_FILE); } /** @@ -2928,8 +2935,7 @@ export class WorkspaceGoalService { // ─────────────────────────────────────────────────────────────────────── private getBoardFilePath(workspaceId: string): string { - assert(workspaceId.trim().length > 0, "WorkspaceGoalService requires non-empty workspaceId"); - return path.join(this.config.getSessionDir(workspaceId), GOAL_BOARD_FILE); + return this.resolveSessionFilePath(workspaceId, GOAL_BOARD_FILE); } private async readBoard(workspaceId: string): Promise { From b207808bbf77c0e6d0ff2b9b7c5126ca96fa20f7 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:37:51 +0000 Subject: [PATCH 10/10] refactor: dedupe invalid-skill diagnostics in readSkillDescriptorFromDir --- .../agentSkills/agentSkillsService.ts | 71 ++++++++----------- 1 file changed, 31 insertions(+), 40 deletions(-) diff --git a/src/node/services/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts index db7fd08810..15f3a10113 100644 --- a/src/node/services/agentSkills/agentSkillsService.ts +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -187,28 +187,34 @@ async function readSkillDescriptorFromDir( ): Promise { const skillFilePath = runtime.normalizePath("SKILL.md", skillDir); - let stat; - try { - stat = await runtime.stat(skillFilePath); - } catch { + // Every invalid-skill diagnostic for this directory shares the same identity + // (directoryName / scope / displayPath); only message + hint vary per failure. + const pushInvalidSkill = (message: string, hint: string): void => { options?.invalidSkills?.push({ directoryName, scope, displayPath: skillFilePath, - message: "SKILL.md is missing or unreadable.", - hint: "Create a SKILL.md file with YAML frontmatter (--- ... ---).", + message, + hint, }); + }; + + let stat; + try { + stat = await runtime.stat(skillFilePath); + } catch { + pushInvalidSkill( + "SKILL.md is missing or unreadable.", + "Create a SKILL.md file with YAML frontmatter (--- ... ---)." + ); return null; } if (stat.isDirectory) { - options?.invalidSkills?.push({ - directoryName, - scope, - displayPath: skillFilePath, - message: "SKILL.md is a directory (expected a file).", - hint: "Replace SKILL.md with a regular file.", - }); + pushInvalidSkill( + "SKILL.md is a directory (expected a file).", + "Replace SKILL.md with a regular file." + ); return null; } @@ -216,13 +222,7 @@ async function readSkillDescriptorFromDir( const sizeValidation = validateFileSize(stat); if (sizeValidation) { log.warn(`Skipping skill '${directoryName}' (${scope}): ${sizeValidation.error}`); - options?.invalidSkills?.push({ - directoryName, - scope, - displayPath: skillFilePath, - message: sizeValidation.error, - hint: "Reduce SKILL.md size below 1MB.", - }); + pushInvalidSkill(sizeValidation.error, "Reduce SKILL.md size below 1MB."); return null; } @@ -232,13 +232,10 @@ async function readSkillDescriptorFromDir( } catch (err) { const message = getErrorMessage(err); log.warn(`Failed to read SKILL.md for ${directoryName}: ${message}`); - options?.invalidSkills?.push({ - directoryName, - scope, - displayPath: skillFilePath, - message: `Failed to read SKILL.md: ${message}`, - hint: "Check file permissions and ensure the file is UTF-8 text.", - }); + pushInvalidSkill( + `Failed to read SKILL.md: ${message}`, + "Check file permissions and ensure the file is UTF-8 text." + ); return null; } @@ -259,13 +256,10 @@ async function readSkillDescriptorFromDir( const validated = AgentSkillDescriptorSchema.safeParse(descriptor); if (!validated.success) { log.warn(`Invalid agent skill descriptor for ${directoryName}: ${validated.error.message}`); - options?.invalidSkills?.push({ - directoryName, - scope, - displayPath: skillFilePath, - message: `Invalid agent skill descriptor: ${validated.error.message}`, - hint: "Fix SKILL.md frontmatter fields to satisfy the skill schema.", - }); + pushInvalidSkill( + `Invalid agent skill descriptor: ${validated.error.message}`, + "Fix SKILL.md frontmatter fields to satisfy the skill schema." + ); return null; } @@ -273,13 +267,10 @@ async function readSkillDescriptorFromDir( } catch (err) { const message = err instanceof AgentSkillParseError ? err.message : getErrorMessage(err); log.warn(`Skipping invalid skill '${directoryName}' (${scope}): ${message}`); - options?.invalidSkills?.push({ - directoryName, - scope, - displayPath: skillFilePath, + pushInvalidSkill( message, - hint: "Fix SKILL.md frontmatter (name + description) and ensure it matches the directory name.", - }); + "Fix SKILL.md frontmatter (name + description) and ensure it matches the directory name." + ); return null; } }