Claude Code Skill Runtime 深度解析:可复用 AI 工作流引擎的架构与实现

从 Promise Memoization、realpath 去重到 contextModifier 闭包链的 Skill Runtime 实现原理全解剖

Posted by 爱折腾的工程师 on Tuesday, April 7, 2026

一、引言:Skill 是 Claude Code 最精巧的运行时子系统

上一篇文章 中,我们全景式地拆解了 Claude Code 的启动流程、Agentic Loop、工具系统与权限治理。本文聚焦于其中工程密度最高的子系统——Skill Runtime,深入到每一个关键函数的实现原理。

一句话定义:Skill = Command { type: 'prompt', getPromptForCommand: (args, ctx) => ContentBlockParam[] }。它是一段可参数化、带权限声明、支持 hooks 和条件激活的 Markdown prompt,在运行时通过 SkillTool 的三路分发引擎(inline / fork / remote)注入 LLM 对话。

本文基于 2026 年 3 月 31 日泄露的 TypeScript 源码快照,逐函数剖析 Skill Runtime 的完整实现链路。

全文架构

Skill Runtime 架构总览

章节 核心实现原理 关键文件
Skill 数据模型与 Prompt 展开管线 bundledSkills.ts, loadSkillsDir.ts
六源并行加载与 realpath 去重算法 loadSkillsDir.ts
SkillTool 三路分发与 contextModifier 闭包链 SkillTool.ts
Safe Properties 排除法白名单权限模型 SkillTool.ts, PermissionContext.ts
Post-sampling Hook 驱动的自动改进引擎 skillImprovement.ts, registerSkillHooks.ts
Compaction 恢复与 Invoked Skills 状态管理 state.ts, processSlashCommand.tsx
chokidar + Bun polling fallback 热重载机制 skillChangeDetector.ts

二、Skill 数据模型与 Prompt 展开管线

2.1 运行时类型统一:所有来源 → Command

无论来自哪个来源(bundled / disk / plugin / MCP),Skill 在运行时都被统一表示为 Command { type: 'prompt' } 类型。核心是 getPromptForCommand 方法——一个 异步闭包,封装了完整的 prompt 展开逻辑:

// src/skills/loadSkillsDir.ts — createSkillCommand 核心
async getPromptForCommand(args, toolUseContext) {
  // Step 1: Base directory 前缀注入
  let finalContent = baseDir
    ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
    : markdownContent

  // Step 2: 命名参数替换 ($arg_name → 实际值)
  finalContent = substituteArguments(finalContent, args, true, argumentNames)

  // Step 3: ${CLAUDE_SKILL_DIR} 变量替换 (Windows 路径归一化)
  if (baseDir) {
    const skillDir = process.platform === 'win32'
      ? baseDir.replace(/\\/g, '/')  // Windows: 反斜杠 → 正斜杠
      : baseDir
    finalContent = finalContent.replace(/\${CLAUDE_SKILL_DIR}/g, skillDir)
  }

  // Step 4: ${CLAUDE_SESSION_ID} 替换
  finalContent = finalContent.replace(/\${CLAUDE_SESSION_ID}/g, getSessionId())

  // Step 5: Shell 命令内联执行 (!`command` 语法)
  // 安全关键: MCP 技能 (loadedFrom === 'mcp') 禁止此步骤
  if (loadedFrom !== 'mcp') {
    finalContent = await executeShellCommandsInPrompt(
      finalContent,
      {
        ...toolUseContext,
        getAppState() {
          // 注入 allowedTools 到权限上下文,使 shell 命令继承技能权限
          const appState = toolUseContext.getAppState()
          return {
            ...appState,
            toolPermissionContext: {
              ...appState.toolPermissionContext,
              alwaysAllowRules: {
                ...appState.toolPermissionContext.alwaysAllowRules,
                command: allowedTools,  // ← 技能声明的工具权限
              },
            },
          }
        },
      },
      `/${skillName}`,
      shell,  // 可选的自定义 shell (从 frontmatter 解析)
    )
  }

  return [{ type: 'text', text: finalContent }]
}

