Claudian 中 Claude Provider 的架构设计:基于 claude-agent-sdk 的执行、存储与历史管理
【免费下载链接】claudianAn Obsidian plugin that embeds Claude Code/Codex as an AI collaborator in your vault项目地址: https://gitcode.com/GitHub_Trending/cl/claudian
本文以 src/providers/claude/AGENTS.md 这份 Claude Provider 架构文档为主体,系统梳理 Claudian(一个将 Claude Code/Codex 嵌入 Obsidian 库的插件)中 Claude 提供商层的职责边界、目录所有权、设计规则、存储规则、运行时陷阱与核心不变量,并结合仓库源码逐条印证这些约束的实际落点,帮助读者理解一个"在 Electron/Obsidian 运行时上长期驻留 AI SDK 进程"的插件层应当如何设计。读完本文,你将掌握:为什么原生 SDK 事件必须先归一化才能进入核心层、哪些变更会触发持久查询重启、.claude/settings.json的合并写入策略,以及会话"失忆"(amnesia)与崩溃恢复的检测机制。
1. Provider 层定位:provider-neutral 契约上的 Claude 兼容层
AGENTS.md 开篇即给出该目录的定位:src/providers/claude/在@anthropic-ai/claude-agent-sdk之上实现provider-neutral(提供商无关)的执行契约,并在其外层叠加 Claude Code CLI 的兼容性处理。当前仓库 package.json 中锁定的 SDK 版本为@anthropic-ai/claude-agent-sdk@0.3.226,这说明整个 Claude 提供商层构建在该 SDK 的原生事件模型之上,而非直接调用 HTTP API。
同目录下的 CLAUDE.md 只有一行@AGENTS.md,即把本 AGENTS.md 作为该模块唯一的 agent 指令源——这也是本仓库模块级文档的组织惯例。
1.1 依赖边界(Dependency Boundary)
文档明确了两条依赖边界规则:
归一化先行:SDK 的原生事件(events)、选项(options)、transcript 记录与 provider 状态,在越过边界进入 core 或 feature 契约之前必须被归一化。从源码结构看,这一边界由多个专门模块承担:
- src/providers/claude/sdk/(
types.ts、typeGuards.ts、messages.ts)负责原生消息的带类型解释; - src/providers/claude/normalization/ClaudeTaskToolNormalizer.ts 与 src/providers/claude/stream/transformClaudeMessage.ts 负责消息流转换;
- src/providers/claude/execution/ClaudeExecutionEventNormalizer.ts 在执行会话处完成事件适配。
- src/providers/claude/sdk/(
迁移接缝(migration seams):现有从 provider 兼容层 storage/types 导入到
src/app/的引用属于迁移期接缝,禁止新增;当实质修改这些接缝时,应把共享契约下沉到core/。这条规则的意义在于防止 app 层对 Claude 特有的存储结构产生新的耦合,保证未来更换或增减 provider 时 app 层不受影响。
2. 目录所有权(Ownership):六个子系统的职责划分
AGENTS.md 的核心是一张所有权表,把src/providers/claude/下的子目录划分为六个职责域。逐条对照源码目录可以确认其边界是真实落地的:
| 区域 | 文档声明的职责 | 源码印证 |
|---|---|---|
execution/ | 执行会话绑定、快照、事件适配、交互处理、恢复策略 | src/providers/claude/execution/ 中的ClaudeExecutionSession.ts、ClaudeExecutionBackend.ts、ClaudeInteractionHandler.ts、ClaudeExecutionStrategies.ts |
runtime/ | 持久 SDK 查询、重启决策、消息通道行为、CLI 派生、原生 prompt 构造 | src/providers/claude/runtime/ 中的ClaudeSessionManager.ts、ClaudeMessageChannel.ts、customSpawn.ts、claudeColdStartQuery.ts、ClaudeUserMessageFactory.ts |
history/ | 只读的原生 transcript 发现、分支投影、历史模型恢复、rewind、subagent 回放 | src/providers/claude/history/ 中的ClaudeConversationHistoryService.ts、sdkBranchFilter.ts、ClaudeSessionRecovery.ts、ClaudeSubagentHistoryService.ts |
app/、commands/、agents/、plugins/ | 工作区作用域发现与 provider 原生目录 | ClaudeWorkspaceServices.ts、ClaudeCommandCatalog.ts、AgentManager.ts、PluginManager.ts |
storage/ | 仅管理文档中列明的 Claudian 托管部分:Claude 兼容的 settings、command、skill、agent、plugin 文件 | src/providers/claude/storage/ 中的CCSettingsStorage.ts、SlashCommandStorage.ts、SkillStorage.ts、AgentVaultStorage.ts等 |
types/ | Claude 拥有的 provider 状态的带类型解释与消毒 | src/providers/claude/types/ 中的providerState.ts、settings.ts、models.ts |
表中还有一条关键的原则性声明:执行会话(execution session)拥有存活的 provider 快照;历史服务(history services)负责重建回放状态,但绝不能成为第二个活会话权威(second live-session authority)。换言之,"当前对话在 SDK 侧的真实状态"只有一个权威来源——执行会话;history/下的服务只能读原生 transcript 做投影,不能反向改写或替代活会话的视图。这一"单一权威"约束与文末 Invariants 一节呼应,是整个 Claude 层状态一致性的基石。
3. 设计规则(Design Rules):持久查询与重启决策
AGENTS.md 的 Design Rules 一节定义了五条运行时行为规则,每一条都能在源码中找到对应实现。
3.1 保持持久 SDK 查询存活
规则原文:Keep the persistent SDK query alive across turns when possible. Update model, permission mode, and effort through SDK calls.
即跨轮次尽量保持同一个 SDK 查询进程存活,模型、权限模式、effort 的变更通过 SDK 的调用(而非杀进程重建)来生效。这样能避免每轮都付出冷启动代价。从源码结构看,claudeColdStartQuery.ts 只承担冷启动查询,而日常轮次走持久通道;src/providers/claude/execution/ClaudeExecutionSession.ts 则管理会话与查询的绑定。
3.2 何时必须重启持久查询
规则原文列出了六类必须重启的变更条件:生效的系统提示词、被禁用的工具集、插件集、settings 来源集、CLI 路径、Chrome 启用状态、auto-mode 启用状态。可以推断,重启判定逻辑位于 src/providers/claude/execution/ClaudeExecutionStrategies.ts 与运行时会话管理中:它需要比较"当前生效配置指纹"与"上一轮查询启动时的指纹",一旦系统提示词或工具集等影响 SDK 子进程行为的参数漂移,就必须终止旧查询、以新参数重新 spawn,否则新设置不会真正作用于 SDK 侧的 agent 行为。
3.3 fallback 模型是"用户偏好"而非硬编码
规则指出:Claude 的 provider fallback 模型是一种用户偏好,需对"当前动态模型选项"(包括环境映射选项与自定义选项)解析;新设置偏好 Opus 档;偏好不可用时静默回退,且不改变既有会话、也不改变全局的"未来标签页种子"。
仓库源码印证了这一分层:src/providers/claude/modelTiers.ts 定义了CLAUDE_MODEL_TIER_DEFINITIONS,每个 tier(haiku / sonnet / opus / fable)绑定一个环境变量键(如ANTHROPIC_DEFAULT_OPUS_MODEL)、环境优先级(environmentPriority)与 1M 上下文/xhigh 的版本门槛;modelSelection.ts、modelOptions.ts 则在其上完成偏好与动态选项的解析。"偏好不可用时回退而不污染既有会话"的语义,正是为了避免用户切模型时历史会话的模型归属被篡改。
3.4 禁止重复助手文本(去重)
规则:Do not duplicate assistant text. The SDK can emit text incrementally and again in the final assistant message; stream handling must preserve the existing dedupe behavior.
即 SDK 会先增量流出文本、再在最终 assistant 消息里整体重发一次;流处理必须保留去重。从源码结构看,去重逻辑落在 src/providers/claude/stream/(transformClaudeMessage.ts负责消息形态转换、toolInputStreamState.ts维护工具流状态),而 tests/unit/providers/claude/ 下有 41 个测试文件守护整个 provider 层的行为契约,其中即包含针对该目录的单测。
3.5 Token 用量合并策略
规则:Token usage is intentionally merged from assistant and result messages. Assistant messages provide accurate input-side counts; result messages provide authoritative context-window data.
Token 统计被有意拆成两条来源再合并:assistant 消息提供准确的输入侧计数,result 消息提供权威的上下文窗口数据。这意味着任何只看单一消息类型的统计实现都会得到不完整数据,改动用量展示时必须保持这一合并语义。
3.6 自定义 spawn 函数
规则:createCustomSpawnFunction()handles Obsidian/Electron process quirks. Preserve full-pathnoderesolution and manual abort handling.
该规则的实现是 src/providers/claude/runtime/customSpawn.ts,其源码注释解释了两处 Electron 特殊处理:
- 全路径 node 解析:SDK 只对部分脚本扩展名走
node,因此在 Electron 以shell=false派生之前,cliPathRequiresNode(command)判定的 Node 支撑路径要先归一化——command === 'node'时替换为findNodeExecutable(enhancedPath)得到的全路径,否则把原命令挪进 args 首位、以解析出的 node 全路径作为新 command。 - 手动 abort 处理:源码注释明确写道——不能把
signal直接传给spawn(),因为 Obsidian 的 Electron 运行时使用不同的 realm 承载AbortSignal,会导致 Node 内部的instanceof EventTarget检查失败;因此改为监听signal的abort事件并手动child.kill('SIGTERM')。 - 另有
installTreeAwareKill包装child.kill,在 Windows cmd shim 场景下支持进程树终止;当DEBUG_CLAUDE_AGENT_SDK环境变量存在时还会接管 stderr 管道,避免 Electron 下 stderr 阻塞。
4. 存储规则(Storage Rules):与 Claude Code 共存的文件边界
这是 AGENTS.md 中最具"共享文件系统治理"色彩的一节:Claudian 与 Claude Code 原生 CLI 同时读写用户的~/.claude与 vault 内的.claude/,必须严格划分各自的地盘。
4.1.claude/settings.json合并写入
规则:CCSettingsStorage.save()must merge with existing.claude/settings.json; Claudian only owns permissions and plugin enablement.
实现见 src/providers/claude/storage/CCSettingsStorage.ts。其save()的行为与文档完全对应:
- 先读取既有
.claude/settings.json(常量CC_SETTINGS_PATH = '.claude/settings.json'),解析失败时抛出NotifiedMutationError并弹出 ObsidianNotice——拒绝覆盖非法 JSON,防止误伤 Claude Code 写入的字段; - 合并策略为
...existing + 受管字段:保留所有不认识的字段("Preserve CC-specific fields we don't manage"),只覆写$schema、permissions,以及可选的enabledPlugins。
类还暴露了权限规则的细粒度 API:addAllowRule/addDenyRule/addAskRule(去重追加)与removeRule(三列表统一过滤),以及setPluginEnabled。normalizePermissions会对 allow/deny/ask 列表做字符串过滤消毒——这正对应 Ownership 表中types/的职责"带类型解释与消毒"。
4.2.claude/mcp.json:Claude Code 独占,Claudian 只删不碰
规则:Claude Code owns MCP configuration, authentication, health checks, and connection lifecycle through its native CLI and settings scopes. At application storage initialization, the composition root invokes the Claude-owned legacy cleanup to delete.claude/mcp.json; no other Claudian code may read, write, inject, or migrate that path.
即 MCP 配置、鉴权、健康检查、连接生命周期全部归 Claude Code 原生 CLI 管;Claudian 唯一被允许的动作是在存储初始化阶段执行一次遗留清理(删除历史版本遗留的.claude/mcp.json),其余任何 Claudian 代码不得读写该路径。实现位于 src/providers/claude/storage/LegacyMcpConfigCleanup.ts。这条规则划清了一条硬边界:Claudian 不再做 MCP 的"影子管理器"。
4.3 插件启用状态双写
规则:Plugin enabled state is dual-written to.claude/settings.jsonandPluginManager.plugins[].enabled. Keep both in sync.
即插件启用位同时写入.claude/settings.json的enabledPlugins(见上文CCSettingsStorage.setPluginEnabled)与 src/providers/claude/plugins/PluginManager.ts 维护的plugins[].enabled,两处必须同步——前者供 Claude Code CLI 识别,后者供 Claudian 内部投影。
4.4 原生 transcript 的发现路径
规则:Native transcripts are read from{CLAUDE_CONFIG_DIR:-~/.claude}/projects/{vault}/; resolve the config dir throughresolveClaudeConfigDir, never hardcode~/.claude.
两处源码严格实现了该规则:
- src/providers/claude/config/ClaudeConfigDir.ts 的
resolveClaudeConfigDir():优先读环境变量CLAUDE_CONFIG_DIR;未设置时回落到按平台解析的主目录(Windows 依次取USERPROFILE、HOMEDRIVE + HOMEPATH,否则$HOME,再兜底os.homedir())拼接.claude;相对路径则相对 vault 路径解析并做 NFC 归一化。 - src/providers/claude/history/sdkSessionPaths.ts 的
getSDKSessionPath():transcript 位于{configDir}/projects/{编码后的 vault 路径}/{sessionId}.jsonl,其中encodeVaultPathForSDK()把 vault 绝对路径中所有非字母数字字符替换为-(replace(/[^a-zA-Z0-9]/g, '-')),与 SDK 的目录命名规则一致;isValidSessionId()用isPathSafeId()防御路径穿越(长度 ≤128、不含../分隔符、仅允许[a-zA-Z0-9_-])。locateSDKSessions()还能在标准路径缺失时对projects/目录做广度扫描,区分available/relocated/missing/unknown四种可用性——这对应文档 Ownership 表中history/的"只读原生 transcript 发现"。
4.5 历史模型恢复与斜杠命令编码
- 历史所选模型恢复:Historical selected-model recovery returns a provider-qualified model only from a valid active-branch checkpoint. For multi-segment conversations, the checkpoint-bearing or latest authoritative segment must resolve; do not silently fall back to an older segment's model or make the recovery locator resumable.即多段会话恢复时,必须解析到"带 checkpoint 的分段或最新的权威分段",禁止静默回退到更老分段的模型,也禁止把恢复定位器做成可续跑的——防止 UI 上显示一个与历史实际不符的模型标签。相关逻辑在 src/providers/claude/history/ClaudeSessionRecovery.ts 与 ClaudeConversationHistoryService.ts。
- 斜杠命令 ID 编码:Slash command IDs use reversible encoding: dashes become
-_, slashes become--.即把-编码为-_、/编码为--,保证命令 ID 在作为标识符(如存储键)时仍可无损还原,实现见 src/providers/claude/storage/SlashCommandStorage.ts。
5. 运行时陷阱(Runtime Gotchas):六条踩坑记录
AGENTS.md 把 SDK 集成的六个"暗坑"写成显式规则,每条都对应一个具体的恢复/缓冲机制:
SDK 失忆检测:SDK amnesia is detected when the returned session ID differs from the resume ID. The next turn injects full conversation history unless this is the first
session_initafter a fork.即当 SDK 返回的 sessionId 与请求恢复(resume)的 sessionId 不一致时,判定 SDK 丢失了会话上下文;下一轮将注入完整对话历史——唯一例外是 fork 之后的首个session_init(此时换 ID 是预期行为)。src/providers/claude/runtime/ClaudeSessionManager.ts 的captureSession()正是该检测的状态机落点:hadSession && isDifferent时置needsHistoryRebuild = true,由后续轮次消费(consumeInvalidation()、clearHistoryRebuild()提供一次性消费语义),并注释说明"SDK lost our session context - need to rebuild history on next message"。崩溃恢复只重试一次:Crash recovery retries once only when the previous send produced no chunks.只有当上一次发送一个 chunk 都没产出时才允许重试,且仅一次——已产出部分内容的发送不能盲目重放,否则会制造重复输出。
自动触发的 SDK 轮次:Auto-triggered SDK turns can arrive without a registered handler; they buffer until the result event.SDK 可能自发产生没有已注册处理者的轮次,事件须先缓冲、直到 result 事件再统一处理,避免无主事件丢失或错乱。
MessageChannel 的合流策略:MessageChannel coalesces text-only queued messages and keeps only one queued attachment message.src/providers/claude/runtime/ClaudeMessageChannel.ts 对排队的纯文本消息做合并,且队列中最多保留一条带附件的消息——防止用户快速连发多条消息时向 SDK 灌入冗余消息。
会话文件是树状的:Claude session files are tree-structured. Branch filtering must preserve the canonical branch plus relevant sibling tool results.原生
.jsonltranscript 实际是树结构(fork/rewind 产生分支),分支过滤必须保留规范分支加上其相关的兄弟工具结果,否则工具调用与结果会被切断。实现见 src/providers/claude/history/sdkBranchFilter.ts 与 sdkMessageParsing.ts。上下文窗口选择的多模型歧义:Context-window selection must handle multi-model runs by exact model match first, then family match, and null on ambiguity.一次会话内可能混用多个模型时,上下文窗口参数须先按模型全名精确匹配,再按模型家族匹配,仍歧义则返回 null(不猜)——而不是取第一个命中。
6. 不变量(Invariants):两条不可妥协的底线
文档以两条不变量收束全文:
- 重启或恢复查询必须保持预期的会话绑定,且不得产生重复的可见输出。这是对 3.2 节重启决策与 5 节崩溃恢复/失忆重建的总括约束:任何重建路径(amnesia 历史重注、崩溃重试、配置漂移重启)都不得让用户看到双份的助手消息,也不得把会话绑到错误的对话上。
- Provider 快照是"存活 SDK 状态 → Claudian 持久化 resume 状态"的唯一通路。结合 2 节"执行会话拥有存活快照"的原则,可以推断出完整的状态流:SDK 实时状态 → 执行会话快照 → 持久化 resume 状态,中间不允许旁路直写,这正是"历史服务不得成为第二权威"的持久化侧投影。快照的类型契约位于 src/providers/claude/types/providerState.ts。
7. 小结:一份"约束文档"如何约束住一个 SDK 集成层
回过头看,src/providers/claude/AGENTS.md 的价值不在于描述"做什么",而在于固化"为什么这么做":
- 边界层:SDK 原生类型归一化后才准进入 core/feature(
sdk/、normalization/、stream/三个目录就是边界的物化); - 状态层:执行会话是活状态的唯一权威,history 只读重建,快照是唯一持久化通路(Ownership + Invariants 双锁);
- 生命周期层:持久查询能活则活,六类配置漂移强制重启,失忆/崩溃各有检测与恢复策略(Design Rules + Runtime Gotchas);
- 文件治理层:与 Claude Code 共享
~/.claude与 vault 内.claude/时,Claudian 只拥有 permissions 与 plugin 启用位,MCP 配置彻底让渡给原生 CLI(Storage Rules)。
对这些约束感兴趣的读者可以沿本文给出的路径深入:execution/与会话恢复策略(ClaudeExecutionStrategies.ts)、transcript 树解析(src/providers/claude/history/)、Electron spawn 特殊处理(customSpawn.ts),以及 tests/unit/providers/claude/ 下的大量单元测试——它们共同构成了这套 provider 层行为的可验证依据。
【免费下载链接】claudianAn Obsidian plugin that embeds Claude Code/Codex as an AI collaborator in your vault项目地址: https://gitcode.com/GitHub_Trending/cl/claudian
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考