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.ts、claudeAgent.ts、claudeSdkOptions.ts、customizations/目录等),完整解析 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.setClientCustomizations与ClaudeAgent.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 same
IAgentsurface 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)表面:
onDidCustomizationsChange、getCustomizations()、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;
- 不把 SDK
initializationResult()当作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-logs、chat-customizations-editor。
架构总览:镜像 clientTools 的两层模型
整体布局可以归纳为"一个会话、两类来源、一个纯函数、一个调度器":
- 会话内持有客户端推送层(client-pushed tier)的状态与脏位;
- SDK 发现层(server-side tier)按需从存活
Query上快照,不进入会话状态; - 两者由纯投影函数合并后输出到
IAgent协议面; ClaudeAgent退化为瘦调度器,按会话 id 查表并委托。
计划中设想的落地文件是新建 customizations/ 目录,与clientTools/平行。实际仓库中该目录已成型,并随后续阶段演进出了一整套"扫描"协作体:
claudeSessionClientCustomizationsModel.ts—— 客户端推送快照的模型 + 脏位跟踪(对应计划第 1 步);claudeSessionCustomizationDiscovery.ts、claudeMultiRootCustomizationDiscovery.ts、scan/下的claudeRuleScan.ts、claudeMcpScan.ts、claudeHookScan.ts、claudeAgentSkillScan.ts、claudeNativePluginScan.ts—— 多根/用户目录级的定制化发现;claudeBuiltinCommands.ts、claudeCustomizationPolicy.ts。
从实现看,计划中独立的ClaudeSdkCustomizationBundler名称未保留为独立文件,而是被多根发现 + 磁盘扫描路径吸收,服务端暴露层改为getSessionCustomizations内"磁盘集合 + 存活 SDK 快照"的合并投影(详见下文)。可以推断:Phase 11 骨架原样落地,而具体文件划分在后续阶段(12–19 的 phase 计划在仓库中亦存在)持续演进。
关键决策一:状态放在 Session,而不是 provider 全局
计划 Decisions 明确:不建 provider 级 controller。ClaudeAgent永远不持有定制化状态本身;它只负责按会话 id 查表、委托、并迭代_sessions聚合出站事件与列表。每条会话都从createProvisional()起就拥有自己的customizationsDiff,因此 materialize 前后 API 行为一致,无需按isPipelineReady分支。
关键决策二:插件集合变更 = 必须重启,没有 defer-and-coalesce
计划的灵魂决策被源码完整继承。原因链如下:
- SDK
Query.reloadPlugins()无参数,只会重新读取启动时固化在Options.plugins里的插件 URI 集合; - 因此任何add / remove / toggle / nonce-bump都需要一次完整的 SDK 重建;
- 由此强制出"单一脏位 + 单一
send()pre-flight 分支"的更简模型; 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.plugins→Options.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.plugins为Record<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 资源。设计要点:
- 并行读取存活
Query的supportedCommands/supportedAgents/mcpServerStatus; - 由
getSessionCustomizations用来暴露服务端层; - 不驱动脏位——脏位只由客户端写入翻转;
- 版本差异风险被隔离在此方法内,会话侧捕获抛错后 warn-log 并回退到纯客户端投影(会话层第 1510-1512 行的 try/catch 印证)。
协议表:IAgent 定制化表面 vs 会话方法
将原文档的 API 规划与实现对照如下,方便检索定位:
| 表面(IAgent 协议 / 会话) | 拥有者 | 实现位置 | 语义 |
|---|---|---|---|
setClientCustomizations(session, clientId, customizations) | ClaudeAgent(瘦委托)→ Session | claudeAgentSession.ts | 调pluginManager.syncCustomizations后采纳快照,翻转脏位 |
setCustomizationEnabled(uri, enabled) | Session | 启停映射 +_rebuildClientCustomizationEnablement(第 1465-1476 行) | 同步写;下一send()生效 |
getCustomizations()/getClientCustomizations() | Session | 第 1452 行 | 客户端推送投影(按 client 合并去重) |
getSessionCustomizations(session) | Session | 第 1491 行 | 客户端 ∪ 磁盘扫描 ∪ SDK 存活快照的合并视图 |
onDidCustomizationsChange | Session(agent 聚合) | 第 1420-1421 行 | 客户端写入 / materialize / rebind 三类来源的合并信号 |
snapshotResolvedCustomizations() | Pipeline | claudeSdkPipeline.ts | 并行读存活Query的 commands / agents / MCP servers |
syncCustomizations(...) | IAgentPluginManager(进程级单例) | agentPluginManager.ts | 全局同步 + 共享 on-disk 缓存 |
竞态语义与风险缓释
计划把竞态处理视为正确性核心,真实代码逐条落实了以下四条不变量:
- mid-turn 不污染当前回合:一次
sendMessage在途期间落地的 sync/toggle 只会更新会话 diff 与磁盘文件,当前回合继续使用 SDK 已持有的插件集继续运行;脏位在下一次send()pre-flight 被观察并触发重启。这正是计划引用的CONTEXT.md §M11中"no mid-turn mutation path"不变量; - rebind 在途的写入不丢失:diff 的 autorun 会在 rebind 完成后再翻转脏位,无需快照对比(模型文件第 92-94 行注释);
- materialize 在途的 sync:
setClientCustomizations路由进 per-session sequencer,且buildOptions调用点在构建 Options 时才读consume(),双保险避免启动期竞态(phase11-plan.md); - 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:
adoptClientCustomizations→setClientCustomizationEnabled→ 读回往返;mid-turn toggle 不污染在途回合;send()pre-flight 脏位触发 rebind;SDK 快照吞错降级; - Agent:瘦调度正确性——
setClientCustomizations以SessionCustomizationUpdatedaction 转发进度且运行于 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(定制化编辑器领域专家)三个技能执行端到端剧本:
- 以
--agents启动 Code OSS,在Local Agent Host下打开 Claude 会话; - 从 chat-customizations 编辑器添加一个带简单 skill 插件的定制化,发一轮使用该 skill 的回合,确认
agenthost.log出现[Claude] session ...: enableFileCheckpointing=true isResume=false且回合成功引用该插件; - 停用同一定制化再发一轮,日志应出现
[Claude] session ...: resume rebuild——任何插件集合变更都是 yield-restart,没有reloadPlugins快路径; - 添加第二个定制化再发一轮,日志再次出现
resume rebuild,且新插件进入Options.plugins; - 确认脏位在回合间清除(无多余第二次 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 边界:SDK
Query.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),仅供参考