关键设计:Step 5 中 getAppState 的 monkey-patch 使得技能 Markdown 中嵌入的 !command shell 命令自动继承技能声明的 allowed-tools 权限——无需用户二次授权。

2.2 内置技能的 Promise Memoization 模式

内置技能(bundled skills)可能附带参考文件(如 verify 技能的 examples),这些文件需要懒提取到磁盘。registerBundledSkill 使用了一个精妙的闭包级 Promise memoization

// src/skills/bundledSkills.ts
export function registerBundledSkill(definition: BundledSkillDefinition): void {
  if (files && Object.keys(files).length > 0) {
    skillRoot = getBundledSkillExtractDir(definition.name)
    // 关键: memo 的是 Promise 本身, 不是 result
    let extractionPromise: Promise<string | null> | undefined
    const inner = definition.getPromptForCommand
    getPromptForCommand = async (args, ctx) => {
      extractionPromise ??= extractBundledSkillFiles(definition.name, files)
      const extractedDir = await extractionPromise
      const blocks = await inner(args, ctx)
      if (extractedDir === null) return blocks  // 提取失败 → 优雅降级
      return prependBaseDir(blocks, extractedDir)
    }
  }
  // ...
}

为什么 memo Promise 而非 result?

  • 如果 memo result(string | null),并发调用者在第一次提取完成前会触发重复的文件写入
  • ??= (nullish coalescing assignment) 确保只赋值一次,后续调用者 await 同一个 Promise
  • 提取失败返回 null,技能继续工作(无 base-directory 前缀),实现优雅降级

2.3 文件提取的安全措施链

// 安全层级 1: per-process nonce 目录 (getBundledSkillsRoot)
// 即使攻击者通过 inotify 获知父目录, 也无法预测子目录名

// 安全层级 2: 路径遍历防护
function resolveSkillFilePath(baseDir: string, relPath: string): string {
  const normalized = normalize(relPath)
  if (isAbsolute(normalized) ||
      normalized.split(pathSep).includes('..') ||
      normalized.split('/').includes('..'))  // 兼容 Windows 路径分隔符
    throw new Error(`bundled skill file path escapes skill dir: ${relPath}`)
  return join(baseDir, normalized)
}

// 安全层级 3: 防符号链接写入
const SAFE_WRITE_FLAGS = process.platform === 'win32'
  ? 'wx'  // Windows: libuv 不支持 numeric O_EXCL
  : fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | O_NOFOLLOW

async function safeWriteFile(p: string, content: string): Promise<void> {
  const fh = await open(p, SAFE_WRITE_FLAGS, 0o600)  // owner-only
  try { await fh.writeFile(content, 'utf8') }
  finally { await fh.close() }
}

// 安全层级 4: 目录权限
await mkdir(parent, { recursive: true, mode: 0o700 })  // owner-only

源码注释明确解释了设计意图:不 unlink+retry on EEXIST——因为 unlink() 会跟随中间路径上的符号链接。


三、六源并行加载与 realpath 去重算法

3.1 五路并行加载 + 策略控制

Skill 发现与加载流程

getSkillDirCommands 使用 lodash-es/memoize(以 cwd 为缓存 key),首次调用时五路并行加载所有来源:

const [managed, user, projectNested, additionalNested, legacy] = await Promise.all([
  // 1. 管理策略目录 (除非 CLAUDE_CODE_DISABLE_POLICY_SKILLS)
  loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),
  // 2. 用户目录 (除非 !isSettingSourceEnabled('userSettings') || locked)
  loadSkillsFromSkillsDir(userSkillsDir, 'userSettings'),
  // 3. 项目目录 (逐级上行: cwd/.claude/skills, ../claude/skills, ...)
  Promise.all(projectDirs.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings'))),
  // 4. --add-dir 附加目录
  Promise.all(additionalDirs.map(dir => loadSkillsFromSkillsDir(...))),
  // 5. 旧式 /commands/ 目录
  loadSkillsFromCommandsDir(cwd),
])

