VS Code Claude Agent 定制化(Customizations/Plugins)架构全解:per-session 插件的同步、启停与 yield-restart 生效机制
2026/9/8 22:14:59 网站建设 项目流程

VS Code Claude Agent 定制化(Customizations/Plugins)架构全解:per-session 插件的同步、启停与 yield-restart 生效机制

【免费下载链接】vscodeVisual Studio Code项目地址: https://gitcode.com/GitHub_Trending/vscode6/vscode

本文围绕src/vs/platform/agentHost/node/claude/phase11-plan.md(Phase 11 实施计划)展开,结合当前仓库中已落地源码(claudeAgentSession.tsclaudeAgent.tsclaudeSdkOptions.tscustomizations/目录等),完整解析 VS Code 内置 Claude Agent Host 如何把"技能/插件定制化"以**按会话(per-session)**的方式端到端打通:工作台推送的定制化如何写入会话、如何通过 SDKOptions.plugins在启动时生效,以及任何增删/启停/元数据变更如何借助 Phase 10 引入的 yield-restart 原始能力在下一次send()前完成重建。读完你将掌握这条链路的每一层(模型层→会话层→调度层→SDK 选项层→流水线层)的职责划分、脏位(dirty bit)语义、竞态防护与验证方法。

背景与目标:把 Customizations 端到端接到 Claude 会话上

在 Agent Host 的架构中,IAgent协议面提供了一个对 provider 中立的定制化表面。CopilotAgent 早已实现该表面;而 Claude provider 端在 Phase 11 之前是缺失的——ClaudeAgent.setClientCustomizationsClaudeAgent.setCustomizationEnabled内只有TODO: Phase 11占位抛出。

Phase 11 的目标(见 phase11-plan.md)就是:

Wire customizations (skills + plugins) end-to-end for the Claude provider so the workbench can sync, enable, and toggle customizations against a live Claude session with the sameIAgentsurface CopilotAgent already implements. The session must accept the synced plugin paths at startup and pick up any later add / remove / toggle / nonce-bump via a yield-restart before the next turn. Customization state lives on the session, mirroring how client tools are held.

方案核心一句话概括:定制化状态(已同步集合、启停映射、已解析插件路径、脏位)全部归ClaudeAgentSession所有,与客户端工具(client tools)的持有方式镜像对齐,而不设 provider 级全局控制器。

范围界定:做什么与不做什么

In scope(计划清单)

  • 替换ClaudeAgent上两处TODO: Phase 11抛出;
  • 补齐出站(outbound)表面:onDidCustomizationsChangegetCustomizations()getSessionCustomizations(session)
  • IBuildOptionsInput增加plugins输入,使buildOptions()将其投射进 SDK 的Options.plugins
  • 新建与clientTools/平行的customizations/目录,由ClaudeAgentSession持有全部 per-session 定制化状态;
  • 在会话send()的 pre-flight 阶段,通过既有的rebindForRestart()路径排空待处理的插件变更;
  • 服务端(SDK 自行发现)定制化以独立的 on-disk 形式暴露;
  • mid-turn 竞态语义:发生在一次sendMessage进行期间的 sync/toggle,只能在下一个 yield 边界可见,绝不改写当前回合。

Out of scope(明确不做)

  • 不改IAgent协议形状(协议面已固定);
  • 不建 provider 级ClaudePluginController类——每个会话自己负责自己的定制化,与SessionClientToolsDiff对 client tools 的处理方式一致(计划明确指出:Copilot 用 provider 级PluginController,而 Claude刻意走 per-session 所有权路线);
  • 不动工作台 / customizations 编辑器 UI;
  • 不把 SDKinitializationResult()当作available_plugins探测手段(诊断价值 > 正确性要求,推迟);
  • 不做会话自行发现(on-disk)定制化这类未来阶段功能;
  • 不做回合中途热插拔:SDK 没有 mid-turn 的reloadPlugins契约,yield 边界是唯一安全点。

