第五部分 · 会话、身份与扩展

第 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 vs preset 再区分

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定义不可变 Packagekind: "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-runnerctx.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/stopReasondecision——由 legacy 顶层 decisionhookSpecificOutput.permissionDecision 两种通道归一成一个枚举);runHookctx.shell.run 执行(凭据清洗、进程组取消、超时);mergeHookOutputsdeny > ask > allow 取最严格决策;matchesMatcher 区分 Claude Code(纯字母数字竖线 = 字面量,否则正则)与 Codex(恒正则);事件写 hook/invoked + hook/result(按 handlerId 配对)。
  • hooks-claude-code:加载未修改的 CC hooks.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-approvalctx.approvalrequest() 先写 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-questionsask_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' 必须确定性拒绝」——这个承诺只有服务自身的请求路径能保证,任何监听器形状的门都可能被注册顺序破坏。注释把为什么说得一清二楚。

实践应用

  1. standing mount + scope 认父:per-session 差异不用「每会话克隆一套插件」——一次装载、多会话共享、插件内部按 session 键控;隔离性交给 leakedServices 装配守卫。
  2. 自修改 = trust boundary 而非 security boundary:白名单门面 + 审批 + 内存态(不写文件、重启即失)三件套,明确回答「什么能改、谁批准、怎么回滚」。
  3. 生态翻译层:加载未修改的 Claude Code/Codex hooks.json,把两种 decision 通道归一成一个枚举,再映射进自家瀑布类型——协议兼容不是适配器,是翻译层。
  4. 外部工具零特权:MCP 工具走标准注册,审批/策略一视同仁——「只是一等公民,不是一等特权」。

总结

这一章拆完了 preset、自修改、hooks、MCP 与审批:per-session 组合的 standing mount、模型亲手安装/卸载插件的 cordis 工具族、竞品 hooks 的翻译层、MCP 的零特权接入、以及把「谁能在场」做成接缝的审批体系。第五部分(会话、身份与扩展)至此闭环。第六部分转向系统的另一半:浏览器里的那个世界——Web UI、API 网关与类型图协议。