策略控制点

  • isRestrictedToPluginOnly('skills') → 锁定后只加载 managed + plugin skills
  • isBareMode()--bare 标志跳过所有自动发现,只加载 --add-dir
  • isEnvTruthy(CLAUDE_CODE_DISABLE_POLICY_SKILLS) → 跳过管理策略

3.2 realpath 去重:并行预计算 + 同步去重

去重面临一个性能挑战:realpath() 是 I/O 操作。解决方案是预计算并行化,去重同步化

// Phase 1: 并行预计算所有文件的 canonical path
const fileIds = await Promise.all(
  allSkillsWithPaths.map(({ skill, filePath }) =>
    skill.type === 'prompt' ? getFileIdentity(filePath) : Promise.resolve(null),
  ),
)

// Phase 2: 同步去重 (first-wins 优先级)
const seenFileIds = new Map<string, SettingSource>()
for (let i = 0; i < allSkillsWithPaths.length; i++) {
  const fileId = fileIds[i]
  if (fileId === null || fileId === undefined) {
    deduplicatedSkills.push(skill)  // 无法解析的文件不去重
    continue
  }
  const existingSource = seenFileIds.get(fileId)
  if (existingSource !== undefined) {
    logForDebugging(`Skipping duplicate skill '${skill.name}' from ${skill.source}`)
    continue
  }
  seenFileIds.set(fileId, skill.source)
  deduplicatedSkills.push(skill)
}

为什么用 realpath 而非 inode? 源码注释引用了 issue #13893:某些文件系统(virtual/container/NFS/ExFAT)报告不可靠的 inode 值(如 inode 0 或精度丢失)。realpath 是 filesystem-agnostic 的。

3.3 条件技能的三 Map 状态管理

条件技能使用三个模块级状态协同工作:

const conditionalSkills = new Map<string, Command>()       // 待激活池
const activatedConditionalSkillNames = new Set<string>()     // 已激活名 (跨 cache clear)
const dynamicSkills = new Map<string, Command>()             // 活跃池

export function activateConditionalSkillsForPaths(filePaths, cwd): string[] {
  for (const [name, skill] of conditionalSkills) {
    const skillIgnore = ignore().add(skill.paths)  // gitignore 风格匹配
    for (const filePath of filePaths) {
      const relativePath = isAbsolute(filePath) ? relative(cwd, filePath) : filePath
      // 防御: 空路径、../逃逸、跨驱动器绝对路径
      if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) continue
      if (skillIgnore.ignores(relativePath)) {
        dynamicSkills.set(name, skill)         // 移入活跃池
        conditionalSkills.delete(name)          // 从待激活池移除
        activatedConditionalSkillNames.add(name) // 持久记录 (cache clear 不影响)
        break
      }
    }
  }
  skillsLoaded.emit()  // Signal 通知
}

activatedConditionalSkillNames 的设计意图clearSkillCaches() 会清除 conditionalSkillsdynamicSkills,但 activatedConditionalSkillNames 保留——这样 reload 后已激活的技能不会回到待激活池。

3.4 MCP 技能桥接:Write-Once Registry 打破循环依赖

// src/skills/mcpSkillBuilders.ts — 依赖图叶子节点 (只导入类型)
let builders: MCPSkillBuilders | null = null

export function registerMCPSkillBuilders(b: MCPSkillBuilders): void { builders = b }
export function getMCPSkillBuilders(): MCPSkillBuilders {
  if (!builders) throw new Error('MCP skill builders not registered')
  return builders
}

// loadSkillsDir.ts 文件末尾的顶层副作用 (模块初始化时执行)
registerMCPSkillBuilders({ createSkillCommand, parseSkillFrontmatterFields })