值得注意:计划本身也记录了实现后的修订——原始设计的"reloadPlugins热替换"方案在 council review 期间被废弃(见 phase11-plan.md),因为 SDK 的Query.reloadPlugins()无参数、无法在启动后改变插件 URI 集合。因此任何由客户端推动的定制化变更都走与 client-tool 变更相同的 rematerializer 路径触发 yield-restart,生产代码已不再调用Query.reloadPlugins()

前置依赖:Phase 10 的 yield-restart 原语与 DI 单例

计划的可行地基来自更早几个 phase 的沉淀(phase11-plan.md):

  • Phase 10 yield-restart 原语SessionClientToolsDiff+ClaudeAgentSession.rebindForClientTools()+ClaudeSdkPipeline.rebindForRestart(),既是 per-session 所有权模式的范本,也是重启回退路径;
  • Phase 10.5 收敛物化:物化被折叠进ClaudeAgentSession.materialize(ctx),会话天然成为 per-session 定制化状态的所有者与待处理重载的排空点;
  • IAgentPluginManagerDI 单例:存在于 agentPluginManager.ts,对外提供syncCustomizations(clientId, customizations, progress?)。它是进程级单例(持有共享的 on-disk 缓存),通过 DI 注入会话(而非注入 agent),与今日IClaudeAgentSdkService注入会话的方式相同。其签名见该文件第 54 行:
syncCustomizations(clientId: string, customizations: ClientPluginCustomization[], progress?: (status: PluginCustomization) => void): Promise<ISyncedCustomization[]>;
  • SDK 固定版本上可用Query.reloadPlugins()Query.supportedCommands()Options.plugins
  • 工作区 E2E 技能可用:launch(Playwright/CDP 自动化)、code-oss-logschat-customizations-editor

架构总览:镜像 clientTools 的两层模型

整体布局可以归纳为"一个会话、两类来源、一个纯函数、一个调度器":

  1. 会话内持有客户端推送层(client-pushed tier)的状态与脏位;
  2. SDK 发现层(server-side tier)按需从存活Query上快照,不进入会话状态;
  3. 两者由纯投影函数合并后输出到IAgent协议面;
  4. ClaudeAgent退化为瘦调度器,按会话 id 查表并委托。

计划中设想的落地文件是新建 customizations/ 目录,与clientTools/平行。实际仓库中该目录已成型,并随后续阶段演进出了一整套"扫描"协作体:

  • claudeSessionClientCustomizationsModel.ts—— 客户端推送快照的模型 + 脏位跟踪(对应计划第 1 步);
  • claudeSessionCustomizationDiscovery.tsclaudeMultiRootCustomizationDiscovery.tsscan/下的claudeRuleScan.tsclaudeMcpScan.tsclaudeHookScan.tsclaudeAgentSkillScan.tsclaudeNativePluginScan.ts—— 多根/用户目录级的定制化发现;
  • claudeBuiltinCommands.tsclaudeCustomizationPolicy.ts

从实现看,计划中独立的ClaudeSdkCustomizationBundler名称未保留为独立文件,而是被多根发现 + 磁盘扫描路径吸收,服务端暴露层改为getSessionCustomizations内"磁盘集合 + 存活 SDK 快照"的合并投影(详见下文)。可以推断:Phase 11 骨架原样落地,而具体文件划分在后续阶段(12–19 的 phase 计划在仓库中亦存在)持续演进。

关键决策一:状态放在 Session,而不是 provider 全局

计划 Decisions 明确:不建 provider 级 controllerClaudeAgent永远不持有定制化状态本身;它只负责按会话 id 查表、委托、并迭代_sessions聚合出站事件与列表。每条会话都从createProvisional()起就拥有自己的customizationsDiff,因此 materialize 前后 API 行为一致,无需按isPipelineReady分支。

关键决策二:插件集合变更 = 必须重启,没有 defer-and-coalesce

