Download src/utils/processUserInput/processSlashCommand.tsx from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 44.5 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/utils/processUserInput/processSlashCommand.tsx
- Command line
-
hf download hf://chenbhao/codev/src/utils/processUserInput/processSlashCommand.tsx
-
curl -L -o processSlashCommand.tsx https://huggingface.co/chenbhao/codev/resolve/main/src/utils/processUserInput/processSlashCommand.tsx
44.5 kB
| import { feature } from 'bun:bundle' | |
| import type { | |
| ContentBlockParam, | |
| TextBlockParam, | |
| } from '@anthropic-ai/sdk/resources' | |
| import { randomUUID } from 'crypto' | |
| import { setPromptId } from 'src/bootstrap/state.js' | |
| import { | |
| builtInCommandNames, | |
| type Command, | |
| type CommandBase, | |
| findCommand, | |
| getCommand, | |
| getCommandName, | |
| hasCommand, | |
| type PromptCommand, | |
| } from 'src/commands.js' | |
| import { NO_CONTENT_MESSAGE } from 'src/constants/messages.js' | |
| import type { SetToolJSXFn, ToolUseContext } from 'src/Tool.js' | |
| import type { | |
| AssistantMessage, | |
| AttachmentMessage, | |
| Message, | |
| NormalizedUserMessage, | |
| ProgressMessage, | |
| UserMessage, | |
| } from 'src/types/message.js' | |
| import { addInvokedSkill, getSessionId } from '../../bootstrap/state.js' | |
| import { COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG } from '../../constants/xml.js' | |
| import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' | |
| import { | |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| logEvent, | |
| } from '../../services/analytics/index.js' | |
| import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js' | |
| import { buildPostCompactMessages } from '../../services/compact/compact.js' | |
| import { resetMicrocompactState } from '../../services/compact/microCompact.js' | |
| import type { Progress as AgentProgress } from '../../tools/AgentTool/AgentTool.js' | |
| import { runAgent } from '../../tools/AgentTool/runAgent.js' | |
| import { renderToolUseProgressMessage } from '../../tools/AgentTool/UI.js' | |
| import type { CommandResultDisplay } from '../../types/command.js' | |
| import { createAbortController } from '../abortController.js' | |
| import { getAgentContext } from '../agentContext.js' | |
| import { | |
| createAttachmentMessage, | |
| getAttachmentMessages, | |
| } from '../attachments.js' | |
| import { logForDebugging } from '../debug.js' | |
| import { isEnvTruthy } from '../envUtils.js' | |
| import { AbortError, MalformedCommandError } from '../errors.js' | |
| import { getDisplayPath } from '../file.js' | |
| import { | |
| extractResultText, | |
| prepareForkedCommandContext, | |
| } from '../forkedAgent.js' | |
| import { getFsImplementation } from '../fsOperations.js' | |
| import { isFullscreenEnvEnabled } from '../fullscreen.js' | |
| import { toArray } from '../generators.js' | |
| import { registerSkillHooks } from '../hooks/registerSkillHooks.js' | |
| import { logError } from '../log.js' | |
| import { enqueuePendingNotification } from '../messageQueueManager.js' | |
| import { | |
| createCommandInputMessage, | |
| createSyntheticUserCaveatMessage, | |
| createSystemMessage, | |
| createUserInterruptionMessage, | |
| createUserMessage, | |
| formatCommandInputTags, | |
| isCompactBoundaryMessage, | |
| isSystemLocalCommandMessage, | |
| normalizeMessages, | |
| prepareUserContent, | |
| } from '../messages.js' | |
| import type { ModelAlias } from '../model/aliases.js' | |
| import { parseToolListFromCLI } from '../permissions/permissionSetup.js' | |
| import { hasPermissionsToUseTool } from '../permissions/permissions.js' | |
| import { | |
| isOfficialMarketplaceName, | |
| parsePluginIdentifier, | |
| } from '../plugins/pluginIdentifier.js' | |
| import { | |
| isRestrictedToPluginOnly, | |
| isSourceAdminTrusted, | |
| } from '../settings/pluginOnlyPolicy.js' | |
| import { parseSlashCommand } from '../slashCommandParsing.js' | |
| import { sleep } from '../sleep.js' | |
| import { recordSkillUsage } from '../suggestions/skillUsageTracking.js' | |
| import { logOTelEvent, redactIfDisabled } from '../telemetry/events.js' | |
| import { buildPluginCommandTelemetryFields } from '../telemetry/pluginTelemetry.js' | |
| import { getAssistantMessageContentLength } from '../tokens.js' | |
| import { createAgentId } from '../uuid.js' | |
| import { getWorkload } from '../workloadContext.js' | |
| import type { | |
| ProcessUserInputBaseResult, | |
| ProcessUserInputContext, | |
| } from './processUserInput.js' | |
| type SlashCommandResult = ProcessUserInputBaseResult & { | |
| command: Command | |
| } | |
| // Poll interval and deadline for MCP settle before launching a background | |
| // forked subagent. MCP servers typically connect within 1-3s of startup; | |
| // 10s headroom covers slow SSE handshakes. | |
| const MCP_SETTLE_POLL_MS = 200 | |
| const MCP_SETTLE_TIMEOUT_MS = 10_000 | |
| /** | |
| * Executes a slash command with context: fork in a sub-agent. | |
| */ | |
| async function executeForkedSlashCommand( | |
| command: CommandBase & PromptCommand, | |
| args: string, | |
| context: ProcessUserInputContext, | |
| precedingInputBlocks: ContentBlockParam[], | |
| setToolJSX: SetToolJSXFn, | |
| canUseTool: CanUseToolFn, | |
| ): Promise<SlashCommandResult> { | |
| const agentId = createAgentId() | |
| const pluginMarketplace = command.pluginInfo | |
| ? parsePluginIdentifier(command.pluginInfo.repository).marketplace | |
| : undefined | |
| logEvent('tengu_slash_command_forked', { | |
| command_name: | |
| command.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| invocation_trigger: | |
| 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| ...(command.pluginInfo && { | |
| _PROTO_plugin_name: command.pluginInfo.pluginManifest | |
| .name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| ...(pluginMarketplace && { | |
| _PROTO_marketplace_name: | |
| pluginMarketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, | |
| }), | |
| ...buildPluginCommandTelemetryFields(command.pluginInfo), | |
| }), | |
| }) | |
| const { skillContent, modifiedGetAppState, baseAgent, promptMessages } = | |
| await prepareForkedCommandContext(command, args, context) | |
| // Merge skill's effort into the agent definition so runAgent applies it | |
| const agentDefinition = | |
| command.effort !== undefined | |
| ? { ...baseAgent, effort: command.effort } | |
| : baseAgent | |
| logForDebugging( | |
| `Executing forked slash command /${command.name} with agent ${agentDefinition.agentType}`, | |
| ) | |
| // Assistant mode: fire-and-forget. Launch subagent in background, return | |
| // immediately, re-enqueue the result as an isMeta prompt when done. | |
| // Without this, N scheduled tasks on startup = N serial (subagent + main | |
| // agent turn) cycles blocking user input. With this, N subagents run in | |
| // parallel and results trickle into the queue as they finish. | |
| // | |
| // Gated on kairosEnabled (not CLAUDE_CODE_BRIEF) because the closed loop | |
| // depends on assistant-mode invariants: scheduled_tasks.json exists, | |
| // the main agent knows to pipe results through SendUserMessage, and | |
| // isMeta prompts are hidden. Outside assistant mode, context:fork commands | |
| // are user-invoked skills (/commit etc.) that should run synchronously | |
| // with the progress UI. | |
| if (feature('KAIROS') && (await context.getAppState()).kairosEnabled) { | |
| // Standalone abortController — background subagents survive main-thread | |
| // ESC (same policy as AgentTool's async path). They're cron-driven; if | |
| // killed mid-run they just re-fire on the next schedule. | |
| const bgAbortController = createAbortController() | |
| const commandName = getCommandName(command) | |
| // Workload: handlePromptSubmit wraps the entire turn in runWithWorkload | |
| // (AsyncLocalStorage). ALS context is captured when this `void` fires | |
| // and survives every await inside — isolated from the parent's | |
| // continuation. The detached closure's runAgent calls see the cron tag | |
| // automatically. We still capture the value here ONLY for the | |
| // re-enqueued result prompt below: that second turn runs in a fresh | |
| // handlePromptSubmit → fresh runWithWorkload boundary (which always | |
| // establishes a new context, even for `undefined`) → so it needs its | |
| // own QueuedCommand.workload tag to preserve attribution. | |
| const spawnTimeWorkload = getWorkload() | |
| // Re-enter the queue as a hidden prompt. isMeta: hides from queue | |
| // preview + placeholder + transcript. skipSlashCommands: prevents | |
| // re-parsing if the result text happens to start with '/'. When | |
| // drained, this triggers a main-agent turn that sees the result and | |
| // decides whether to SendUserMessage. Propagate workload so that | |
| // second turn is also tagged. | |
| const enqueueResult = (value: string): void => | |
| enqueuePendingNotification({ | |
| value, | |
| mode: 'prompt', | |
| priority: 'later', | |
| isMeta: true, | |
| skipSlashCommands: true, | |
| workload: spawnTimeWorkload, | |
| }) | |
| void (async () => { | |
| // Wait for MCP servers to settle. Scheduled tasks fire at startup and | |
| // all N drain within ~1ms (since we return immediately), capturing | |
| // context.options.tools before MCP connects. The sync path | |
| // accidentally avoided this — tasks serialized, so task N's drain | |
| // happened after task N-1's 30s run, by which time MCP was up. | |
| // Poll until no 'pending' clients remain, then refresh. | |
| const deadline = Date.now() + MCP_SETTLE_TIMEOUT_MS | |
| while (Date.now() < deadline) { | |
| const s = context.getAppState() | |
| if (!s.mcp.clients.some(c => c.type === 'pending')) break | |
| await sleep(MCP_SETTLE_POLL_MS) | |
| } | |
| const freshTools = | |
| context.options.refreshTools?.() ?? context.options.tools | |
| const agentMessages: Message[] = [] | |
| for await (const message of runAgent({ | |
| agentDefinition, | |
| promptMessages, | |
| toolUseContext: { | |
| ...context, | |
| getAppState: modifiedGetAppState, | |
| abortController: bgAbortController, | |
| }, | |
| canUseTool, | |
| isAsync: true, | |
| querySource: 'agent:custom', | |
| model: command.model as ModelAlias | undefined, | |
| availableTools: freshTools, | |
| override: { agentId }, | |
| })) { | |
| agentMessages.push(message) | |
| } | |
| const resultText = extractResultText(agentMessages, 'Command completed') | |
| logForDebugging( | |
| `Background forked command /${commandName} completed (agent ${agentId})`, | |
| ) | |
| enqueueResult( | |
| `<scheduled-task-result command="/${commandName}">\n${resultText}\n</scheduled-task-result>`, | |
| ) | |
| })().catch(err => { | |
| logError(err) | |
| enqueueResult( | |
| `<scheduled-task-result command="/${commandName}" status="failed">\n${err instanceof Error ? err.message : String(err)}\n</scheduled-task-result>`, | |
| ) | |
| }) | |
| // Nothing to render, nothing to query — the background runner re-enters | |
| // the queue on its own schedule. | |
| return { messages: [], shouldQuery: false, command } | |
| } | |
| // Collect messages from the forked agent | |
| const agentMessages: Message[] = [] | |
| // Build progress messages for the agent progress UI | |
| const progressMessages: ProgressMessage<AgentProgress>[] = [] | |
| const parentToolUseID = `forked-command-${command.name}` | |
| let toolUseCounter = 0 | |
| // Helper to create a progress message from an agent message | |
| const createProgressMessage = ( | |
| message: AssistantMessage | NormalizedUserMessage, | |
| ): ProgressMessage<AgentProgress> => { | |
| toolUseCounter++ | |
| return { | |
| type: 'progress', | |
| data: { | |
| message, | |
| type: 'agent_progress', | |
| prompt: skillContent, | |
| agentId, | |
| }, | |
| parentToolUseID, | |
| toolUseID: `${parentToolUseID}-${toolUseCounter}`, | |
| timestamp: new Date().toISOString(), | |
| uuid: randomUUID(), | |
| } | |
| } | |
| // Helper to update progress display using agent progress UI | |
| const updateProgress = (): void => { | |
| setToolJSX({ | |
| jsx: renderToolUseProgressMessage(progressMessages, { | |
| tools: context.options.tools, | |
| verbose: false, | |
| }), | |
| shouldHidePromptInput: false, | |
| shouldContinueAnimation: true, | |
| showSpinner: true, | |
| }) | |
| } | |
| // Show initial "Initializing…" state | |
| updateProgress() | |
| // Run the sub-agent | |
| try { | |
| for await (const message of runAgent({ | |
| agentDefinition, | |
| promptMessages, | |
| toolUseContext: { | |
| ...context, | |
| getAppState: modifiedGetAppState, | |
| }, | |
| canUseTool, | |
| isAsync: false, | |
| querySource: 'agent:custom', | |
| model: command.model as ModelAlias | undefined, | |
| availableTools: context.options.tools, | |
| })) { | |
| agentMessages.push(message) | |
| const normalizedNew = normalizeMessages([message]) | |
| // Add progress message for assistant messages (which contain tool uses) | |
| if (message.type === 'assistant') { | |
| // Increment token count in spinner for assistant messages | |
| const contentLength = getAssistantMessageContentLength(message) | |
| if (contentLength > 0) { | |
| context.setResponseLength(len => len + contentLength) | |
| } | |
| const normalizedMsg = normalizedNew[0] | |
| if (normalizedMsg && normalizedMsg.type === 'assistant') { | |
| progressMessages.push(createProgressMessage(message)) | |
| updateProgress() | |
| } | |
| } | |
| // Add progress message for user messages (which contain tool results) | |
| if (message.type === 'user') { | |
| const normalizedMsg = normalizedNew[0] | |
| if (normalizedMsg && normalizedMsg.type === 'user') { | |
| progressMessages.push(createProgressMessage(normalizedMsg)) | |
| updateProgress() | |
| } | |
| } | |
| } | |
| } finally { | |
| // Clear the progress display | |
| setToolJSX(null) | |
| } | |
| let resultText = extractResultText(agentMessages, 'Command completed') | |
| logForDebugging( | |
| `Forked slash command /${command.name} completed with agent ${agentId}`, | |
| ) | |
| // Prepend debug log for ant users so it appears inside the command output | |
| if ("external" === 'ant') { | |
| resultText = `[ANT-ONLY] API calls: ${getDisplayPath(getDumpPromptsPath(agentId))}\n${resultText}` | |
| } | |
| // Return the result as a user message (simulates the agent's output) | |
| const messages: UserMessage[] = [ | |
| createUserMessage({ | |
| content: prepareUserContent({ | |
| inputString: `/${getCommandName(command)} ${args}`.trim(), | |
| precedingInputBlocks, | |
| }), | |
| }), | |
| createUserMessage({ | |
| content: `<local-command-stdout>\n${resultText}\n</local-command-stdout>`, | |
| }), | |
| ] | |
| return { | |
| messages, | |
| shouldQuery: false, | |
| command, | |
| resultText, | |
| } | |
| } | |
| /** | |
| * Determines if a string looks like a valid command name. | |
| * Valid command names only contain letters, numbers, colons, hyphens, and underscores. | |
| * | |
| * @param commandName - The potential command name to check | |
| * @returns true if it looks like a command name, false if it contains non-command characters | |
| */ | |
| export function looksLikeCommand(commandName: string): boolean { | |
| // Command names should only contain [a-zA-Z0-9:_-] | |
| // If it contains other characters, it's probably a file path or other input | |
| return !/[^a-zA-Z0-9:\-_]/.test(commandName) | |
| } | |
| export async function processSlashCommand( | |
| inputString: string, | |
| precedingInputBlocks: ContentBlockParam[], | |
| imageContentBlocks: ContentBlockParam[], | |
| attachmentMessages: AttachmentMessage[], | |
| context: ProcessUserInputContext, | |
| setToolJSX: SetToolJSXFn, | |
| uuid?: string, | |
| isAlreadyProcessing?: boolean, | |
| canUseTool?: CanUseToolFn, | |
| ): Promise<ProcessUserInputBaseResult> { | |
| const parsed = parseSlashCommand(inputString) | |
| if (!parsed) { | |
| logEvent('tengu_input_slash_missing', {}) | |
| const errorMessage = 'Commands are in the form `/command [args]`' | |
| return { | |
| messages: [ | |
| createSyntheticUserCaveatMessage(), | |
| ...attachmentMessages, | |
| createUserMessage({ | |
| content: prepareUserContent({ | |
| inputString: errorMessage, | |
| precedingInputBlocks, | |
| }), | |
| }), | |
| ], | |
| shouldQuery: false, | |
| resultText: errorMessage, | |
| } | |
| } | |
| const { commandName, args: parsedArgs, isMcp } = parsed | |
| const sanitizedCommandName = isMcp | |
| ? 'mcp' | |
| : !builtInCommandNames().has(commandName) | |
| ? 'custom' | |
| : commandName | |
| // Check if it's a real command before processing | |
| if (!hasCommand(commandName, context.options.commands)) { | |
| // Check if this looks like a command name vs a file path or other input | |
| // Also check if it's an actual file path that exists | |
| let isFilePath = false | |
| try { | |
| await getFsImplementation().stat(`/${commandName}`) | |
| isFilePath = true | |
| } catch { | |
| // Not a file path — treat as command name | |
| } | |
| if (looksLikeCommand(commandName) && !isFilePath) { | |
| logEvent('tengu_input_slash_invalid', { | |
| input: | |
| commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| const unknownMessage = `Unknown skill: ${commandName}` | |
| return { | |
| messages: [ | |
| createSyntheticUserCaveatMessage(), | |
| ...attachmentMessages, | |
| createUserMessage({ | |
| content: prepareUserContent({ | |
| inputString: unknownMessage, | |
| precedingInputBlocks, | |
| }), | |
| }), | |
| // gh-32591: preserve args so the user can copy/resubmit without | |
| // retyping. System warning is UI-only (filtered before API). | |
| ...(parsedArgs | |
| ? [ | |
| createSystemMessage( | |
| `Args from unknown skill: ${parsedArgs}`, | |
| 'warning', | |
| ), | |
| ] | |
| : []), | |
| ], | |
| shouldQuery: false, | |
| resultText: unknownMessage, | |
| } | |
| } | |
| const promptId = randomUUID() | |
| setPromptId(promptId) | |
| logEvent('tengu_input_prompt', {}) | |
| // Log user prompt event for OTLP | |
| void logOTelEvent('user_prompt', { | |
| prompt_length: String(inputString.length), | |
| prompt: redactIfDisabled(inputString), | |
| 'prompt.id': promptId, | |
| }) | |
| return { | |
| messages: [ | |
| createUserMessage({ | |
| content: prepareUserContent({ inputString, precedingInputBlocks }), | |
| uuid: uuid, | |
| }), | |
| ...attachmentMessages, | |
| ], | |
| shouldQuery: true, | |
| } | |
| } | |
| // Track slash command usage for feature discovery | |
| const { | |
| messages: newMessages, | |
| shouldQuery: messageShouldQuery, | |
| allowedTools, | |
| model, | |
| effort, | |
| command: returnedCommand, | |
| resultText, | |
| nextInput, | |
| submitNextInput, | |
| } = await getMessagesForSlashCommand( | |
| commandName, | |
| parsedArgs, | |
| setToolJSX, | |
| context, | |
| precedingInputBlocks, | |
| imageContentBlocks, | |
| isAlreadyProcessing, | |
| canUseTool, | |
| uuid, | |
| ) | |
| // Local slash commands that skip messages | |
| if (newMessages.length === 0) { | |
| const eventData: Record<string, boolean | number | undefined> = { | |
| input: | |
| sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| } | |
| // Add plugin metadata if this is a plugin command | |
| if (returnedCommand.type === 'prompt' && returnedCommand.pluginInfo) { | |
| const { pluginManifest, repository } = returnedCommand.pluginInfo | |
| const { marketplace } = parsePluginIdentifier(repository) | |
| const isOfficial = isOfficialMarketplaceName(marketplace) | |
| // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns | |
| // (unredacted, all users); plugin_name/plugin_repository stay in | |
| // additional_metadata as redacted variants for general-access dashboards. | |
| eventData._PROTO_plugin_name = | |
| pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED | |
| if (marketplace) { | |
| eventData._PROTO_marketplace_name = | |
| marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED | |
| } | |
| eventData.plugin_repository = ( | |
| isOfficial ? repository : 'third-party' | |
| ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | |
| eventData.plugin_name = ( | |
| isOfficial ? pluginManifest.name : 'third-party' | |
| ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | |
| if (isOfficial && pluginManifest.version) { | |
| eventData.plugin_version = | |
| pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | |
| } | |
| Object.assign( | |
| eventData, | |
| buildPluginCommandTelemetryFields(returnedCommand.pluginInfo), | |
| ) | |
| } | |
| logEvent('tengu_input_command', { | |
| ...eventData, | |
| invocation_trigger: | |
| 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| ...("external" === 'ant' && { | |
| skill_name: | |
| commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| ...(returnedCommand.type === 'prompt' && { | |
| skill_source: | |
| returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| ...(returnedCommand.loadedFrom && { | |
| skill_loaded_from: | |
| returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| ...(returnedCommand.kind && { | |
| skill_kind: | |
| returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| }), | |
| }) | |
| return { | |
| messages: [], | |
| shouldQuery: false, | |
| model, | |
| nextInput, | |
| submitNextInput, | |
| } | |
| } | |
| // For invalid commands, preserve both the user message and error | |
| if ( | |
| newMessages.length === 2 && | |
| newMessages[1]!.type === 'user' && | |
| typeof newMessages[1]!.message.content === 'string' && | |
| newMessages[1]!.message.content.startsWith('Unknown command:') | |
| ) { | |
| // Don't log as invalid if it looks like a common file path | |
| const looksLikeFilePath = | |
| inputString.startsWith('/var') || | |
| inputString.startsWith('/tmp') || | |
| inputString.startsWith('/private') | |
| if (!looksLikeFilePath) { | |
| logEvent('tengu_input_slash_invalid', { | |
| input: | |
| commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| } | |
| return { | |
| messages: [createSyntheticUserCaveatMessage(), ...newMessages], | |
| shouldQuery: messageShouldQuery, | |
| allowedTools, | |
| model, | |
| } | |
| } | |
| // A valid command | |
| const eventData: Record<string, boolean | number | undefined> = { | |
| input: | |
| sanitizedCommandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| } | |
| // Add plugin metadata if this is a plugin command | |
| if (returnedCommand.type === 'prompt' && returnedCommand.pluginInfo) { | |
| const { pluginManifest, repository } = returnedCommand.pluginInfo | |
| const { marketplace } = parsePluginIdentifier(repository) | |
| const isOfficial = isOfficialMarketplaceName(marketplace) | |
| eventData._PROTO_plugin_name = | |
| pluginManifest.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED | |
| if (marketplace) { | |
| eventData._PROTO_marketplace_name = | |
| marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED | |
| } | |
| eventData.plugin_repository = ( | |
| isOfficial ? repository : 'third-party' | |
| ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | |
| eventData.plugin_name = ( | |
| isOfficial ? pluginManifest.name : 'third-party' | |
| ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | |
| if (isOfficial && pluginManifest.version) { | |
| eventData.plugin_version = | |
| pluginManifest.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | |
| } | |
| Object.assign( | |
| eventData, | |
| buildPluginCommandTelemetryFields(returnedCommand.pluginInfo), | |
| ) | |
| } | |
| logEvent('tengu_input_command', { | |
| ...eventData, | |
| invocation_trigger: | |
| 'user-slash' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| ...("external" === 'ant' && { | |
| skill_name: | |
| commandName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| ...(returnedCommand.type === 'prompt' && { | |
| skill_source: | |
| returnedCommand.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| ...(returnedCommand.loadedFrom && { | |
| skill_loaded_from: | |
| returnedCommand.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| ...(returnedCommand.kind && { | |
| skill_kind: | |
| returnedCommand.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }), | |
| }), | |
| }) | |
| // Check if this is a compact result which handle their own synthetic caveat message ordering | |
| const isCompactResult = | |
| newMessages.length > 0 && | |
| newMessages[0] && | |
| isCompactBoundaryMessage(newMessages[0]) | |
| return { | |
| messages: | |
| messageShouldQuery || | |
| newMessages.every(isSystemLocalCommandMessage) || | |
| isCompactResult | |
| ? newMessages | |
| : [createSyntheticUserCaveatMessage(), ...newMessages], | |
| shouldQuery: messageShouldQuery, | |
| allowedTools, | |
| model, | |
| effort, | |
| resultText, | |
| nextInput, | |
| submitNextInput, | |
| } | |
| } | |
| async function getMessagesForSlashCommand( | |
| commandName: string, | |
| args: string, | |
| setToolJSX: SetToolJSXFn, | |
| context: ProcessUserInputContext, | |
| precedingInputBlocks: ContentBlockParam[], | |
| imageContentBlocks: ContentBlockParam[], | |
| _isAlreadyProcessing?: boolean, | |
| canUseTool?: CanUseToolFn, | |
| uuid?: string, | |
| ): Promise<SlashCommandResult> { | |
| const command = getCommand(commandName, context.options.commands) | |
| // Track skill usage for ranking (only for prompt commands that are user-invocable) | |
| if (command.type === 'prompt' && command.userInvocable !== false) { | |
| recordSkillUsage(commandName) | |
| } | |
| // Check if the command is user-invocable | |
| // Skills with userInvocable === false can only be invoked by the model via SkillTool | |
| if (command.userInvocable === false) { | |
| return { | |
| messages: [ | |
| createUserMessage({ | |
| content: prepareUserContent({ | |
| inputString: `/${commandName}`, | |
| precedingInputBlocks, | |
| }), | |
| }), | |
| createUserMessage({ | |
| content: `This skill can only be invoked by Claude, not directly by users. Ask Claude to use the "${commandName}" skill for you.`, | |
| }), | |
| ], | |
| shouldQuery: false, | |
| command, | |
| } | |
| } | |
| try { | |
| switch (command.type) { | |
| case 'local-jsx': { | |
| return new Promise<SlashCommandResult>(resolve => { | |
| let doneWasCalled = false | |
| const onDone = ( | |
| result?: string, | |
| options?: { | |
| display?: CommandResultDisplay | |
| shouldQuery?: boolean | |
| metaMessages?: string[] | |
| nextInput?: string | |
| submitNextInput?: boolean | |
| }, | |
| ) => { | |
| doneWasCalled = true | |
| // If display is 'skip', don't add any messages to the conversation | |
| if (options?.display === 'skip') { | |
| void resolve({ | |
| messages: [], | |
| shouldQuery: false, | |
| command, | |
| nextInput: options?.nextInput, | |
| submitNextInput: options?.submitNextInput, | |
| }) | |
| return | |
| } | |
| // Meta messages are model-visible but hidden from the user | |
| const metaMessages = (options?.metaMessages ?? []).map( | |
| (content: string) => createUserMessage({ content, isMeta: true }), | |
| ) | |
| // In fullscreen the command just showed as a centered modal | |
| // pane — the transient notification is enough feedback. The | |
| // "❯ /config" + "⎿ dismissed" transcript entries are | |
| // type:system subtype:local_command (user-visible but NOT sent | |
| // to the model), so skipping them doesn't affect model context. | |
| // Outside fullscreen keep them so scrollback shows what ran. | |
| // Only skip "<Name> dismissed" modal-close notifications — | |
| // commands that early-exit before showing a modal (/ultraplan | |
| // usage, /rename, /proactive) use display:system for actual | |
| // output that must reach the transcript. | |
| const skipTranscript = | |
| isFullscreenEnvEnabled() && | |
| typeof result === 'string' && | |
| result.endsWith(' dismissed') | |
| void resolve({ | |
| messages: | |
| options?.display === 'system' | |
| ? skipTranscript | |
| ? metaMessages | |
| : [ | |
| createCommandInputMessage( | |
| formatCommandInput(command, args), | |
| ), | |
| createCommandInputMessage( | |
| `<local-command-stdout>${result}</local-command-stdout>`, | |
| ), | |
| ...metaMessages, | |
| ] | |
| : [ | |
| createUserMessage({ | |
| content: prepareUserContent({ | |
| inputString: formatCommandInput(command, args), | |
| precedingInputBlocks, | |
| }), | |
| }), | |
| result | |
| ? createUserMessage({ | |
| content: `<local-command-stdout>${result}</local-command-stdout>`, | |
| }) | |
| : createUserMessage({ | |
| content: `<local-command-stdout>${NO_CONTENT_MESSAGE}</local-command-stdout>`, | |
| }), | |
| ...metaMessages, | |
| ], | |
| shouldQuery: options?.shouldQuery ?? false, | |
| command, | |
| nextInput: options?.nextInput, | |
| submitNextInput: options?.submitNextInput, | |
| }) | |
| } | |
| void command | |
| .load() | |
| .then(mod => mod.call(onDone, { ...context, canUseTool }, args)) | |
| .then(jsx => { | |
| if (jsx == null) return | |
| if (context.options.isNonInteractiveSession) { | |
| void resolve({ | |
| messages: [], | |
| shouldQuery: false, | |
| command, | |
| }) | |
| return | |
| } | |
| // Guard: if onDone fired during mod.call() (early-exit path | |
| // that calls onDone then returns JSX), skip setToolJSX. This | |
| // chain is fire-and-forget — the outer Promise resolves when | |
| // onDone is called, so executeUserInput may have already run | |
| // its setToolJSX({clearLocalJSX: true}) before we get here. | |
| // Setting isLocalJSXCommand after clear leaves it stuck true, | |
| // blocking useQueueProcessor and TextInput focus. | |
| if (doneWasCalled) return | |
| setToolJSX({ | |
| jsx, | |
| shouldHidePromptInput: true, | |
| showSpinner: false, | |
| isLocalJSXCommand: true, | |
| isImmediate: command.immediate === true, | |
| }) | |
| }) | |
| .catch(e => { | |
| // If load()/call() throws and onDone never fired, the outer | |
| // Promise hangs forever, leaving queryGuard stuck in | |
| // 'dispatching' and deadlocking the queue processor. | |
| logError(e) | |
| if (doneWasCalled) return | |
| doneWasCalled = true | |
| setToolJSX({ | |
| jsx: null, | |
| shouldHidePromptInput: false, | |
| clearLocalJSX: true, | |
| }) | |
| void resolve({ messages: [], shouldQuery: false, command }) | |
| }) | |
| }) | |
| } | |
| case 'local': { | |
| const displayArgs = command.isSensitive && args.trim() ? '***' : args | |
| const userMessage = createUserMessage({ | |
| content: prepareUserContent({ | |
| inputString: formatCommandInput(command, displayArgs), | |
| precedingInputBlocks, | |
| }), | |
| }) | |
| try { | |
| const syntheticCaveatMessage = createSyntheticUserCaveatMessage() | |
| const mod = await command.load() | |
| const result = await mod.call(args, context) | |
| if (result.type === 'skip') { | |
| return { | |
| messages: [], | |
| shouldQuery: false, | |
| command, | |
| } | |
| } | |
| // Use discriminated union to handle different result types | |
| if (result.type === 'compact') { | |
| // Append slash command messages to messagesToKeep so that | |
| // attachments and hookResults come after user messages | |
| const slashCommandMessages = [ | |
| syntheticCaveatMessage, | |
| userMessage, | |
| ...(result.displayText | |
| ? [ | |
| createUserMessage({ | |
| content: `<local-command-stdout>${result.displayText}</local-command-stdout>`, | |
| // --resume looks at latest timestamp message to determine which message to resume from | |
| // This is a perf optimization to avoid having to recaculcate the leaf node every time | |
| // Since we're creating a bunch of synthetic messages for compact, it's important to set | |
| // the timestamp of the last message to be slightly after the current time | |
| // This is mostly important for sdk / -p mode | |
| timestamp: new Date(Date.now() + 100).toISOString(), | |
| }), | |
| ] | |
| : []), | |
| ] | |
| const compactionResultWithSlashMessages = { | |
| ...result.compactionResult, | |
| messagesToKeep: [ | |
| ...(result.compactionResult.messagesToKeep ?? []), | |
| ...slashCommandMessages, | |
| ], | |
| } | |
| // Reset microcompact state since full compact replaces all | |
| // messages — old tool IDs are no longer relevant. Budget state | |
| // (on toolUseContext) needs no reset: stale entries are inert | |
| // (UUIDs never repeat, so they're never looked up). | |
| resetMicrocompactState() | |
| return { | |
| messages: buildPostCompactMessages( | |
| compactionResultWithSlashMessages, | |
| ), | |
| shouldQuery: false, | |
| command, | |
| } | |
| } | |
| // Text result — use system message so it doesn't render as a user bubble | |
| return { | |
| messages: [ | |
| userMessage, | |
| createCommandInputMessage( | |
| `<local-command-stdout>${result.value}</local-command-stdout>`, | |
| ), | |
| ], | |
| shouldQuery: false, | |
| command, | |
| resultText: result.value, | |
| } | |
| } catch (e) { | |
| logError(e) | |
| return { | |
| messages: [ | |
| userMessage, | |
| createCommandInputMessage( | |
| `<local-command-stderr>${String(e)}</local-command-stderr>`, | |
| ), | |
| ], | |
| shouldQuery: false, | |
| command, | |
| } | |
| } | |
| } | |
| case 'prompt': { | |
| try { | |
| // Check if command should run as forked sub-agent | |
| if (command.context === 'fork') { | |
| return await executeForkedSlashCommand( | |
| command, | |
| args, | |
| context, | |
| precedingInputBlocks, | |
| setToolJSX, | |
| canUseTool ?? hasPermissionsToUseTool, | |
| ) | |
| } | |
| return await getMessagesForPromptSlashCommand( | |
| command, | |
| args, | |
| context, | |
| precedingInputBlocks, | |
| imageContentBlocks, | |
| uuid, | |
| ) | |
| } catch (e) { | |
| // Handle abort errors specially to show proper "Interrupted" message | |
| if (e instanceof AbortError) { | |
| return { | |
| messages: [ | |
| createUserMessage({ | |
| content: prepareUserContent({ | |
| inputString: formatCommandInput(command, args), | |
| precedingInputBlocks, | |
| }), | |
| }), | |
| createUserInterruptionMessage({ toolUse: false }), | |
| ], | |
| shouldQuery: false, | |
| command, | |
| } | |
| } | |
| return { | |
| messages: [ | |
| createUserMessage({ | |
| content: prepareUserContent({ | |
| inputString: formatCommandInput(command, args), | |
| precedingInputBlocks, | |
| }), | |
| }), | |
| createUserMessage({ | |
| content: `<local-command-stderr>${String(e)}</local-command-stderr>`, | |
| }), | |
| ], | |
| shouldQuery: false, | |
| command, | |
| } | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| if (e instanceof MalformedCommandError) { | |
| return { | |
| messages: [ | |
| createUserMessage({ | |
| content: prepareUserContent({ | |
| inputString: e.message, | |
| precedingInputBlocks, | |
| }), | |
| }), | |
| ], | |
| shouldQuery: false, | |
| command, | |
| } | |
| } | |
| throw e | |
| } | |
| } | |
| function formatCommandInput(command: CommandBase, args: string): string { | |
| return formatCommandInputTags(getCommandName(command), args) | |
| } | |
| /** | |
| * Formats the metadata for a skill loading message. | |
| * Used by the Skill tool and for subagent skill preloading. | |
| */ | |
| export function formatSkillLoadingMetadata( | |
| skillName: string, | |
| _progressMessage: string = 'loading', | |
| ): string { | |
| // Use skill name only - UserCommandMessage renders as "Skill(name)" | |
| return [ | |
| `<${COMMAND_MESSAGE_TAG}>${skillName}</${COMMAND_MESSAGE_TAG}>`, | |
| `<${COMMAND_NAME_TAG}>${skillName}</${COMMAND_NAME_TAG}>`, | |
| `<skill-format>true</skill-format>`, | |
| ].join('\n') | |
| } | |
| /** | |
| * Formats the metadata for a slash command loading message. | |
| */ | |
| function formatSlashCommandLoadingMetadata( | |
| commandName: string, | |
| args?: string, | |
| ): string { | |
| return [ | |
| `<${COMMAND_MESSAGE_TAG}>${commandName}</${COMMAND_MESSAGE_TAG}>`, | |
| `<${COMMAND_NAME_TAG}>/${commandName}</${COMMAND_NAME_TAG}>`, | |
| args ? `<command-args>${args}</command-args>` : null, | |
| ] | |
| .filter(Boolean) | |
| .join('\n') | |
| } | |
| /** | |
| * Formats the loading metadata for a command (skill or slash command). | |
| * User-invocable skills use slash command format (/name), while model-only | |
| * skills use the skill format ("The X skill is running"). | |
| */ | |
| function formatCommandLoadingMetadata( | |
| command: CommandBase & PromptCommand, | |
| args?: string, | |
| ): string { | |
| // Use command.name (the qualified name including plugin prefix, e.g. | |
| // "product-management:feature-spec") instead of userFacingName() which may | |
| // strip the plugin prefix via displayName fallback. | |
| // User-invocable skills should show as /command-name like regular slash commands | |
| if (command.userInvocable !== false) { | |
| return formatSlashCommandLoadingMetadata(command.name, args) | |
| } | |
| // Model-only skills (userInvocable: false) show as "The X skill is running" | |
| if ( | |
| command.loadedFrom === 'skills' || | |
| command.loadedFrom === 'plugin' || | |
| command.loadedFrom === 'mcp' | |
| ) { | |
| return formatSkillLoadingMetadata(command.name, command.progressMessage) | |
| } | |
| return formatSlashCommandLoadingMetadata(command.name, args) | |
| } | |
| export async function processPromptSlashCommand( | |
| commandName: string, | |
| args: string, | |
| commands: Command[], | |
| context: ToolUseContext, | |
| imageContentBlocks: ContentBlockParam[] = [], | |
| ): Promise<SlashCommandResult> { | |
| const command = findCommand(commandName, commands) | |
| if (!command) { | |
| throw new MalformedCommandError(`Unknown command: ${commandName}`) | |
| } | |
| if (command.type !== 'prompt') { | |
| throw new Error( | |
| `Unexpected ${command.type} command. Expected 'prompt' command. Use /${commandName} directly in the main conversation.`, | |
| ) | |
| } | |
| return getMessagesForPromptSlashCommand( | |
| command, | |
| args, | |
| context, | |
| [], | |
| imageContentBlocks, | |
| ) | |
| } | |
| async function getMessagesForPromptSlashCommand( | |
| command: CommandBase & PromptCommand, | |
| args: string, | |
| context: ToolUseContext, | |
| precedingInputBlocks: ContentBlockParam[] = [], | |
| imageContentBlocks: ContentBlockParam[] = [], | |
| uuid?: string, | |
| ): Promise<SlashCommandResult> { | |
| // In coordinator mode (main thread only), skip loading the full skill content | |
| // and permissions. The coordinator only has Agent + TaskStop tools, so the | |
| // skill content and allowedTools are useless. Instead, send a brief summary | |
| // telling the coordinator how to delegate this skill to a worker. | |
| // | |
| // Workers run in-process and inherit CLAUDE_CODE_COORDINATOR_MODE from the | |
| // parent env, so we also check !context.agentId: agentId is only set for | |
| // subagents, letting workers fall through to getPromptForCommand and receive | |
| // the real skill content when they invoke the Skill tool. | |
| if ( | |
| feature('COORDINATOR_MODE') && | |
| isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) && | |
| !context.agentId | |
| ) { | |
| const metadata = formatCommandLoadingMetadata(command, args) | |
| const parts: string[] = [ | |
| `Skill "/${command.name}" is available for workers.`, | |
| ] | |
| if (command.description) { | |
| parts.push(`Description: ${command.description}`) | |
| } | |
| if (command.whenToUse) { | |
| parts.push(`When to use: ${command.whenToUse}`) | |
| } | |
| const skillAllowedTools = command.allowedTools ?? [] | |
| if (skillAllowedTools.length > 0) { | |
| parts.push( | |
| `This skill grants workers additional tool permissions: ${skillAllowedTools.join(', ')}`, | |
| ) | |
| } | |
| parts.push( | |
| `\nInstruct a worker to use this skill by including "Use the /${command.name} skill" in your Agent prompt. The worker has access to the Skill tool and will receive the skill's content and permissions when it invokes it.`, | |
| ) | |
| const summaryContent: ContentBlockParam[] = [ | |
| { type: 'text', text: parts.join('\n') }, | |
| ] | |
| return { | |
| messages: [ | |
| createUserMessage({ content: metadata, uuid }), | |
| createUserMessage({ content: summaryContent, isMeta: true }), | |
| ], | |
| shouldQuery: true, | |
| model: command.model, | |
| effort: command.effort, | |
| command, | |
| } | |
| } | |
| const result = await command.getPromptForCommand(args, context) | |
| // Register skill hooks if defined. Under ["hooks"]-only (skills not locked), | |
| // user skills still load and reach this point — block hook REGISTRATION here | |
| // where source is known. Mirrors the agent frontmatter gate in runAgent.ts. | |
| const hooksAllowedForThisSkill = | |
| !isRestrictedToPluginOnly('hooks') || isSourceAdminTrusted(command.source) | |
| if (command.hooks && hooksAllowedForThisSkill) { | |
| const sessionId = getSessionId() | |
| registerSkillHooks( | |
| context.setAppState, | |
| sessionId, | |
| command.hooks, | |
| command.name, | |
| command.type === 'prompt' ? command.skillRoot : undefined, | |
| ) | |
| } | |
| // Record skill invocation for compaction preservation, scoped by agent context. | |
| // Skills are tagged with their agentId so only skills belonging to the current | |
| // agent are restored during compaction (preventing cross-agent leaks). | |
| const skillPath = command.source | |
| ? `${command.source}:${command.name}` | |
| : command.name | |
| const skillContent = result | |
| .filter((b): b is TextBlockParam => b.type === 'text') | |
| .map(b => b.text) | |
| .join('\n\n') | |
| addInvokedSkill( | |
| command.name, | |
| skillPath, | |
| skillContent, | |
| getAgentContext()?.agentId ?? null, | |
| ) | |
| const metadata = formatCommandLoadingMetadata(command, args) | |
| const additionalAllowedTools = parseToolListFromCLI( | |
| command.allowedTools ?? [], | |
| ) | |
| // Create content for the main message, including any pasted images | |
| const mainMessageContent: ContentBlockParam[] = | |
| imageContentBlocks.length > 0 || precedingInputBlocks.length > 0 | |
| ? [...imageContentBlocks, ...precedingInputBlocks, ...result] | |
| : result | |
| // Extract attachments from command arguments (@-mentions, MCP resources, | |
| // agent mentions in SKILL.md). skipSkillDiscovery prevents the SKILL.md | |
| // content itself from triggering discovery — it's meta-content, not user | |
| // intent, and a large SKILL.md (e.g. 110KB) would fire chunked AKI queries | |
| // adding seconds of latency to every skill invocation. | |
| const attachmentMessages = await toArray( | |
| getAttachmentMessages( | |
| result | |
| .filter((block): block is TextBlockParam => block.type === 'text') | |
| .map(block => block.text) | |
| .join(' '), | |
| context, | |
| null, | |
| [], // queuedCommands - handled by query.ts for mid-turn attachments | |
| context.messages, | |
| 'repl_main_thread', | |
| { skipSkillDiscovery: true }, | |
| ), | |
| ) | |
| const messages = [ | |
| createUserMessage({ | |
| content: metadata, | |
| uuid, | |
| }), | |
| createUserMessage({ | |
| content: mainMessageContent, | |
| isMeta: true, | |
| }), | |
| ...attachmentMessages, | |
| createAttachmentMessage({ | |
| type: 'command_permissions', | |
| allowedTools: additionalAllowedTools, | |
| model: command.model, | |
| }), | |
| ] | |
| return { | |
| messages, | |
| shouldQuery: true, | |
| allowedTools: additionalAllowedTools, | |
| model: command.model, | |
| effort: command.effort, | |
| command, | |
| } | |
| } | |