源码注释详细解释了为什么两个替代方案都不可行:

  • 变量名动态 import await import(variable) → Bun bundle 后路径解析为 /$bunfs/root/...,运行时找不到模块
  • 字面量动态 import await import('./loadSkillsDir.js') → dependency-cruiser 追踪到,loadSkillsDir 传递性依赖几乎所有模块,单条新边扇出为数十条 cycle violations

四、SkillTool 三路分发与 contextModifier 闭包链

SkillTool 执行管线

4.1 getAllCommands:MCP Skill 的安全过滤

async function getAllCommands(context: ToolUseContext): Promise<Command[]> {
  // 只包含 loadedFrom === 'mcp' 的 MCP skills, 不包含 plain MCP prompts
  // 修复: 此前模型可以通过猜测 mcp__server__prompt 名称调用 MCP prompts
  const mcpSkills = context.getAppState().mcp.commands.filter(
    cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp',
  )
  if (mcpSkills.length === 0) return getCommands(getProjectRoot())
  const localCommands = await getCommands(getProjectRoot())
  return uniqBy([...localCommands, ...mcpSkills], 'name')  // 本地优先
}

4.2 Inline 路径的 contextModifier 闭包链

Inline 执行是最复杂的路径。其核心创新是 contextModifier——一个闭包,在技能 prompt 注入后持续影响后续所有 tool 调用

return {
  data: { success: true, commandName, allowedTools, model },
  newMessages,       // 技能 prompt 注入主对话
  contextModifier(ctx) {
    let modifiedContext = ctx

    // Layer 1: 工具权限合并
    if (allowedTools.length > 0) {
      const previousGetAppState = modifiedContext.getAppState  // 捕获前一层
      modifiedContext = {
        ...modifiedContext,
        getAppState() {
          const appState = previousGetAppState()  // 链式调用, 不是 ctx.getAppState
          return {
            ...appState,
            toolPermissionContext: {
              ...appState.toolPermissionContext,
              alwaysAllowRules: {
                ...appState.toolPermissionContext.alwaysAllowRules,
                command: [
                  ...new Set([  // Set 去重
                    ...(appState.toolPermissionContext.alwaysAllowRules.command || []),
                    ...allowedTools,
                  ]),
                ],
              },
            },
          }
        },
      }
    }

    // Layer 2: 模型覆盖 (保留 [1m] 后缀防止降低 context window)
    if (model) {
      modifiedContext = {
        ...modifiedContext,
        options: {
          ...modifiedContext.options,
          mainLoopModel: resolveSkillModelOverride(model, ctx.options.mainLoopModel),
        },
      }
    }

    // Layer 3: Effort level 覆盖
    if (effort !== undefined) {
      const previousGetAppState = modifiedContext.getAppState  // 再次捕获
      modifiedContext = {
        ...modifiedContext,
        getAppState() {
          return { ...previousGetAppState(), effortValue: effort }
        },
      }
    }

    return modifiedContext
  },
}

链式 getAppState 的关键:每层修改都捕获 previousGetAppState(而非 ctx.getAppState),确保多层 contextModifier 正确组合。如果直接引用 ctx.getAppState,Layer 3 的修改会跳过 Layer 1。

4.3 Fork 路径的隔离执行与内存管理