计划的灵魂决策被源码完整继承。原因链如下:

  1. SDKQuery.reloadPlugins()无参数,只会重新读取启动时固化在Options.plugins里的插件 URI 集合;
  2. 因此任何add / remove / toggle / nonce-bump都需要一次完整的 SDK 重建;
  3. 由此强制出"单一脏位 + 单一send()pre-flight 分支"的更简模型;
  4. reloadPlugins仅在"未来某 phase 中如果 profile 显示纯内容 nonce-bump 值得优化"时,才可能作为窄路优化回归。

这一约束在真实会话里体现为:ClaudeAgentSession.send()的 pre-flight 里,只要检测到工具集或定制化有差异,就整条_rebindForSyncedState()(见下文"发送前排水")。

实现分层详解

层一:模型(customizations/ 目录)—— 快照、启停、脏位与事件

计划第 1 步要求新建会话定制化模型,实际实现文件为 claudeSessionClientCustomizationsModel.ts,内含两个类:

SessionClientCustomizationsModel(同文件第 40 行起)是纯可观察状态容器,只装客户端推送ISyncedCustomization[]

  • 内部按clientId维护Map<string, readonly ISyncedCustomization[]>,合并时按customization.id去重、先插入的 client 优先、顺序保持 client 插入序(_mergedSynced(),第 56-69 行);
  • setSyncedCustomizations(clientId, synced)(第 72 行)与removeClient(clientId)(第 78 行)写入后触发 observablestate更新;
  • 通过 observable 的equalsFn结构性等值比较:重复推送同一份快照不会触发下游订阅者。

SessionClientCustomizationsDiff(第 109 行起)是"脏位 + 已应用路径"的跟踪器:

  • 订阅 model 的state,一旦结构变化(URI 列表、nonce、状态、用户可见元数据)就置脏;用_ignoreNextFire跳过 autorun 注册时的首次回调,避免全新 diff 未发生任何变更就报告 dirty(第 118 行);
  • 维护_appliedPluginPaths,记录上次成功应用给 SDK 的插件路径;
  • rebind 抛出时脏位保留不清——SDK 仍在用旧插件集运行,因此下一次sendMessage应当重试(同文件第 105-107 行注释);
  • SessionClientToolsDiff相同的竞态语义:一次 rebind 在途期间落地的写入会经 autorun 重新置脏,调用方无需做快照对比。

层二:SDK 选项 ——IBuildOptionsInput.pluginsOptions.plugins

计划第 2 步在 claudeSdkOptions.ts 落地。IBuildOptionsInput(第 42 行)增加字段:

/** * `Options.plugins` as `{ type: 'local', path }`. Omitted from the * options when empty. Built per-session from ... */ readonly plugins?: readonly { readonly uri: URI; readonly skipMcpDiscovery: boolean }[];

buildOptions()在投影处(第 195-196 行)将每个 URI 转成 SDK 期望的本地插件形态:

...(input.plugins && input.plugins.length > 0 ? { plugins: input.plugins.map(plugin => ({ type: 'local' as const, path: plugin.uri.fsPath, skipMcpDiscovery: plugin.skipMcpDiscovery, })) } : {})

契约要点(计划 Done 条件):

  • 有非空已启用路径时,buildOptions返回Options.pluginsRecord<name, { type: 'local', path }>
  • 为空时整个字段省略,而不是传空对象;
  • 插件路径在 materialize 与 rematerializer 两处调用点都取当前快照(customizationsDiff.consume()),避免捕获旧值。

层三:会话(ClaudeAgentSession)—— 状态所有者与排水口

claudeAgentSession.ts 是整条链路的中枢,实现了计划的四个公开方法与一个合并事件:

  • adoptClientCustomizations(clientId, synced, customizations)(第 1430 行):采纳一次全局IAgentPluginManager.syncCustomizations的结果。它把 synced 快照写进 model,并把每个客户端的插件级/子项级启停(pluginEnablement/childEnablement,对应ClientPluginCustomization)登记到 per-client 映射后重建合并启停表(第 1442-1444 行"先删后插"以保持 last-write-wins 合并优先级),随后翻转脏位,令下一次send()pre-flight 重载 SDK 插件;
  • getClientCustomizations()(第 1452 行):读回客户端推送投影(不含服务端条目);
  • getSessionCustomizations()(第 1491 行):合并投影。真实实现把三层来源并起来:
