mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 12:26:58 +10:00
922 lines
141 KiB
TypeScript
922 lines
141 KiB
TypeScript
|
|
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
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwiQ29udGVudEJsb2NrUGFyYW0iLCJUZXh0QmxvY2tQYXJhbSIsInJhbmRvbVVVSUQiLCJzZXRQcm9tcHRJZCIsImJ1aWx0SW5Db21tYW5kTmFtZXMiLCJDb21tYW5kIiwiQ29tbWFuZEJhc2UiLCJmaW5kQ29tbWFuZCIsImdldENvbW1hbmQiLCJnZXRDb21tYW5kTmFtZSIsImhhc0NvbW1hbmQiLCJQcm9tcHRDb21tYW5kIiwiTk9fQ09OVEVOVF9NRVNTQUdFIiwiU2V0VG9vbEpTWEZuIiwiVG9vbFVzZUNvbnRleHQiLCJBc3Npc3RhbnRNZXNzYWdlIiwiQXR0YWNobWVudE1lc3NhZ2UiLCJNZXNzYWdlIiwiTm9ybWFsaXplZFVzZXJNZXNzYWdlIiwiUHJvZ3Jlc3NNZXNzYWdlIiwiVXNlck1lc3NhZ2UiLCJhZGRJbnZva2VkU2tpbGwiLCJnZXRTZXNzaW9uSWQiLCJDT01NQU5EX01FU1NBR0VfVEFHIiwiQ09NTUFORF9OQU1FX1RBRyIsIkNhblVzZVRvb2xGbiIsIkFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMiLCJBbmFseXRpY3NNZXRhZGF0YV9JX1ZFUklGSUVEX1RISVNfSVNfUElJX1RBR0dFRCIsImxvZ0V2ZW50IiwiZ2V0RHVtcFByb21wdHNQYXRoIiwiYnVpbGRQb3N0Q29tcGFjdE1lc3NhZ2VzIiwicmVzZXRNaWNyb2NvbXBhY3RTdGF0ZSIsIlByb2dyZXNzIiwiQWdlbnRQcm9ncmVzcyIsInJ1bkFnZW50IiwicmVuZGVyVG9vbFVzZVByb2dyZXNzTWVzc2FnZSIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiY3JlYXRlQWJvcnRDb250cm9sbGVyIiwiZ2V0QWdlbnRDb250ZXh0IiwiY3JlYXRlQXR0YWNobWVudE1lc3NhZ2UiLCJnZXRBdHRhY2htZW50TWVzc2FnZXMiLCJsb2dGb3JEZWJ1Z2dpbmciLCJpc0VudlRydXRoeSIsIkFib3J0RXJyb3IiLCJNYWxmb3JtZWRDb21tYW5kRXJyb3IiLCJnZXREaXNwbGF5UGF0aCIsImV4dHJhY3RSZXN1bHRUZXh0IiwicHJlcGFyZUZvcmtlZENvbW1hbmRDb250ZXh0IiwiZ2V0RnNJbXBsZW1lbnRhdGlvbiIsImlzRnVsbHNjcmVlbkVudkVuYWJsZWQiLCJ0b0FycmF5IiwicmVnaXN0ZXJTa2lsbEhvb2tzIiwibG9nRXJyb3IiLCJlbnF1ZXVlUGVuZGluZ05vdGlmaWNhdGlvbiIsImNyZWF0ZUNvbW1hbmRJbnB1dE1lc3NhZ2UiLCJjcmVhdGVTeW50aGV0aWNVc2VyQ2F2ZWF0TWVzc2FnZSIsImNyZWF0ZVN5c3RlbU1lc3NhZ2UiLCJjcmVhdGVVc2VySW50ZXJydXB0aW9uTWVzc2FnZSIsImNyZWF0ZVVzZXJNZXNzYWdlIiwiZm9ybWF0Q29tbWFuZElucHV0VGFncyIsImlzQ29tcGFjdEJvdW5kYXJ5TWVzc2FnZSIsImlzU3lzdGVtTG9jYWxDb21tYW5kTWVzc2FnZSIsIm5vcm1hbGl6ZU1lc3NhZ2VzIiwicHJlcGFyZVVzZXJDb250ZW50IiwiTW9kZWxBbGlhcyIsInBhcnNlVG9vbExpc3RGcm9tQ0xJIiwiaGFzUGVybWlzc2lvbnNUb1VzZVRvb2wiLCJpc09mZmljaWFsTWFya2V0cGxhY2VOYW1lIiwicGFyc2VQbHVnaW5JZGVudGlmaWVyIiwiaXNSZXN0cmljdGVkVG9QbHVnaW5Pbmx5IiwiaXNTb3VyY2VBZG1pblRydXN0ZWQiLCJwYXJzZVNsYXNoQ29tbWFuZCIsInNsZWVwIiwicmVjb3JkU2tpbGxVc2FnZSIsImxvZ09UZWxFdmVudCIsInJlZGFjdElmRGlzYWJsZWQiLCJidWlsZFBsdWdpbkNvbW1hbmRUZWxlbWV0cnlGaWVsZHMiLCJnZXRBc3Npc3RhbnRNZXNzYWdlQ29udGVudExlbmd0aCIsImNyZWF0ZUFnZW50SWQiLCJnZXRXb3JrbG9hZCIsIlByb2Nlc3NVc2VySW5wdXRCYXNlUmVzdWx0IiwiUHJvY2Vzc1VzZXJJbnB1dENvbnRleHQiLCJTbGFzaENvbW1hbmRSZXN1bHQiLCJjb21tYW5kIiwiTUNQX1NFVFRMRV9QT0xMX01TIiwiTUNQX1NFVFRMRV9USU1FT1VUX01TIiwiZXhlY3V0ZUZvcmtlZFNsYXNoQ29tbWFuZCIsImFyZ3MiLCJjb250ZXh0IiwicHJlY2VkaW5nSW5wdXRCbG9ja3MiLCJzZXRUb29sSlNYIiwiY2FuVXNlVG9vbCIsIlByb21pc2UiLCJhZ2VudElkIiwicGx1Z2luTWFya2V0cGxhY2UiLCJwbHVnaW5JbmZvIiwicmVwb3NpdG9yeSIsIm1hcmtldHBsYWNlIiwidW5kZWZpbmVkIiwiY29tbWFuZF9uYW1lIiwibmFtZSIsImludm9jYXRpb25fdHJpZ2dlciIsIl9QUk9UT19wbHVnaW5fbmFtZSIsInBsdWdpbk1hbmlmZXN0IiwiX1BST1RPX21hcmtldHBsYWNlX25hbWUiLCJza2lsbENvbnRlbnQiLCJtb2RpZmllZEdldEFwcFN0YXRlIiwiYmFzZUFnZW50IiwicHJvbXB0TWVzc2FnZXMiLCJhZ2VudERlZmluaXRpb24iLCJlZmZvcnQiLCJhZ2VudFR5cGUiLCJnZXRBcHBTdGF0ZSIsImthaXJvc0VuYWJsZWQiLCJiZ0Fib3J0Q29udHJvbGxlciIsImNvbW1hbmROYW1lIiwic3Bhd25UaW1lV29ya2xvYWQiLCJlbnF1ZXVlUmVzdWx0IiwidmFsdWUiLCJtb2RlIiwicHJpb3JpdHkiLCJpc01ldGEiLCJza2lwU2xhc2hDb21tYW5kcyIsIndvcmtsb2FkIiwiZGVhZGxpbmUiLCJEYXRlIiwibm93IiwicyIsIm1jcCIsImNsaWVudHMiLCJzb21lIiwiYyIsInR5cGUiLCJmcmVzaFRvb2xzIiwib3B0aW9ucyIsInJlZnJlc2hUb29scyIsInRvb2xzIiwiYWdlbnRNZXNzYWdlcyIsIm1lc3NhZ2UiLCJ0b29sVXNlQ29udGV4dCIsImFib3J0Q29udHJvbGxlciIsImlzQXN5bmMiLCJxdWVyeVNvdXJjZSIsIm1vZGVsIiwiYXZhaWxhYmxlVG9vbHMiLCJvdmVycmlkZSIsInB1c2giLCJyZXN1bHRUZXh0IiwiY2F0Y2giLCJlcnIiLCJFcnJvciIsIlN0cmluZyIsIm1lc3NhZ2VzIiwic2hvdWxkUXVlcnkiLCJwcm9ncmVzc01lc3NhZ2VzIiwicGFyZW50VG9vbFVzZUlEIiwidG9vbFVzZUNvdW50ZXIiLCJjcmVhdGVQcm9ncmVzc01lc3NhZ2UiLCJkYXRhIiwicHJvbXB0IiwidG9vbFVzZUlEIiwidGltZXN0YW1wIiwidG9JU09TdHJpbmciLCJ1dWlkIiwidXBkYXRlUHJvZ3Jlc3MiLCJqc3giLCJ2ZXJib3NlIiwic2hvdWxkSGlkZVByb21wdElucHV0Iiwic2hvdWxkQ29udGludWVBbmltYXRpb24iLCJzaG93U3Bpbm5lciIsIm5vcm1hbGl6ZWROZXciLCJjb250ZW50TGVuZ3RoIiwic2V0UmVzcG9uc2VMZW5ndGgiLCJsZW4iLCJub3JtYWxpemVkTXNnIiwiY29udGVudCIsIml
|