async function executeForkedSkill(command, commandName, args, context, ...) {
  const agentId = createAgentId()

  // prepareForkedCommandContext: 获取 prompt + 工具权限 + agent 定义
  const { modifiedGetAppState, baseAgent, promptMessages, skillContent } =
    await prepareForkedCommandContext(command, args || '', context)

  // 合并 effort 到 agent definition
  const agentDefinition = command.effort !== undefined
    ? { ...baseAgent, effort: command.effort }
    : baseAgent

  const agentMessages: Message[] = []
  try {
    // runAgent 返回 async iterator
    for await (const message of runAgent({
      agentDefinition,
      promptMessages,
      toolUseContext: { ...context, getAppState: modifiedGetAppState },
      isAsync: false,           // 同步执行, 阻塞主对话
      querySource: 'agent:custom',
      model: command.model as ModelAlias | undefined,
      override: { agentId },    // 注入自定义 agentId
    })) {
      agentMessages.push(message)
      // 上报进度 (tool_use / tool_result)
      if ((message.type === 'assistant' || message.type === 'user') && onProgress) {
        // ... progress reporting
      }
    }

    const resultText = extractResultText(agentMessages, 'Skill execution completed')
    agentMessages.length = 0   // 显式释放消息内存

    return { data: { success: true, commandName, status: 'forked', agentId, result: resultText } }
  } finally {
    clearInvokedSkillsForAgent(agentId)  // 释放 compaction 状态
  }
}

prepareForkedCommandContext 内部通过 createGetAppStateWithAllowedTools 包装 getAppState,使用 Set 去重合并工具权限。Agent 查找遵循 fallback 链:command.agent'general-purpose'agents[0]


五、Safe Properties 排除法白名单权限模型

Skill 安全与权限模型

5.1 checkPermissions 的四阶段决策链

Deny Rules → [Remote Canonical 自动放行] → Allow Rules → Safe Properties 自动放行 → User Ask

规则匹配支持两种模式

  • 精确匹配:Skill(review-pr) → 匹配 review-pr
  • 前缀通配:Skill(review:*) → 匹配 review-prreview-code

5.2 Safe Properties 排除法白名单

这是权限系统中最精巧的设计——排除法确保新属性默认需要权限:

const SAFE_SKILL_PROPERTIES = new Set([
  // PromptCommand properties
  'type', 'progressMessage', 'contentLength', 'argNames', 'model',
  'effort', 'source', 'pluginInfo', 'disableNonInteractive',
  'skillRoot', 'context', 'agent', 'getPromptForCommand', 'frontmatterKeys',
  // CommandBase properties
  'name', 'description', 'hasUserSpecifiedDescription', 'isEnabled',
  'isHidden', 'aliases', 'isMcp', 'argumentHint', 'whenToUse',
  'paths', 'version', 'disableModelInvocation', 'userInvocable',
  'loadedFrom', 'immediate', 'userFacingName',
])

function skillHasOnlySafeProperties(command: Command): boolean {
  for (const key of Object.keys(command)) {
    if (SAFE_SKILL_PROPERTIES.has(key)) continue
    const value = (command as Record<string, unknown>)[key]
    // 忽略: undefined, null, 空数组, 空对象
    if (value === undefined || value === null) continue
    if (Array.isArray(value) && value.length === 0) continue
    if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) continue
    return false  // 有非安全属性且有值 → 需要权限
  }
  return true
}

注意 hooksallowedTools 不在白名单中——有 hooks 或 allowedTools 的技能必须经过用户审批。


六、Post-sampling Hook 驱动的自动改进引擎

6.1 改进检测:每 5 条消息的增量分析

// src/utils/hooks/skillImprovement.ts
function createSkillImprovementHook() {
  let lastAnalyzedCount = 0   // 上次分析时的 user 消息数
  let lastAnalyzedIndex = 0   // 上次分析到的消息索引

  const config: ApiQueryHookConfig<SkillUpdate[]> = {
    async shouldRun(context) {
      if (context.querySource !== 'repl_main_thread') return false
      if (!findProjectSkill()) return false  // 只分析项目级技能
      const userCount = count(context.messages, m => m.type === 'user')
      if (userCount - lastAnalyzedCount < 5) return false  // 每 5 条才触发
      lastAnalyzedCount = userCount
      return true
    },

    buildMessages(context) {
      const projectSkill = findProjectSkill()!
      const newMessages = context.messages.slice(lastAnalyzedIndex)  // 增量分析
      lastAnalyzedIndex = context.messages.length
      return [createUserMessage({
        content: `...<skill_definition>${projectSkill.content}</skill_definition>
        <recent_messages>${formatRecentMessages(newMessages)}</recent_messages>...`
      })]
    },

    parseResponse(content) {
      const updatesStr = extractTag(content, 'updates')
      return updatesStr ? jsonParse(updatesStr) : []  // 解析失败 → 空数组
    },

    getModel: getSmallFastModel,  // 使用小模型降低成本
  }
  return createApiQueryHook(config)
}