const [multiRoot, rules, mcpServers, hooks] = await Promise.all([ discoverClaudeMultiRootCustomizations(this.workingDirectories, userHome, this._fileService, this._logService), scanClaudeRules(this.workingDirectory, userHome, this._fileService), scanClaudeMcpServers(this.workingDirectory, userHome, this._fileService), scanClaudeHooks(this.workingDirectory, userHome, this._fileService), ]); let sdk: ISdkResolvedCustomizations | undefined; if (this._pipeline) { try { sdk = await this._pipeline.snapshotResolvedCustomizations(); } catch (err) { this._logService.warn(`[Claude:${this.sessionId}] snapshotResolvedCustomizations failed`, err); } }

即"客户端推送层 ∪ 磁盘发现层(rules/mcp/hooks/多根)∪ SDK 存活快照"三重合并:materialize 后,存活 SDK 快照会把磁盘集合过滤为会话真正加载的集合(并把 SDK 独有条目标记为不可编辑);materialize 前没有Query,展示完整磁盘集合;一次瞬时的 SDK 读失败只 warn-log 并回退到未过滤磁盘集合——绝不让 UI 被清空(同文件第 1486-1489 行注释明示该回退策略)。

  • onDidCustomizationsChange: Event<void>(第 1420-1421 行):合并的 fire-and-forget 信号,三种来源:客户端写入(经 diff observable,构造期接线)、materialize 完成(首次向工作台暴露服务端层)、send()pre-flight rebind 完成(重建后的 SDK 解析集可能变化)。它只驱动工作台重新拉取getSessionCustomizations本身不触发任何 SDK 动作

此外会话还有setHostCustomizations(hostCustomizations)(供宿主在getChatCustomizations边界显式下发最新快照,undefined与空列表语义刻意区分)。

层四:发送前排水 ——send()pre-flight 的统一 rebind

计划第 6 步把"发送前检查"从单一工具差异扩展为统一差异检查。真实实现(claudeAgentSession.ts):

