mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
* feat: 第一版大重构 * fix: 修复类型问题 * chore: 更新版本到 1.3.2 * Add brave as alternative WebSearchTool * fix: 修正顺序 * fix: 修复对穷鬼模式的 auto dream 和 session memory 越过 * feat: 穷鬼模式去除 session-summary * feat: 创建 builtin-tools 包,搬运所有工具实现 将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/, 内部导入路径已更新为 src/ alias 模式。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/ - src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/ - 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock - tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射 - 新增 packages/builtin-tools/src 至 include Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀 所有包名及 import 路径统一添加 @claude-code-best/ 前缀: - builtin-tools → @claude-code-best/builtin-tools - mcp-client → @claude-code-best/mcp-client - agent-tools → @claude-code-best/agent-tools Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复 node 环境没有 bun 的问题 --------- Co-authored-by: Eric-Guo <eric.guocz@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1263 lines
44 KiB
TypeScript
1263 lines
44 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 '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js'
|
||
import { runAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js'
|
||
import { renderToolUseProgressMessage } from '@claude-code-best/builtin-tools/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 as AssistantMessage)
|
||
if (contentLength > 0) {
|
||
context.setResponseLength(len => len + contentLength)
|
||
}
|
||
|
||
const normalizedMsg = normalizedNew[0]
|
||
if (normalizedMsg && normalizedMsg.type === 'assistant') {
|
||
progressMessages.push(createProgressMessage(message as AssistantMessage))
|
||
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 as AssistantMessage))
|
||
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 (process.env.USER_TYPE === '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,
|
||
...(process.env.USER_TYPE === '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,
|
||
...(process.env.USER_TYPE === '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,
|
||
) as AssistantMessage[],
|
||
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,
|
||
}
|
||
}
|