Feature Gate:双重开关 feature('SKILL_IMPROVEMENT') && getFeatureValue_CACHED_MAY_BE_STALE('tengu_copper_panda', false)

6.2 改进应用:side-channel LLM 重写 SKILL.md

export async function applySkillImprovement(skillName: string, updates: SkillUpdate[]) {
  const filePath = join(getCwd(), '.claude', 'skills', skillName, 'SKILL.md')
  const currentContent = await fs.readFile(filePath, 'utf-8')

  const response = await queryModelWithoutStreaming({
    messages: [createUserMessage({ content: `...` })],
    systemPrompt: asSystemPrompt(['You edit skill definition files...']),
    thinkingConfig: { type: 'disabled' },  // 省 token
    tools: [],                              // 不需要工具
    signal: createAbortController().signal,  // 非阻塞
    options: {
      model: getSmallFastModel(),
      temperatureOverride: 0,               // 确定性输出
      querySource: 'skill_improvement_apply',
    },
  })

  const updatedContent = extractTag(responseText, 'updated_file')
  if (updatedContent) await fs.writeFile(filePath, updatedContent, 'utf-8')
}

6.3 技能 Hooks 的 Session-scoped 注册

// src/utils/hooks/registerSkillHooks.ts
export function registerSkillHooks(setAppState, sessionId, hooks, skillName, skillRoot?) {
  for (const eventName of HOOK_EVENTS) {
    const matchers = hooks[eventName]
    if (!matchers) continue
    for (const matcher of matchers) {
      for (const hook of matcher.hooks) {
        // once: true → 成功后自动 removeSessionHook
        const onHookSuccess = hook.once
          ? () => removeSessionHook(setAppState, sessionId, eventName, hook)
          : undefined
        addSessionHook(setAppState, sessionId, eventName,
          matcher.matcher || '', hook, onHookSuccess, skillRoot)
      }
    }
  }
}

支持的 Hook 事件:PreToolUse, PostToolUse, PostToolUseFailure, Stop, SessionStart, UserPromptSubmit, PreCompact, PostCompact, Notification, PermissionRequest


七、Compaction 恢复与 Invoked Skills 状态管理

7.1 invokedSkills Map 的复合键设计

// src/bootstrap/state.ts
// 键格式: `${agentId ?? ''}:${skillName}`  — 防止跨 agent 覆盖
invokedSkills: Map<string, {
  skillName: string
  skillPath: string     // 格式: `${source}:${name}` ( "projectSettings:my-skill")
  content: string       // 已经过完整展开的 prompt 内容
  invokedAt: number
  agentId: string | null  // null = 主线程
}>

写入点(两处):

  1. processSlashCommand.tsxgetMessagesForPromptSlashCommandaddInvokedSkill(command.name, skillPath, skillContent, agentId)
  2. SkillTool.tsexecuteRemoteSkilladdInvokedSkill(commandName, skillPath, finalContent, agentId)

读取点

  • skillImprovement.tsgetInvokedSkillsForAgent(null) 只读主线程技能
  • Compaction 恢复逻辑 → getInvokedSkills() 获取所有技能内容

清理点

  • clearInvokedSkills(preservedAgentIds?) — compaction 时保留指定 agent 的技能
  • clearInvokedSkillsForAgent(agentId) — fork 技能执行完毕后释放

7.2 processSlashCommand 中的完整注册链