async send(prompt: SDKUserMessage, turnId: string, resource: URI, workingDirectories?: readonly URI[], /* ... */): Promise<void> { // ... if (this.toolDiff.hasDifference || this.clientCustomizationsDiff.hasDifferenceFrom(this._desiredClientPluginPaths()) || this._appliedMcpLaunchEnablementRevision !== this._mcpLaunchEnablementRevision || this._pendingResumeSessionAt !== undefined || !areAdditionalWorkingDirectoriesEqual(this._appliedAdditionalDirectories, this._desiredAdditionalDirectories) || this._pendingTransportSwitch) { await this._rebindForSyncedState(); } else { await pipeline.setPermissionMode(resolveCurrentPermissionMode(/* ... */)); } // ... await pipeline.send(prompt, turnId, clientContext); }

可验证的要点:

  • 单一 yield-restart 覆盖工具与定制化两种发散_rebindForSyncedState()一次 trip 内同时处理 client-tool diff 与 customization diff,计划中"reloadPlugins后对比工具再决定是否重启"的两段式流程已不存在;
  • 定制化脏位是增量比较:实现用的clientCustomizationsDiff.hasDifferenceFrom(this._desiredClientPluginPaths()),把期望插件路径与上次成功应用路径做差异比较(计划第 1 步中consume()清脏 + 返回当前路径的设计,在后阶段实现中演化为"reducer-backed enablement drift 检测"),同时保留模型内dirty位与 rebind 失败重试语义;
  • rebind 在途竞态_onDidCustomizationsChange在 rebind 完成后会再次 fire,让工作台拿到重建后的解析结果。

层五:瘦调度器 ClaudeAgent

ClaudeAgent只做查表与扇出(计划第 5 步)。源码佐证(claudeAgent.ts):

  • _findAnySession(sessionId)(第 475 行)是统一的会话解析入口,支持 SDK 会话 id 与本地会话 id 两种形式(第 493、524、552 行);
  • getChatCustomizations(chat, context, hostCustomizations?)(第 2594 行)先经_findChatByUri(chat)精确定位 backing 会话(不靠配置作用域猜 SDK conversation id),再下发宿主快照并返回sess.getSessionCustomizations()(第 2602 行);
  • startMcpServer/stopMcpServer(第 2621-2629 行)同样走_findAnySession委托,反映定制化生态(MCP server 启停)也挂在会话上。

计划中另外要求的动作语义:setClientCustomizations构造 progress 回调、把每一项经_onDidSessionProgress.fire(SessionCustomizationUpdated)转发,并放入 per-session_sessionSequencer串行化,防止来自AgentSideEffects的 fire-and-forget 调用与首次sendMessage竞争;setCustomizationEnabled(uri, enabled)遍历_sessions.values()扇出到每条会话;启停写是同步的,不做专门 sequencer——SDK 副作用在send()pre-flight 排空,而 pre-flight 本身已运行于 per-session sequencer 之下,快速连点 toggle 自然合并,只有最终启停态对下一次 send 有意义。

层六:流水线快照 ——snapshotResolvedCustomizations()

计划第 3 步要求 claudeSdkPipeline.ts 暴露窄公共方法。实现于该文件第 110 行async snapshotResolvedCustomizations(): Promise<ISdkResolvedCustomizations>,返回结构(第 67 行注释)包含会话侧解析出的三类 SDK 资源。设计要点:

  • 并行读取存活QuerysupportedCommands/supportedAgents/mcpServerStatus
  • getSessionCustomizations用来暴露服务端层;
  • 不驱动脏位——脏位只由客户端写入翻转;
  • 版本差异风险被隔离在此方法内,会话侧捕获抛错后 warn-log 并回退到纯客户端投影(会话层第 1510-1512 行的 try/catch 印证)。

协议表:IAgent 定制化表面 vs 会话方法

将原文档的 API 规划与实现对照如下,方便检索定位:

表面(IAgent 协议 / 会话)拥有者实现位置语义
setClientCustomizations(session, clientId, customizations)ClaudeAgent(瘦委托)→ SessionclaudeAgentSession.tspluginManager.syncCustomizations后采纳快照,翻转脏位
setCustomizationEnabled(uri, enabled)Session启停映射 +_rebuildClientCustomizationEnablement(第 1465-1476 行)同步写;下一send()生效
getCustomizations()/getClientCustomizations()Session第 1452 行客户端推送投影(按 client 合并去重)
getSessionCustomizations(session)Session第 1491 行客户端 ∪ 磁盘扫描 ∪ SDK 存活快照的合并视图
onDidCustomizationsChangeSession(agent 聚合)第 1420-1421 行客户端写入 / materialize / rebind 三类来源的合并信号
snapshotResolvedCustomizations()PipelineclaudeSdkPipeline.ts并行读存活Query的 commands / agents / MCP servers
syncCustomizations(...)IAgentPluginManager(进程级单例)agentPluginManager.ts全局同步 + 共享 on-disk 缓存

竞态语义与风险缓释

计划把竞态处理视为正确性核心,真实代码逐条落实了以下四条不变量:

  1. mid-turn 不污染当前回合:一次sendMessage在途期间落地的 sync/toggle 只会更新会话 diff 与磁盘文件,当前回合继续使用 SDK 已持有的插件集继续运行;脏位在下一次send()pre-flight 被观察并触发重启。这正是计划引用的CONTEXT.md §M11中"no mid-turn mutation path"不变量;
  2. rebind 在途的写入不丢失:diff 的 autorun 会在 rebind 完成后再翻转脏位,无需快照对比(模型文件第 92-94 行注释);
  3. materialize 在途的 syncsetClientCustomizations路由进 per-session sequencer,且buildOptions调用点在构建 Options 时才读consume(),双保险避免启动期竞态(phase11-plan.md);
  4. per-session 启停分叉是被接受的设计:每条会话各自拥有启停态;工作台负责对自己关心的每条会话广播——调用setCustomizationEnabled(uri, enabled),由 agent 扇出到所有_sessions

与之配套的风险清单(计划 Risks):Query.reloadPlugins()无参数这一事实驱动了从"延迟合并重载"到"总是重建"的架构转向;SDK 版本差异被收口在snapshotResolvedCustomizations()内并允许降级;rebind 抛错时脏位保留以保证重试。

验证与测试

计划内置的验证分三层,测试文件布局镜像源码布局:

单元 / 集成。执行既有 agentHost 单元套件:

./scripts/test.sh --runGlob "**/agentHost/test/**/*.test.js"

再按模块补齐针对性用例:

  • Model:sync 往返、启停触发onDidChange、nonce-bump 与元数据变更翻转脏位、enabledPluginPaths派生、consume()清脏;
  • Options:plugins非空 →Options.plugins投影;空 → 字段省略;
  • Pipeline:snapshotResolvedCustomizations原样返回三类 SDK 字段;
  • Session:adoptClientCustomizationssetClientCustomizationEnabled→ 读回往返;mid-turn toggle 不污染在途回合;send()pre-flight 脏位触发 rebind;SDK 快照吞错降级;
  • Agent:瘦调度正确性——setClientCustomizationsSessionCustomizationUpdatedaction 转发进度且运行于 per-session sequencer;setCustomizationEnabled扇出所有_sessions

对应测试文件:customizations/ 模型单测、claudeAgent.test.ts(新增 describe)、claudeSdkOptions.test.ts(plugins投影)、claudeSdkPipeline.test.ts(快照往返)。

E2E 场景。用launch(Playwright/CDP 驱动./scripts/code.sh --agents)、code-oss-logs(读agenthost.log与 per-session 日志)、chat-customizations-editor(定制化编辑器领域专家)三个技能执行端到端剧本:

  1. --agents启动 Code OSS,在Local Agent Host下打开 Claude 会话;
  2. 从 chat-customizations 编辑器添加一个带简单 skill 插件的定制化,发一轮使用该 skill 的回合,确认agenthost.log出现[Claude] session ...: enableFileCheckpointing=true isResume=false且回合成功引用该插件;
  3. 停用同一定制化再发一轮,日志应出现[Claude] session ...: resume rebuild——任何插件集合变更都是 yield-restart,没有reloadPlugins快路径;
  4. 添加第二个定制化再发一轮,日志再次出现resume rebuild,且新插件进入Options.plugins
  5. 确认脏位在回合间清除(无多余第二次 rebind),且无 Claude 子进程泄漏(ps aux | grep claude | grep -v grep)。

总结:可以从该仓库直接带走的关键结论

  • 状态所有权是架构主线:VS Code 的 Claude 集成从 Phase 10.5/11 起确立了"会话自治"方向——client tools 与 customizations 都以 per-session diff 形态存在,provider 层只做查表与扇出;这与 CopilotAgent 的 provider 级PluginController形成刻意对照;
  • 插件生效的唯一安全点是 yield 边界:SDKQuery.reloadPlugins()无参数这一现实约束,把"热替换 + 延迟合并"方案整体否决,换来的是"单一脏位 +send()pre-flight 统一 rebind"的简单正确模型;
  • 合并视图分三层:客户端推送(IAgentPluginManager.syncCustomizations结果)∪ 磁盘/多根扫描(rules / MCP / hooks / skills)∪ 存活 SDK 快照,任何一层瞬态失败都不应清空 UI;
  • 想深入继续阅读,仓库内还保留了同一演进线的 roadmap 与 phase 计划(roadmap.md、phase10.5-plan.md 及 phase12–19 系列),以及 CONTEXT.md 中的定制化集群与 hot-swap/restart 分类法。

【免费下载链接】vscodeVisual Studio Code项目地址: https://gitcode.com/GitHub_Trending/vscode6/vscode

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询