第五部分 · 会话、身份与扩展
第 16 章:预设、自修改与集成生态
per-session 组合、agent 自修改运行时、Claude Code/Codex hooks 桥、MCP 与审批
「一切皆插件」的最后两公里
「一切皆插件」的前半程是把系统拆成插件树(第 1、2 章);后半程是回答两个更尖锐的问题:同一个进程里,不同会话能不能有不同的插件组合?和模型能不能亲手改这棵树?——这一章的主角就是这两个问题的答案:preset 与 cordis 工具族,外加把外部生态接进来的 hooks 与 MCP。
preset:一次装载、多会话共享、守卫负责隔离
第 2 章预告过:preset 是运行期、per-session、模型面的组合。一个 preset 就是一个目录,内含 agent.cordis.yml(可选 preset.metadata.yml 提供显示名/描述),目录名即 id(PRESET_ID = /^[a-z0-9][a-z0-9-]*$/,路径包含边界)。核心机制是 standing mount(常驻挂载):每个 preset 在整个进程里只装配一次,挂在 standing scope 下;每个 agent 通过 bindScopeParent(agentKey, standing.key) 把自己的 scope key 认父到 standing key——于是该 agent 的 ctx.tools/ctx.systemPrompt 视图看到这套注册,监听器收到该 agent 的事件(第 3 章的 scope 路由)。
「一次装载、多会话共享」意味着插件实例是进程内共享的——会话差异由插件内部按 session 键控。隔离性由两道装配守卫保证(mount.ts):
const { tree, fiber } = subtree
const unusable = inactiveRows(tree)
if (unusable.length > 0) {
throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`)
}
const leaked = leakedServices(agentCtx, fiber)
if (leaked.length > 0) {
throw new Error(
`row(s) published process-global service(s) [${leaked.join(', ')}]; `
+ 'a preset service must sit behind an `isolate` realm or move to the host composition',
)
}
inactiveRows 拒绝未激活的行(缺注入服务);leakedServices 拒绝把服务发布进 ROOT realm 的行——那是进程级全局,第二个会话挂载就会碰撞。preset 行通过 isolate: 把服务放进 entry-local realm(组内私有、进程内不可见),两个会话各自拿到自己的实例。错误信息直白:「a preset service must sit behind an isolate realm」——隔离性被放进挂载守卫而不是靠纪律。另外 PresetTree.write() 被覆写为空:preset 是输入,绝不回写文件(避免 Loader 卸载时把组合文件截断成 [])。
会话日志记录:header.agentPreset 是创建事实;空白期切换写 agent-preset/selected 事件;resolveSessionPreset 以日志中最后一次选择为准。Web UI 的「新会话屏幕」用 recompose 在空白会话出现时应用选择(bindScopeParent 的重链是唯一权柄)。
profile(第 2 章):启动期、进程级、系统面——这个进程启动哪些子系统。preset:运行期、per-session、模型面——这个会话给模型看哪些工具/提示词。用三个问题区分:什么时候生效(启动 vs 会话创建)、影响谁(进程 vs agent)、谁能改(CLI 配置 vs 用户选择/子代理继承)。
自修改:手术刀对着自己
packages/extensions/ 是 dsh 最激进的包族——agent 运行期自修改自己的插件运行时。模型侧是 tool-cordis 的六个工具:
| 工具 | 作用 |
|---|---|
cordis_inspect_list | 列出所有 Inspect Provider(平台/只读方法/schema) |
cordis_inspect_query | 按 provider+method 执行只读查询(真实服务签名/事件/工具 schema/slot 树) |
cordis_inspect_self | 查本会话拥有的动态插件/包及其源码与运行诊断 |
cordis_define | 定义不可变 Package:kind: "new"(3–6 小写前缀)或 kind: "existing"(追加版本);code.host/code.client 各是「返回 Cordis Plugin 的纯 JS 函数体」,只校验不执行 |
cordis_run | 激活某个 Package:mode: "run"(首次/回滚)、mode: "update"(切版本);含 Client half 的包先进入 awaiting-approval |
cordis_stop / cordis_undefine | 停止 / 删除(只能删本会话拥有的动态插件) |
宿主侧(cordis-host-runner,ctx.dynamicCordisRunner):Plugin 按 sessionId 归属,Package 不可变追加,每个 Plugin 一个 active run(fiber)。含 Client half 的激活先发 cordis/request-run 事件置 awaiting-approval,浏览器审批后经 @Remote('runHostHalf') 启动(可勾选「批准未来版本」)。异步结果经 agent.steer(...) 灌回模型下一步。Host half 代码在 node:vm 沙箱里求值(vmTimeoutMs 默认 5000 仅限同步部分)。
安全边界(guard.ts)的定性很重要——门面是白名单而非沙箱:sandboxContext 用 Proxy 只暴露 effect/on/once/provide 与 timer 动词、标记校验的 harness.defineTool/registerTool、schema-only 的 ctx.tools.get(不暴露可调用的 execute,防绕过 ToolRuntime.execute);服务读取要求先 inject 声明;任何服务返回 Context 一律拒绝(denyContext)。设计笔记明确:这是 trust boundary,不是 security boundary——动态插件能拿到 ctx.shell/ctx.fs 的真实权限,是「bash 同级信任的 opt-in 开发工具」;且临时插件只存在于进程内存(不写文件、不改 cordis.yml、重启即失)。
为什么自修改不用「每个能力一个结构化工具」(注册工具、挂监听器、装服务……各来一个)?设计笔记里记着这个决定:一个统一的 Cordis Plugin 词汇覆盖全部能力。模型写的是「一段返回插件的 JS 函数体」,而不是「填这个 schema、调那个 API」——因为 Plugin 本来就是「一切皆插件」的最小公分母。配合不可变 Package + current/next 指针,天然得到版本化与回滚。这是「用系统的原生词汇回答系统自身」的极致。
hooks:把竞品生态翻译进自己的类型系统
packages/hooks/ 是「协议兼容」的样板:不发明新 hook 格式,而是直接加载 Claude Code / Codex 的 hooks.json,把语义折叠成中性结果再映射进 dsh 自己的扩展点。三层结构:
hook-protocol:双方言共享形状CommandHook {command, timeoutSec}与MatcherGroup;中性结果HookOutput(exitCode/stderr/stdout、continue/stopReason、decision——由 legacy 顶层decision与hookSpecificOutput.permissionDecision两种通道归一成一个枚举);runHook经ctx.shell.run执行(凭据清洗、进程组取消、超时);mergeHookOutputs按 deny > ask > allow 取最严格决策;matchesMatcher区分 Claude Code(纯字母数字竖线 = 字面量,否则正则)与 Codex(恒正则);事件写hook/invoked+hook/result(按 handlerId 配对)。hooks-claude-code:加载未修改的 CChooks.json(跳过prompt/agent/http类型并告警);构建 CC 方言载荷;在agent/session-start(SessionStart)、agent/pre-step(UserPromptSubmit)、tools/pre-execute(PreToolUse)、tools/post-execute(PostToolUse)、agent/turn-stopping(Stop)、subagent/start|end六个扩展点上桥接。hooks-codex:snake_case 载荷、正则 matcher、无尾部换行、只尊重阻塞决策(deny)、5 个 hook 点。
PreToolUse 的桥接长这样(hooks-claude-code/src/index.ts):
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }
return next()
})
「deny → dsh 的 deny、ask → dsh 的 ask、否则 next() 放行」——竞品的决策语义被无损翻译进 dsh 的瀑布类型。可迁移模式:协议兼容 = 在你自己的类型系统里为别人的协议留一个翻译层。
MCP:外部工具是一等公民,但零特权
MCP 集成(packages/mcp/mcp-client)的哲学一句话:MCP 工具只是 ctx.tools.register 的普通工具。一个插件实例连一个 MCP 服务器(stdio 或 streamable-http 传输,多服务器 = 配置多行);tools/list 分页拉全量,每个工具转成 ToolDefinition,公开名带命名空间 + 哈希防碰撞:
export function publicToolName(serverName: string, rawName: string): string {
const joined = `mcp__${serverName}__${rawName}`
const normalized = joined.replace(INVALID_NAME_CHARS, '_')
if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized
const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH)
return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`
}
execute 闭包持有原始名并发送 tools/call(从不解析公开名还原);syncTools 两阶段:先取全量构建下一代(失败不动注册表),再 swap(冲突回滚为零工具)。因为走标准注册,审批/权限策略对 MCP 工具一视同仁——外部协议以「只是又一个工具」的身份进入既有管线,MCP 调用就是普通 tool/call/tool/result 会话事件(无专用 MCP 事件)。重连由独立监督器负责:指数退避(500ms → 30s)、maxAttempts 10、超过稳定窗口重置预算。
审批、权限、命令与 ask-user
交互族(packages/interaction/)是安全与人类在场的落点:
| 包 | 内容 |
|---|---|
user-approval | ctx.approval:request() 先写 approval/asked、决定后写 approval/decided(审计对必须 turn 内闭合);策略 'ask'(走 waterfall approval/request,无人应答 fail-closed 为 'unavailable')或 'never'(在分发前确定性拒绝——只有服务自身路径能保证不受监听器顺序破坏);唯一授予是 'allowed-once' |
permission-presets | 两个独立旋钮(sandbox 模式 × approval 策略)捆成预设表;/permission 命令;base bundle 默认 workspace-write + ask(第 2 章见过的 cordis.patch.yml) |
commands | /cmd 人类命令注册表:parseCommand 解析、register() 支持全局与 per-agent scoped 层、execute 不把命令发给模型、command/run/command/done log-only 事件 |
tool-ask-user + user-questions | ask_user_question 工具 → ctx.userQuestions.ask();只允许一个活跃 UI provider,且做运行时归属校验:CALLER_NOT_LIVE(不是注册表里那个精确实例)与 DELEGATED_CALLER(子 agent 不能问人类,必须把问题带回父结果) |
第 5 章的 serviceAsk 就是 ctx.approval 的消费者;第 7 章的沙箱升级 approveEscalation 也是。审批不是某个工具的特性,而是工具管道的一个接缝——任何工具(包括 MCP 工具)都可以要求它。
if (signal?.aborted) return 'cancelled'
// The 'never' policy is decided HERE, before any dispatch: a listener
// registered with `prepend: true` after this service mounts would sit
// ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
// documented promise that 'never' rejects deterministically regardless
// of registration order — only the service's own request path can.
if (this.effectivePolicy(session) === 'never') return 'rejected'
「'never' 必须确定性拒绝」——这个承诺只有服务自身的请求路径能保证,任何监听器形状的门都可能被注册顺序破坏。注释把为什么说得一清二楚。
实践应用
- standing mount + scope 认父:per-session 差异不用「每会话克隆一套插件」——一次装载、多会话共享、插件内部按 session 键控;隔离性交给
leakedServices装配守卫。 - 自修改 = trust boundary 而非 security boundary:白名单门面 + 审批 + 内存态(不写文件、重启即失)三件套,明确回答「什么能改、谁批准、怎么回滚」。
- 生态翻译层:加载未修改的 Claude Code/Codex hooks.json,把两种 decision 通道归一成一个枚举,再映射进自家瀑布类型——协议兼容不是适配器,是翻译层。
- 外部工具零特权:MCP 工具走标准注册,审批/策略一视同仁——「只是一等公民,不是一等特权」。
总结
这一章拆完了 preset、自修改、hooks、MCP 与审批:per-session 组合的 standing mount、模型亲手安装/卸载插件的 cordis 工具族、竞品 hooks 的翻译层、MCP 的零特权接入、以及把「谁能在场」做成接缝的审批体系。第五部分(会话、身份与扩展)至此闭环。第六部分转向系统的另一半:浏览器里的那个世界——Web UI、API 网关与类型图协议。