// src/utils/processUserInput/processSlashCommand.tsx
async function getMessagesForPromptSlashCommand(command, args, context, ...) {
  // 1. 获取展开后的 prompt 内容
  const result = await command.getPromptForCommand(args, context)

  // 2. 注册 skill hooks (受策略控制)
  const hooksAllowed = !isRestrictedToPluginOnly('hooks') || isSourceAdminTrusted(command.source)
  if (command.hooks && hooksAllowed) {
    registerSkillHooks(context.setAppState, sessionId, command.hooks, command.name, command.skillRoot)
  }

  // 3. 注册到 compaction 恢复系统
  const skillPath = command.source ? `${command.source}:${command.name}` : command.name
  const skillContent = result.filter(b => b.type === 'text').map(b => b.text).join('\n\n')
  addInvokedSkill(command.name, skillPath, skillContent, getAgentContext()?.agentId ?? null)

  // 4. 提取附件 (@-mentions, MCP resources) — skipSkillDiscovery 避免 AKI 延迟
  const attachmentMessages = await toArray(
    getAttachmentMessages(/* ... */, { skipSkillDiscovery: true })
  )

  // 5. 构建消息列表
  return {
    messages: [
      createUserMessage({ content: metadata, uuid }),          // 元数据
      createUserMessage({ content: mainContent, isMeta: true }), // 技能内容
      ...attachmentMessages,                                    // 附件
      createAttachmentMessage({ type: 'command_permissions', allowedTools, model }), // 权限
    ],
    shouldQuery: true,
    allowedTools, model, effort, command,
  }
}

八、chokidar + Bun polling fallback 热重载机制

8.1 Bun FSWatcher 死锁的规避

// src/utils/skills/skillChangeDetector.ts
const USE_POLLING = typeof Bun !== 'undefined'

源码注释引用了两个 Bun issue(#27469, #26385):Bun 的 fs.watch() 有 PathWatcherManager 死锁——主线程关闭 watcher 时,如果 File Watcher 线程正在递送事件,两个线程会在 __ulock_wait2 中互相死锁。chokidar 在 depth: 2 的技能目录上可靠触发此 bug。

8.2 防抖的三重保护

const RELOAD_DEBOUNCE_MS = 300

function scheduleReload(changedPath: string): void {
  pendingChangedPaths.add(changedPath)       // 累积路径
  if (reloadTimer) clearTimeout(reloadTimer) // 重置定时器
  reloadTimer = setTimeout(async () => {
    reloadTimer = null
    const paths = [...pendingChangedPaths]
    pendingChangedPaths.clear()

    // 保护 1: ConfigChange hook 可阻止重载
    const results = await executeConfigChangeHooks('skills', paths[0]!)
    if (hasBlockingResult(results)) return

    // 保护 2: 级联清除三层缓存
    clearSkillCaches()       // getSkillDirCommands.cache + conditionalSkills
    clearCommandsCache()     // getCommands memoization
    resetSentSkillNames()    // 触发技能列表重发

    // 保护 3: Signal 通知 UI (React hook 订阅)
    skillsChanged.emit()
  }, RELOAD_DEBOUNCE_MS)
}

动态技能加载的特殊处理onDynamicSkillsLoaded 回调只清除 clearCommandMemoizationCaches()(不调用 clearCommandsCache()),因为后者会触发 clearSkillCaches() 清除刚刚加载的动态技能。

8.3 Token Budget 管理算法

技能列表在 system prompt 中的空间被精确控制:

export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01  // 上下文窗口的 1%
export const CHARS_PER_TOKEN = 4
export const MAX_LISTING_DESC_CHARS = 250

export function formatCommandsWithinBudget(commands, contextWindowTokens?) {
  const budget = getCharBudget(contextWindowTokens)
  const fullTotal = commands.reduce((sum, cmd) => sum + stringWidth(formatDesc(cmd)), 0)
  if (fullTotal <= budget) return fullEntries.join('\n')  // 全描述

  // 分区: bundled (永不截断) vs rest
  const bundledChars = /* bundled 技能占用 */
  const remainingBudget = budget - bundledChars
  const maxDescLen = Math.floor(remainingBudget / restCommands.length)

  if (maxDescLen < 20) return /* 极端: rest 只显示名字 */
  return /* 截断 rest 描述到 maxDescLen */
}

使用 stringWidth(非 length)精确计算 CJK/emoji 字符宽度。


九、设计洞察与关键实现模式

9.1 七大关键实现模式

模式 实现位置 解决的技术问题
Promise Memoization bundledSkills.ts 并发安全的懒加载(memo Promise 而非 result)
realpath 并行去重 loadSkillsDir.ts I/O 密集操作的 Promise.all 预计算 + 同步去重
contextModifier 闭包链 SkillTool.ts 多层 getAppState 修改的正确组合(链式捕获 previousGetAppState)
Safe Properties 排除法 SkillTool.ts 新属性默认需要权限(正向枚举安全属性)
Write-once Registry mcpSkillBuilders.ts Bun bundle 中的循环依赖(不是 dynamic import)
三 Map 条件激活 loadSkillsDir.ts conditional → dynamic 状态转移,activated 跨 cache clear 保留
Bun Polling Fallback skillChangeDetector.ts FSWatcher 死锁的运行时环境检测与降级

9.2 Skill Runtime 的数据流全景

                           ┌─ bundled/index.ts ──→ registerBundledSkill()
                           ├─ loadSkillsDir.ts ──→ getSkillDirCommands() [memoized]
来源层 ─→ 注册层           ├─ builtinPlugins.ts ──→ getBuiltinPluginSkillCommands()
                           ├─ MCP server ──→ mcpSkillBuilders → createSkillCommand()
                           └─ dynamicSkills Map ←── discoverSkillDirsForPaths()
                                    │
                                    ▼
发现层 ──→ getCommands(cwd) ──→ formatCommandsWithinBudget() ──→ System Prompt
                                    │
                                    ▼ (模型调用 Skill tool)
执行层 ──→ SkillTool.validateInput() → checkPermissions() → call()
                                    │
                  ┌─────────────────┼──────────────────┐
                  ▼                 ▼                   ▼
              Inline              Fork               Remote
         processPrompt...   executeForked...    executeRemote...
              │                   │                    │
              ▼                   ▼                    ▼
        newMessages +        runAgent() →         loadRemoteSkill()
        contextModifier     extractResult          + addInvokedSkill
              │                   │                    │
              └───────────────────┴────────────────────┘
                                    │
状态层 ──→ addInvokedSkill() ──→ compaction 恢复
          registerSkillHooks() ──→ session hooks
          skillImprovement ──→ auto-rewrite SKILL.md
          skillChangeDetector ──→ chokidar + debounce → cache invalidation

9.3 对 AI Agent 工程化的启示

Claude Code 的 Skill Runtime 展示了 “Prompt-as-Function” 范式的完整工程化实现:

  • 参数化$arg_name + ${CLAUDE_SKILL_DIR} + ${CLAUDE_SESSION_ID}
  • 权限声明式allowed-tools frontmatter → contextModifier 自动注入
  • 可组合:技能可以通过 SkillTool 调用其他技能
  • 可观察:hooks 系统覆盖完整生命周期(9 种事件类型)
  • 自进化:每 5 条消息自动检测用户偏好并重写 SKILL.md
  • 可信任:六级信任层级 + Safe Properties 排除法 + per-process nonce 安全写入

这种架构将 AI Agent 从"单轮响应"推向了"可编程工作流引擎"。


本文基于公开可获取的源码快照进行纯技术架构分析,仅用于学习和研究目的。原始 Claude Code 源码版权归 Anthropic 所有。

「真诚赞赏,手留余香」

爱折腾的工程师

真诚赞赏,手留余香

使用微信扫描二维码完成支付