# cc-linker 自建方案产品设计文档 > 版本:v2.4 > 日期:2026-06-03 > 作者:Claude Code > 更新说明:v2.3 → v2.4 文档与代码对齐:Registry schema v4、飞书命令去掉 /bridge 前缀、user-mapping 结构更新、补充 activity 检测和 provider 管理等已实现功能 --- ## 一、产品概述 ### 1.1 产品定位 cc-linker 是一个**自建的飞书 ↔ Claude Code CLI 桥接工具**,解决飞书和终端之间的会话切换壁垒,让用户可以在飞书中继续 CLI 会话,实现真正的无缝切换。 ### 1.2 核心价值 1. **完整能力**:在飞书中具备 CLI 的完整能力(文件系统访问、命令执行、项目上下文) > 注:完整项目上下文(CLAUDE.md、MCP servers、Memory)适用于两类场景: > 1) 切换到已有 CLI 会话;2) 使用 `/new [cwd] [-- prompt]` 在明确的项目目录下创建新会话。 > Phase 1 **不再**默认在 `~` 下隐式创建新会话,避免用户误以为拿到了完整项目上下文。 2. **无缝切换**:飞书 ↔ 终端之间往返切换同一会话 3. **会话发现**:统一管理所有会话(CLI 发起 + 飞书发起) 4. **最小配置**:仅需飞书 App ID 和 App Secret,其余开箱即用 ### 1.3 与 cc-connect 方案的对比 | 特性 | cc-connect 方案 | 自建方案 | |------|----------------|----------| | 飞书 bot 框架 | 依赖 cc-connect | 自建 | | 会话管理 | 依赖 cc-connect | 自建 | | CLI 能力 | 无(只有消息历史) | 完整(文件系统、命令执行) | | 架构控制 | 受限 | 完全控制 | | 迭代速度 | 依赖第三方 | 自主迭代 | | 工作量 | 小 | 大 | ### 1.4 目标用户 - **Phase 1**:1 个开发者 + 1 台机器 + 1 个飞书私聊 Bot 的个人自用场景 - 使用 Claude Code CLI 进行开发、需要在飞书和终端之间切换会话的工程师 - 希望在飞书中查看和继续本机 CLI 会话的用户 > **范围约束(Phase 1)**: > - 单机单用户私有部署,不面向团队共享 > - 仅支持飞书**私聊**机器人 > - 不支持群聊、多用户隔离、会话共享 --- ## 二、核心功能 ### 2.1 会话管理 #### 2.1.1 会话发现 - **CLI 会话**:扫描 `~/.claude/projects/*/*.jsonl` 文件 - **飞书会话**:由飞书 Bot 在用户执行 `/new [cwd] [-- prompt]` 时创建,映射记录在 `~/.cc-linker/user-mapping.json`(详见 §2.1.2) - **统一索引**:所有会话注册到 `~/.cc-linker/registry.json` #### 2.1.2 用户身份映射 Phase 1 采用**单用户私有模式**。飞书用户(`open_id`)与当前对话目标的映射存储在 `~/.cc-linker/user-mapping.json`。当前目标既可以是已存在的 Claude Code 会话,也可以是”待创建新会话”的工作目录: ```json { “version”: 12, “ownerOpenId”: “ou_xxx”, “entries”: { “ou_xxx”: { “type”: “session”, “sessionUuid”: “8c4b1297-...”, “cwd”: “/Users/xxx/Git/cc-linker”, “createdAt”: “2026-05-05T10:30:00Z”, “casToken”: “abc123”, “lastActiveAt”: “2026-05-05T10:30:00Z”, “defaultProvider”: “claude-sonnet-4-20250514” } } } ``` ```json { “version”: 13, “ownerOpenId”: “ou_xxx”, “entries”: { “ou_xxx”: { “type”: “pending_new_session_claimed”, “cwd”: “/Users/xxx/Git/cc-linker”, “createdAt”: “2026-05-05T10:30:00Z”, “casToken”: “def456”, “claimedByMessageId”: “om_xxx”, “claimedAt”: “2026-05-05T10:30:05Z”, “lastActiveAt”: “2026-05-05T10:30:05Z” } } } ``` ```json { “version”: 12, “ownerOpenId”: “ou_xxx”, “entries”: { “ou_xxx”: { “type”: “pending_new_session”, “cwd”: “/Users/xxx/Git/cc-linker”, “createdAt”: “2026-05-05T10:30:00Z”, “casToken”: “ghi789”, “lastActiveAt”: “2026-05-05T10:30:00Z” } } } ``` **映射规则**: - `ownerOpenId` 默认要求通过配置显式指定(`feishu_bot.owner_open_id` 或环境变量) - 仅在本地开发环境且显式开启 `feishu_bot.allow_auto_bind_owner = true` 时,才允许首次私聊自动绑定 owner - 只有 `ownerOpenId` 的消息会被处理;其他用户收到固定提示:”该 Bot 为个人私有实例,暂不对外开放” - 一个 `open_id` 同时只映射一个当前目标:要么是活跃会话,要么是待创建新会话的 `cwd` - 用户发送 `/switch ` 可切换映射目标 - 用户发送 `/new [cwd]` 时,仅记录”下一次新会话”的目标目录,不立即调用 Claude Code - 用户发送 `/new [cwd] [-- prompt]` 且携带 `prompt` 时,立即创建新会话并执行首条问题 - 若当前目标为 `pending_new_session`,则**只有第一条**普通文本消息可通过 CAS(compare-and-swap)原子抢占创建权,并把映射切换为 `pending_new_session_claimed` - `pending_new_session_claimed` 表示“首条 prompt 已经入队并取得创建权,但真实 `session_id` 尚未稳定落盘”;此状态下的后续普通文本**不得**再次创建第二个新会话,而应返回“新会话正在创建,请稍后重试” - 一旦 Claude 返回 `session_id`,必须立刻用 `type=session` 覆盖 `pending_new_session_claimed` - 若首条 prompt 在**拿到 `session_id` 之前**失败,则将 `pending_new_session_claimed` 回滚为 `pending_new_session`,允许用户重试 - Phase 1 不再因普通文本消息自动创建 `~` 下的新会话 - **关键修正**:消息一旦成功写入 spool,必须同时固化 `target snapshot`(目标快照);后续 Dispatcher 处理该消息时,**只能**使用快照中记录的会话/目录,不能再读取最新 `UserMapping` 重新判定目标,避免 `/switch` 与排队消息产生竞态 - **关键修正**:`pending_new_session` 不是“可重复消费状态”,而是“一次性待消费槽位”;必须通过原子 claim 机制防止用户连续两条普通文本创建出两个新会话 #### 2.1.3 会话状态 ```text 会话状态机: ┌──────────────┐ │ provisioning │ ← 已拿到 session_id,但 jsonl_path 尚未补齐 └──────┬───────┘ │ ├──────────────► ┌─────────┐ │ │ active │ ← 正常可恢复 │ └────┬────┘ │ │ │ ▼ │ ┌─────────────┐ │ │ archived │ ← 超过 30 天未活跃 │ └────┬────────┘ │ │ ▼ ▼ ┌─────────────┐ ┌─────────────┐ │ degraded │ ─────► │ corrupted │ └─────────────┘ └─────────────┘ ``` - `provisioning`:新会话已创建成功,用户可以继续对话,但 `jsonl_path` 还未补齐 - `degraded`:会话主链路可用,但存在待修复问题(如 `jsonl_path` 补齐失败、Registry/Mapping 不一致) - `active`:会话信息完整,可正常 `--resume` - `archived`:长期未活跃,默认仍保留恢复能力 - `corrupted`:确认无法恢复,需用户重新切换或重建 #### 2.1.4 会话元数据 ```typescript interface SessionEntry { origin: 'cli' | 'feishu'; // 来源 cwd: string; // 工作目录 project_name: string | null; // 项目名称 jsonl_path: string | null; // JSONL 文件路径;新会话刚创建时允许暂时为空,后续异步补齐 project_dir: string | null; // 项目目录名(从 JSONL 路径提取) pending_jsonl_resolve?: boolean; // 是否仍需后台补齐 jsonl_path last_error?: string | null; // 最近一次恢复/补偿失败原因 feishu_session_id?: string; // 飞书会话 ID(可选) feishu_user_id?: string; // 飞书用户 open_id(仅 origin=feishu) created_at: string; // 创建时间 last_active: string; // 最后活跃时间 title: string | null; // 标题 message_count: number; // 消息数量 last_message_preview: string; // 最后消息预览(100 字符) last_user_preview?: string; // 最后用户消息预览(≤ 80 字符) last_assistant_preview?: string; // 最后助手回复预览(≤ 80 字符) status?: 'provisioning' | 'active' | 'archived' | 'degraded' | 'corrupted'; lastKnownProvider?: string | null; // 显示用:会话创建时使用的模型 } ``` > **字段精简说明**:相比 v0.1.0(cc-connect 方案),移除了以下字段: > - `source`、`platform`、`owner`、`owner_user_key`(多用户/权限相关,Phase 4 才需要) > - `cc_connect_session_id`、`cc_connect_session_file`(cc-connect 特有) > - `visibility`、`shared_with`(访问控制,Phase 4 才需要) ### 2.2 CLI 命令 #### 2.2.1 `cc-linker init` 初始化 registry 并扫描已有会话。 > **重要说明**:`init`/`sync` 对 Claude 会话的**基础发现能力**来自 JSONL 扫描; > “该会话是否由飞书创建(`origin='feishu'`)”属于**增强元数据**,优先从已有 `registry.json` > 和 `user-mapping.json` 继承。若用户删除了旧 registry 后执行全新初始化,历史飞书来源可能无法 100% > 还原,但不影响会话继续恢复使用。 ```bash cc-linker init # 输出: # ✅ Created ~/.cc-linker/registry.json # 🔍 Scanning for existing sessions... # Found 8 Claude sessions from JSONL # Restored 3 Feishu-origin metadata entries # ✅ Registered 8 sessions total ``` #### 2.2.2 `cc-linker list` 列出所有会话。 ```bash cc-linker list # 输出: # 📋 我的会话(共 8 个) # # 1. Improve README and create... # ID: 8c4b1297 # 终端 | 230条 | 刚刚 | cc-linker # # 2. Review remote debug metrics... # ID: a402c07a # 飞书 | 2601条 | 1 小时前 | traeData # # 3. Analyze bridge recovery path... # ID: c91d3e11 # 降级 | 12条 | 5 分钟前 | cc-linker ``` #### 2.2.3 `cc-linker resume` 恢复指定会话到终端。 ```bash cc-linker resume 8c4b1297 # 输出: # 将执行: cd /Users/xxx/Git/cc-linker && claude --resume 8c4b1297-... ``` **可恢复性规则**: - `active` / `archived`:允许直接恢复 - `provisioning`:先触发一次定向修复(补齐 `jsonl_path`),修复成功后再恢复;若仍失败,返回“会话仍在创建中,请稍后重试或执行 `cc-linker repair `” - `degraded`:先触发一次修复;修复成功则恢复,失败则明确提示降级原因与修复建议 - `corrupted`:拒绝直接恢复,提示用户切换到其他会话或重新创建 > **Phase 1 决策**:`resume` 不直接对 `provisioning/degraded` 盲目执行 `claude --resume`,而是先做一次 repair,避免用户在终端得到模糊错误。 > **飞书侧对齐规则**:`/resume ` 也必须遵循同样的状态语义: > - `active` / `archived`:返回 `cc-linker resume ` > - `provisioning` / `degraded`:返回”建议先修复再恢复”的终端命令或提示,不直接给出可能失败的裸 `claude --resume` > - `corrupted`:直接拒绝,并提示用户先 `/switch` 或重新创建 #### 2.2.4 `cc-linker show` 查看会话详情。 ```bash cc-linker show 8c4b1297 # 输出: # 会话详情 # ──────────────────────────────────── # UUID: 8c4b1297-... # 标题: Improve README and create acceptance guide # 来源: 终端 # 项目: cc-linker # 工作目录: /Users/xxx/Git/cc-linker # 状态: active # 创建时间: 2026/5/3 10:30:00 # 最后活跃: 3 分钟前 # 消息数: 230 ``` #### 2.2.5 `cc-linker sync` 手动同步会话。 ```bash cc-linker sync # 输出: # 🔄 Syncing sessions... # Found 8 Claude sessions from JSONL # Restored 3 Feishu-origin metadata entries # ✅ Sync complete. Total registered: 8 ``` #### 2.2.6 `cc-linker search` 搜索会话。 ```bash cc-linker search "README" # 输出: # 找到 3 个匹配: # 1. Improve README and create acceptance guide # ID: 8c4b1297 | 终端 | 230条 | 刚刚 # 2. Review project docs for developer experience # ID: f27e5dea | 终端 | 570条 | 20 小时前 ``` #### 2.2.7 `cc-linker export` 导出会话为 Markdown/JSON/Text。 ```bash cc-linker export 8c4b1297 --format markdown --output ~/Desktop/session.md # 输出: # 导出会话: Improve README and create acceptance guide # 导出完成: /Users/xxx/Desktop/session.md (230 条消息) ``` #### 2.2.8 `cc-linker clean` 清理无效记录。 ```bash cc-linker clean --dry-run # 输出: # 将清理 2 个会话: # - 028037a3 Untitled # - b21d6d04 Untitled # (dry run,未实际删除) ``` #### 2.2.9 `cc-linker start` 启动飞书 Bot 服务(长连接模式)。 ```bash cc-linker start # 输出: # ✅ Feishu Bot connected via WebSocket # ✅ Registry loaded: 8 sessions # # Press Ctrl+C to stop ``` **选项**: - `--no-feishu`:仅加载 registry 并同步,不连接飞书(调试用,验证 CLI 侧功能) > **Phase 2 规划**:`--daemon` 后台运行模式(PID 文件 + `cc-linker stop` 命令)。 > Phase 1 仅支持前台运行,`Ctrl+C` 停止,进程退出时自动 flush Registry。 #### 2.2.10 `cc-linker status` 查看桥接状态。 ```bash cc-linker status # 输出: # cc-linker Status # ──────────────────────────────────── # Registry: ~/.cc-linker/registry.json # Last modified: 刚刚 # Total sessions: 8 # From CLI: 5 # From Feishu: 3 # Active: 6 # Provisioning: 1 # Degraded: 1 # Archived: 0 # Corrupted: 0 # # Hook: # Claude Code: installed ``` > **Phase 1 说明**:`cc-linker status` 仅展示 registry 和 hook 状态。 > 由于 Bot 在前台运行时 `status` 是另一个进程,无法获取共享运行状态。 > Bot 运行状态由进程本身输出到终端/日志体现。Phase 2 `--daemon` 模式下可通过 PID 文件判断。 ### 2.3 飞书集成 #### 2.3.1 飞书命令 | 命令 | 说明 | |------|------| | `/help` | 显示命令帮助 | | `/list` | 列出所有会话 | | `/listDir` | 交互式浏览和切换工作目录 | | `/new [cwd] [--model <别名>] [-- prompt]` | 指定新会话目录;带 `prompt` 时立即创建,不带时仅记录待创建目录 | | `/switch ` | 切换到指定会话,支持 UUID 前缀或最近一次列表快照序号 | | `/resume ` | 获取**安全恢复建议**,支持 UUID 前缀或最近一次列表快照序号 | | `/model [序号\|别名\|--clear]` | 查看、设置或清除默认模型 | | `/status` | 查看桥接状态 | | `/whoami` | 获取你的 open_id | #### 2.3.2 飞书消息格式 ``` 📋 我的会话(共 8 个) 1. Improve README and create acceptance guide ID: 8c4b1297 终端 | 230条 | 刚刚 | cc-linker 2. Review remote debug metrics diagnosis code ID: a402c07a 飞书 | 2601条 | 1 小时前 | traeData ━━━━━━━━━━━━━━━━ 💡 可使用列表序号作为快捷参数(仅对最近一次 /list 结果有效): /switch 1 /resume 1 ``` > **序号语义(Phase 1 明确)**: > - `/list` 返回的 1/2/3... 是**一次性列表快照序号** > - `/switch 1` / `/resume 1` 中的 `1`,解析为最近一次 `/list` 结果里的第 1 项 > - 若列表快照不存在、已过期(默认 10 分钟)或序号越界,则返回”请重新执行 `/list`” > - 若参数不是纯数字,则按 UUID 前缀处理 **推荐的新会话用法**: ```text /new ~/Git/cc-linker # 仅记录下一次新会话的工作目录,返回"已设置目录,请继续发送第一条消息" /new ~/Git/cc-linker -- 帮我先梳理这个项目的架构 # 创建新会话并立刻执行首条问题,用户一步完成 /new -- 帮我检查默认项目里的 README 是否完整 # 使用配置中的 default_cwd 创建新会话并立刻提问 ``` **命令解析规则**: - `--` 左侧视为 `cwd` - `--` 右侧视为首条 `prompt` - 如果不带 `--`,则整段参数都视为 `cwd` - 如果命令只有 `/new -- `,则 `cwd` 使用 `feishu_bot.default_cwd` - 如果既没有显式 `cwd`,也没有配置 `default_cwd`,则返回引导信息,不创建会话 - `/new [cwd]` 只有目录、没有 `prompt` 时,只记录 `pending_new_session`,不会产生 Claude 会话与费用 #### 2.3.2.5 图片消息支持 飞书 Bot 支持接收和处理图片消息(`message_type === 'image'`)。 **处理流程**: 1. WSClient 回调中检测 `message_type === 'image'` 2. 从 `content` JSON 中提取 `image_key` 3. 调用 `im.v1.messageResource.get` API 下载图片到 `~/.cc-linker/images/` 4. 校验图片大小(默认限制 10MB) 5. 使用 `buildPromptWithImages()` 组装包含图片路径引用的 prompt 6. 图片路径和文本一起写入 SpoolMessage 的 `imagePaths` 字段 7. Claude 通过 SDK/流式/非流式模式接收带图片路径的 prompt,读取本地图片文件进行分析 **prompt 组装格式**: ``` [用户发送了第1张图片: ~/.cc-linker/images/msg_xxx_img_xxx.png] [用户发送了第2张图片: ~/.cc-linker/images/msg_yyy_img_yyy.png] 请查看以上图片文件,然后理解图片内容。 <用户原始文本> ``` **配置**(`[images]` 段): | 配置项 | 默认值 | 说明 | |--------|--------|------| | `enabled` | `true` | 是否启用图片处理 | | `max_size_bytes` | `10485760` (10MB) | 图片大小限制 | | `cleanup_max_age_hours` | `24` | 过期图片清理周期 | **清理策略**: - 每小时执行一次过期图片清理 - 删除超过 `cleanup_max_age_hours` 的图片文件 - 下载失败或超限时返回友好错误提示 #### 2.3.2.6 目录浏览(/listDir) 用户通过 `/listDir` 命令交互式浏览和切换工作目录,方便在飞书端为新建会话选择项目目录。 **用户流程**: ``` 用户: /listDir Bot: [Card] 📂 目录浏览 当前路径: /Users/xxx/Git ⬆️ 上级目录 [→ 进入] ───────────── 📁 cc-linker [→ 进入] 📁 my-project [→ 进入] 用户: *点击 "→ 进入" on cc-linker* Bot: ✅ 已切换到 /Users/xxx/Git/cc-linker 发送消息即可在该目录创建新会话。 ``` **实现要点**: - 解析当前 cwd:优先从活跃会话获取,其次 pending entry,最后 fallback 到 `feishu_bot.default_cwd` - 使用 `readdir()` 读取子目录,过滤隐藏目录(`.` 前缀),按名称排序,最多显示 15 个 - 提供 `⬆️ 上级目录` 按钮支持向上导航 - 点击目录按钮触发 `select_dir` card action,通过 CAS 更新 UserMapping 为 `pending_new_session` - 安全校验:目录必须存在、路径通过 `allowed_roots` / `denied_roots` 检查 **状态转换**: ``` Before: { type: 'session', sessionUuid: 'abc', cwd: '/old' } After: { type: 'pending_new_session', sessionUuid: null, cwd: '/new' } ``` 活跃会话仍保留在 Registry 中,用户可通过 `/switch` 或 `/list` 返回。 #### 2.3.3 一条飞书消息的完整生命周期 **已有会话(用户之前对话过)**: ``` 1. 用户在飞书给 Bot 发消息 "帮我看看这个项目的 README" 2. 飞书服务器通过 WebSocket 推送 im.message.receive_v1 事件 3. WSClient 触发回调函数(⚠️ 必须在 3 秒内完成返回) 3a. 校验 message_type === 'text'(非文本消息直接忽略) 3b. 解析出 message_id、open_id、消息文本 3c. 检查 `message_id` 的持久化幂等状态(`receipts/` + spool 文件),防止超时重推导致重复处理 3d. 消息原子写入 spool,并写入 `receipt` 接收记录,立即返回(< 100ms) 4. Dispatcher 调度该消息 4a. 使用入队时固化的 `target snapshot` 4b. 若 snapshot.kind = session → 取其中的 session_uuid,标记 isNew = false 5. 调用 sessionManager.sendMessage(sessionUuid, text, cwd, isNew=false) 6. Session Manager spawn Claude Code 进程(带 --resume): claude -p "帮我看看这个项目的 README" --resume --output-format json 7. 进程完成后,解析返回 JSON,从 result.result 提取文本回复 8. 调用 API Client 发送回复到飞书 9. 用户在飞书看到 Claude 的回复 ``` **新会话(显式创建 + 携带首问)**: ``` 1. 用户在飞书发送 `/new ~/Git/cc-linker -- 帮我先梳理这个项目的架构` 2. 飞书服务器通过 WebSocket 推送 im.message.receive_v1 事件 3. WSClient 触发回调(同上:校验 + 解析 + 去重 + spool 落盘) 4. Dispatcher 调度该消息 4a. 解析命令得到目标 `cwd` 4a.1 若命令包含 `--`,则取右侧文本作为首条 `prompt` 4b. 校验目录存在且可访问;若未传 `cwd`,则使用 `feishu_bot.default_cwd` 4c. 若仍无有效目录,则返回引导信息,不创建会话 5. 直接使用用户提供的首条 `prompt` 6. 调用 sessionManager.sendMessage(null, prompt, cwd, isNew=true) 7. Session Manager spawn Claude Code 进程(不带 --resume): claude -p "" --output-format json ⚠️ 关键区别:新会话不带 --resume,让 Claude Code 自行创建 session 8. 进程完成后,解析返回 JSON: - result.result → 文本回复 - result.session_id → Claude Code 分配的真实 session UUID 9. 先以 `status=provisioning` 写入 Registry 10. 再更新 UserMapping 11. 按 `session_id` 定向查找 JSONL 文件(短轮询 + 增量扫描),尝试补齐 `jsonl_path` 12. 成功则标记 `active`,失败则标记 `degraded` 13. 清除可能存在的 `pending_new_session_claimed` 14. 后续消息用 --resume 恢复上下文 15. 调用 API Client 发送回复到飞书,直接返回 Claude 对该问题的正式回答 ``` **显式指定目录但暂不提问**: ``` 1. 用户发送 `/new ~/Git/cc-linker` 2. Bot 校验目录存在且可访问 3. 将 UserMapping 更新为: - type = pending_new_session - cwd = /Users/xxx/Git/cc-linker 4. 返回确认文案:"已设置新会话目录,请继续发送第一条消息" 5. 此时尚未调用 Claude Code,因此: - 没有新 session_id - 没有额外费用 - 不会注入 bootstrap prompt 污染上下文 ``` **无活跃会话时的普通文本消息**: ``` 1. 用户直接发送普通文本消息 2. 若 UserMapping 中已有活跃 session_uuid → 按"已有会话"流程处理 3. 若 UserMapping 中为 `pending_new_session`: - 先在 mapping 锁下执行 CAS 抢占 - 抢占成功:将 mapping 改为 `pending_new_session_claimed` - 为该消息固化 `target snapshot = new_session_claim` 4. 若 UserMapping 中已是 `pending_new_session_claimed`: - 不再创建第二个新会话 - 直接回复"新会话正在创建,请稍后重试或执行 /list 查看是否已生成" 5. 若既无活跃映射,也无 `pending_new_session` → 不隐式创建新会话 6. Bot 返回引导: - 使用 /list 查看已有会话 - 使用 /switch 切换到已有会话 - 使用 /new [cwd] [-- prompt] 显式创建新会话 ``` #### 2.3.4 飞书命令列表 | 命令 | 说明 | 是否调用 Claude | |------|------|----------------| | `/help` | 显示命令帮助 | ❌ 静态文本 | | `/list` | 列出所有会话 | ❌ 直接读 Registry | | `/listDir` | 交互式浏览和切换工作目录 | ❌ 读取当前 cwd + readdir | | `/new [cwd] [--model <别名>] [-- prompt]` | 指定新会话目录;带 `prompt` 时立即创建,否则只记录待创建状态 | ✅ 条件调用 | | `/switch ` | 切换到指定会话 | ❌ 更新 UserMapping / 读取 List Snapshot | | `/resume ` | 获取安全恢复建议 | ❌ 直接读 Registry / 读取 List Snapshot | | `/model [序号\|别名\|--clear]` | 查看、设置或清除默认模型 | ❌ 更新 UserMapping | | `/status` | 查看桥接状态 | ❌ 直接读 Registry | | `/whoami` | 获取你的 open_id | ❌ 静态文本 | | 其他消息 | 若已有活跃会话则转发;若为待创建状态则先 claim 再创建;若已处于 `pending_new_session_claimed` 则返回等待提示;否则返回引导 | ✅ 条件调用 Session Manager | > **Phase 1 说明**: > - 处理文本消息(`message_type === 'text'`)和图片消息(`message_type === 'image'`)。 > - 图片自动下载到 `~/.cc-linker/images/`,通过 prompt 注入传递给 Claude。 > - 文件/视频/表情等非文本消息将被忽略。 > - 仅处理飞书**私聊**消息;群聊消息不处理。 --- ## 三、系统架构 ### 3.1 整体架构 > **修正(2026-05-05)**:CLI Proxy 和 Feishu Bot 运行在同一进程中, > 通过内部模块直接调用,无需 HTTP 通信。 > 飞书 Bot 使用**长连接(WebSocket)**接收消息,不需要公网地址。 ``` ┌──────────────────────────────────────────────────────┐ │ 用户设备 │ │ │ │ ┌───────────────┐ ┌────────────────────────┐ │ │ │ Claude Code │ │ 飞书用户 │ │ │ │ CLI │ │ │ │ │ │ session-1 │ │ 飞书 App │ │ │ │ session-2 │ │ │ │ │ └───────┬───────┘ └──────────┬─────────────┘ │ │ │ │ WebSocket │ │ │ SessionStart hook │ im.message.recv │ │ ▼ ▼ │ │ ┌──────────────────────────────────────────────┐ │ │ │ cc-linker 主进程(同一进程) │ │ │ │ │ │ │ │ ┌────────────────────────────┐ │ │ │ │ │ CLI Proxy 模块 │ │ │ │ │ │ - Claude Session Manager │ │ │ │ │ │ - 进程管理 + stdout 解析 │ │ │ │ │ └────────────────────────────┘ │ │ │ │ ▲ 内部调用 │ │ │ │ ┌───────────┴────────────────────┐ │ │ │ │ │ Feishu Bot 模块 │ │ │ │ │ │ - WSClient(长连接收消息) │ │ │ │ │ │ - API Client(发消息到飞书) │ │ │ │ │ │ - 命令解析 + 消息格式化 │ │ │ │ │ └────────────────────────────────┘ │ │ │ │ │ │ │ │ 共享模块:Registry · Scanner · Config │ │ │ └──────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────┐ │ │ │ Session Registry │ │ │ │ ~/.cc-linker/registry.json │ │ │ └──────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────┘ ``` ### 3.2 核心组件 #### 3.2.1 CLI Proxy 模块 **角色**:cc-linker 的"大脑"——负责实际的 AI 处理 **职责**: - 管理 Claude Code 进程的生命周期(每次飞书消息 spawn 新进程)—— CLI 模式 - 或通过 `@anthropic-ai/claude-agent-sdk` SDK 直接调用(SDK 模式,支持权限交互) - 解析 Claude Code 的 stdout 输出,提取文本回复和 session_id - 区分新会话(不带 `--resume`)和已有会话(带 `--resume`) - 跟踪会话状态(idle/busy/error) - 并发控制(同一会话同时只允许一个请求,per-session 锁) - 对新创建的会话执行 `jsonl_path` 定向补齐;若短时间内无法补齐,先以 `provisioning/degraded` 状态注册,再由后台补偿,不阻断用户本次回复 **实现方式**:内部模块,飞书 Bot 直接引用调用(非 HTTP) **核心接口**: ```typescript interface SessionManager { /** * 发送消息到指定会话 * @param sessionId 会话 UUID,新会话时为 null * @param text 用户消息文本 * @param cwd 工作目录 * @param isNew 是否为新会话(决定是否带 --resume) * * 返回文本回复 + 费用 + 耗时 + Claude Code 分配的真实 session_id * 新会话时优先尝试补齐 jsonlPath;若暂时失败,返回 provisioning 状态并交由后台补偿 */ sendMessage( sessionId: string | null, text: string, cwd: string, isNew?: boolean ): Promise<{ response: string; costUsd: number; durationMs: number; sessionId: string; jsonlPath: string | null; sessionStatus: 'active' | 'provisioning' | 'degraded'; }>; // 列出所有活跃会话 listSessions(): ClaudeSession[]; // 清理超时会话记录 cleanupIdleSessions(idleTimeoutMs: number): void; } ``` **并发控制**:同一会话同一时间只允许一个活跃请求。后续消息排队等待前一个完成(带超时),防止多个 Claude Code 进程同时操作同一 JSONL 文件导致乱序或数据损坏。 **会话活跃检测**:当飞书用户发送消息到已有会话时,系统会先检测该会话是否正在被 CLI 端使用: - `SessionActivityCache` 维护每个会话的活跃状态缓存 - 检测方式:OS 信号(CPU/子进程采样)+ Claude Code hooks(可选,100% 准确) - 若检测到 CLI 端正在使用该会话(`isProcessing=true` 且 `confidence !== 'low'`),Bot 会发送"CLI 端忙碌"卡片 - 用户可点击卡片上的"强制发送"按钮,将消息强制推送到会话 - 活跃检测失败时降级为允许发送,不阻断用户 **Provider 管理**:支持多模型切换: - `ProviderManager` 扫描 `~/.claude/providers/` 目录获取手动配置的 provider - 支持 CC Switch 集成,自动检测已安装的 provider - 用户可通过 `/model` 命令查看可用模型、设置默认模型 - 每个用户的 `defaultProvider` 存储在 `user-mapping.json` 中 - 创建新会话时可通过 `/new [cwd] --model <别名>` 指定模型 #### 3.2.2 飞书 Bot 模块 **角色**:cc-linker 的"耳朵和嘴巴"——耳朵接收飞书消息,嘴巴回复 Claude 的回答 **职责**(四个环节): | 环节 | 职责 | 实现 | |------|------|------| | 1. 消息入口 | 接收飞书用户消息 | `WSClient` 长连接,监听 `im.message.receive_v1` | | 2. 消息解析 | 区分命令和普通消息 | `/list` → 读 Registry;其他 → 转发给 Claude | | 3. 消息转发 | 将普通消息转发给 CLI Proxy | 查找 open_id 映射的会话,调用 `sessionManager.sendMessage()` | | 4. 消息回复 | 将 Claude 响应发送回飞书 | `API Client` 调用 `POST /im/v1/messages` | **实现方式**: - **接收消息**:`Lark.WSClient` 长连接模式(WebSocket),无需公网地址 - **发送消息**:`Lark.Client` 调用飞书服务端 REST API - **命令处理**:同一进程内直接引用其他模块(Registry、SessionManager) **为什么用长连接模式**: | | Webhook 模式 | 长连接模式 | |---|-------------|-----------| | 公网 IP | 必须有 | **不需要** | | HTTPS | 必须有 | **不需要** | | 本地开发 | 需要 ngrok | **直接可用** | | 签名验证 | 手动实现 | **SDK 自动处理** | | 消息解密 | 手动 AES-256 | **SDK 自动处理** | | Challenge 验证 | 1s 内返回 | **不需要** | 对于本地运行的 cc-linker,长连接模式是唯一合理的选择。 #### 3.2.3 Message Spool + Dispatcher **角色**:cc-linker 的"调度中枢"——负责消息可靠接收、恢复、分发和并发控制。 **职责**: - WSClient 回调中将消息原子写入本地 spool 目录 - 为每条飞书消息写入持久化 `receipt`,作为跨重启的幂等依据 - 在入队时固化 `target snapshot`(会话 UUID / 新会话 claim / 创建中 / 无目标),消除消息处理阶段读取最新映射带来的竞态 - 启动时扫描未完成的 spool 文件,恢复上次异常退出前尚未处理的消息 - 实现**全局有限并发**(默认 2)+ **会话级串行**(同一会话/同一用户的新会话创建串行) - 处理消息状态流转:`pending -> processing -> replied -> done/failed` - 为飞书出站回复写入持久化 `delivery receipt`,避免“已回复用户但进程在 markDone 前崩溃”导致的重复回复 - 队列过载时拒绝新消息,而不是丢弃旧消息 **关键设计**: - `~/.cc-linker/spool/pending/`:待处理消息 - `~/.cc-linker/spool/processing/`:正在处理 - `~/.cc-linker/spool/replied/`:Claude 结果已成功发回飞书,但主状态尚未 finalize(崩溃恢复关键) - `~/.cc-linker/spool/done/`:已完成成功消息(默认保留 24 小时,且最多保留 1000 个文件) - `~/.cc-linker/spool/failed/`:已失败消息(默认保留 7 天,且最多保留 200 个文件,用于排障) - `~/.cc-linker/spool/receipts/`:轻量幂等收据(默认保留 7 天,防止重启/重推造成重复执行) - `~/.cc-linker/spool/deliveries/`:出站投递收据(记录是否已经成功回飞书、返回了哪些 message_id) - 入队成功标准:**消息文件落盘并 `rename()` 成功** - 去重主键:`message_id` - 新会话串行键:`new:` - 新会话 claim 键:`claim:`(用于把 `pending_new_session` 原子切到 `pending_new_session_claimed`) - 飞书出站幂等键:`uuid = sha256(message_id + ":" + chunk_index)`;同一消息、同一分片重试时必须复用同一个 `uuid` - 幂等原则:**磁盘状态优先于内存缓存**。内存 `Map` 只做热点优化,不作为正确性来源 - 处理原则:对“是否已发送成功但本地未收到响应”的不确定场景,优先依赖稳定 `uuid` 重试,而不是重新生成一条新消息 **归档清理策略(Phase 1 默认)**: - 启动时执行一次清理 - 运行期间每 1 小时执行一次清理 - `done/` 目录按"时间优先 + 数量上限"双阈值清理:超过 24 小时删除;若仍超过 1000 个,则继续删除最旧文件 - `failed/` 目录默认保留 7 天,便于排查偶发失败;若超过 200 个,也按最旧优先删除 - `receipts/` 默认保留 7 天;保留时间应覆盖飞书重推窗口和常见人工重试窗口 - `deliveries/` 默认保留 7 天;用于判定历史消息是否已经成功回飞书 > 从个人使用视角看,`done/` 的价值主要是短期排障和验证消息是否被正确消费,24 小时足够覆盖"今天收不到回复/怀疑重复处理"这类问题;再长只会带来无意义的磁盘堆积。 > 这样即使进程在飞书回调返回后崩溃,重启后也能扫描 spool 恢复未完成消息,避免"只进内存未处理"的消息丢失问题;同时借助 `replied/` 与 `deliveries/`,还能避免“飞书已收到回复但本地尚未来得及 `markDone`”导致的重复回消息问题。 #### 3.2.4 Session Registry **角色**:cc-linker 的"记忆"——统一管理所有会话的元数据索引 **文件**:`~/.cc-linker/registry.json` **数据结构**(当前 schema v4): ```json { "version": 4, "updated_at": "2026-05-05T10:30:00Z", "sessions": { "8c4b1297-...": { "origin": "cli", "title": "Improve README and create acceptance guide", "cwd": "/Users/xxx/Git/cc-linker", "project_name": "cc-linker", "project_dir": "cc-linker", "message_count": 230, "created_at": "2026-05-03T10:30:00Z", "last_active": "2026-05-05T10:30:00Z", "last_message_preview": "好的,我来实现...", "last_user_preview": "帮我看看这个项目的 README", "last_assistant_preview": "好的,我来实现...", "status": "active", "jsonl_path": "/Users/xxx/.claude/projects/-Users-xxx-Git-cc-linker/8c4b1297-....jsonl", "feishu_session_id": null, "feishu_user_id": null, "lastKnownProvider": "claude-sonnet-4-20250514" } } } ``` #### 3.2.5 Runtime State Coordinator(运行态单写者模型) **角色**:cc-linker 的"状态协调器"——约束运行中谁能改写本地状态文件,避免多进程互相覆盖。 **Phase 1 规则**: - `cc-linker start` 成功启动后,获取 `~/.cc-linker/runtime/owner.lock`,成为运行态唯一写者 - 运行中只有主进程允许改写:`registry.json`、`user-mapping.json`、`list snapshot`、`spool/*`、`deliveries/*` - Claude SessionStart hook **不直接**改写 `registry.json`;只写入 `~/.cc-linker/runtime/session-events/` 的发现事件或 scanner dirty flag,由主进程/离线 `sync` 统一归并 - `cc-linker list/show/search/export/status/resume` 属于只读命令,可在服务运行时执行 - `cc-linker init/sync/clean` 属于写命令;若检测到 `owner.lock`,Phase 1 默认拒绝直接写入,并提示“请先停止 `cc-linker start` 后再执行”,避免运行中覆盖主进程状态 - 若服务未运行,则离线 CLI 命令可在获取独占文件锁后执行写操作 **设计目的**: - 避免 Bot 进程与 CLI 写命令同时改写同一份 `registry.json` - 避免 Hook、Scanner、Reconciler 对 `origin='feishu'`、`provisioning/degraded` 等增强元数据相互覆盖 - 将复杂度控制在 Phase 1 可落地范围内,不引入额外 HTTP/RPC 控制面 #### 3.2.6 Startup Reconciler(启动自愈) **角色**:cc-linker 的"修复器"——在进程启动和定时巡检时修复跨文件状态不一致。 **职责**: - 将 `spool/processing/` 中的文件搬回 `pending/`,重新调度 - 若消息已存在 `delivery receipt` 且标记为 `sent`,则直接进入 `done`,不再重复调用 Claude / 飞书 - 扫描 `status=provisioning/degraded` 的会话,重试补齐 `jsonl_path` - 校验 `user-mapping.json` 与 `registry.json` 一致性:若 Mapping 指向不存在会话,则回退为无活跃目标或降级为 `pending_new_session` - 识别“卡住的 `pending_new_session_claimed`”:若 claim 持续超过阈值且不存在对应 `processing/pending/replied/done` 消息,则自动回滚为 `pending_new_session` - 对 `jsonl_path` 缺失但 `session_id` 已存在的会话执行增量扫描修复 - 恢复最近一次 `/list` 的快照索引;过期快照自动清理 - 归并 `runtime/session-events/` 中的 Hook 发现事件,刷新 Registry **设计原则**: - `UserMapping`、`Registry`、`spool` 不是强事务系统,Phase 1 采用"可检测 + 可补偿 + 启动自愈"策略,而不是追求复杂的多文件原子提交 - 任何“用户已收到成功回复”的消息,在恢复时都必须优先保证**不重复回复** **确定性恢复规则(Phase 1 明确)**: 1. 若 `registry` 中存在最近创建的 `provisioning/degraded` 会话,但 `UserMapping` 尚未切到它,且该 owner 当前没有活跃目标,则自动补回该映射 2. 若同时存在多个候选新会话,或无法唯一判定归属,则**不自动切换**,保留会话并提示用户 `/list` 后手动 `/switch` 3. 若消息已写入 `delivery receipt=sent`,即使 `done` 未写成功,也禁止再次调用 Claude 或再次发送飞书消息 ### 3.3 数据流 #### 3.3.1 CLI 会话 → 飞书查看 ``` 用户在终端启动 Claude Code ↓ SessionStart Hook 触发 ↓ 写入 session discovery event / scanner dirty flag ↓ cc-linker 主进程或离线 sync 归并到 Registry ↓ 用户在飞书发送 /list ↓ 飞书 Bot 读取 Registry ↓ 返回会话列表到飞书 ``` #### 3.3.2 飞书继续 CLI 会话 ``` 用户在飞书发送消息 ↓ 飞书 WSClient 接收消息(长连接 WebSocket) ↓ WSClient 回调:解析 + 权限校验 + 持久化幂等检查 + spool 落盘(< 100ms,必须在 3 秒内返回) ↓ Dispatcher 发现可处理消息 ↓ 获取全局并发令牌 + 会话级串行锁 ↓ 使用消息入队时固化的 target snapshot (而不是重新读取最新 UserMapping) ↓ 从 snapshot / Registry 获取 session_uuid 与 cwd ↓ Session Manager spawn Claude Code 进程(每次新进程) ↓ 解析 Claude Code 输出 JSON,提取文本回复 + 费用 ↓ 调用飞书 API 发送回复到飞书 ↓ 写入 delivery receipt,先标记 replied,再 finalize 为 done ``` #### 3.3.3 飞书发起新会话 ``` 用户在飞书发送 `/new [cwd] [-- prompt]` ↓ 飞书 WSClient 接收消息 ↓ WSClient 回调:校验消息类型 + 持久化幂等检查 + spool 落盘(< 100ms,立即返回) ↓ Dispatcher 调度该消息 ↓ 命令处理器解析目标 `cwd` 与可选 `prompt` ↓ 若 `cwd` 无效 → 返回引导,不创建会话 ↓ 若存在 `prompt`: 调用 Session Manager(不带 --resume): claude -p "<用户 prompt>" --output-format json ↓ 从返回 JSON 中提取 session_id(Claude Code 分配的真实 UUID) ↓ 先以 provisioning 状态写入 Registry ↓ 用 session_id 更新 UserMapping:open_id → session_id ↓ 立即尝试补齐 jsonl_path(短轮询 + 增量扫描) ↓ 若补齐成功:标记 active 若暂时失败:标记 degraded,记录待补偿任务 ↓ 后续消息用 --resume 恢复上下文 ↓ 回复到飞书,写入 delivery receipt,再标记 spool 完成 若不存在 `prompt`: 仅把 UserMapping 更新为 `pending_new_session` ↓ 返回"已设置新会话目录,请继续发送第一条消息" ``` #### 3.3.4 终端恢复 CLI 会话 ``` 用户在终端执行 cc-linker resume ↓ 读取 Registry 获取会话信息 ↓ 执行 claude --resume ↓ Claude Code 从 JSONL 加载历史 ↓ 继续会话 ``` --- ## 四、技术方案 ### 4.1 技术栈 - **运行时**:Bun - **CLI 框架**:Commander - **飞书 SDK**:`@larksuiteoapi/node-sdk`(WSClient + Client) - **Claude Code 调用**: - 默认:`@anthropic-ai/claude-agent-sdk` npm 包(SDK 模式,支持权限交互) - 备选:`spawn('claude', ...)` 子进程模式(`--output-format json/stream-json`,通过 `sdk.enabled=false` 切换) - **HTTP 服务**:Bun.serve()(Phase 2,仅用于 CLI Proxy 调试端口 9820) - **进程管理**:child_process - **配置管理**:TOML - **文件锁**:proper-lockfile - **可靠队列**:本地 spool 目录 + 原子写入(temp file + rename) - **语言**:TypeScript ### 4.2 Claude Code 程序化调用方式 #### 4.2.1 调研结论 **重要发现**:Claude Code 提供了强大的程序化调用接口,完全支持我们的场景。 > **验证日期**:2026-05-05 > **验证环境**:Claude Code v2.1.126, macOS Darwin 24.6.0 | 能力 | 支持情况 | 方式 | 验证状态 | |------|---------|------|---------| | 非交互调用 | ✅ | `claude -p "prompt"` | ✅ 已验证 | | 流式 JSON 输出 | ✅ | `--output-format stream-json` | ✅ 已验证 | | **双向流式 IPC** | ✅ | `--input-format stream-json --output-format stream-json` | ✅ 已验证(需配合 `--print --verbose`) | | 会话恢复 | ✅ | `--resume ` / `--continue` | ✅ 已验证 | | JSONL 会话格式 | ✅ | 存储在 `~/.claude/projects//` | ✅ 已验证 | | `--cwd` 参数 | ❌ | **不存在** | ❌ 已证伪,应使用 `spawn()` 的 `cwd` 选项 | | `--output-format json` hook 噪声 | ✅ 无 | stdout 仅输出单行 JSON,无 hook 内容混入 | ✅ 已验证 | #### 4.2.2 程序化调用方案(统一说明) > **⚠️ 关键修正(2026-05-05 验证)**: > 经验证,Claude Code **不支持**"长期运行的双向流式 IPC"。 > `-p` 和 `--print` 是同一个 flag 的短/长形式。在 `--print` 模式下, > 进程在每次收到输入并输出完整响应后会**自动退出**。 > 因此唯一的调用模式是:**每次飞书消息 spawn 新 Claude Code 进程**。 > VS Code / JetBrains 插件也是同样的机制。 **唯一调用模式**:每次消息 spawn 新进程,进程在响应完成后自动退出。 **Phase 1 使用方案**(`--output-format json`,推荐): ```bash claude -p "消息内容" --resume --output-format json ``` 返回单行 JSON,直接 `JSON.parse`,无 hook 噪声,实现最简单。 **Phase 2 可升级方案**(`--output-format stream-json`,已实现): ```bash claude --print --input-format stream-json --output-format stream-json --verbose --resume ``` 返回多行 JSON(每行一个对象),支持流式获取 assistant 消息,但需处理 hook 噪声。 **新会话特殊处理**:首次消息**不带** `--resume`,让 Claude Code 自行创建 session: ```bash # 新会话(不带 --resume) claude -p "消息内容" --output-format json # 返回的 result.session_id 就是 Claude Code 分配的真实 UUID # 后续消息(带 --resume) claude -p "下一条消息" --resume <上面拿到的session_id> --output-format json ``` **两种输出格式对比**: | 对比项 | `--output-format json`(单次调用) | `--output-format stream-json`(流式调用) | |--------|----------------------------------|----------------------------------------| | 输出形式 | 单行 JSON | 多行 JSON(每行一个对象) | | 流式响应 | ❌ 不支持 | ✅ 支持(实时获取 assistant 消息) | | Hook 噪声 | 无 | 有(需过滤 `type: system` 行) | | 实现复杂度 | 低 | 中 | | session_id | `result.session_id` | `result.session_id`(最后一个 JSON 对象) | **`--output-format json` 输出格式**(Phase 1 使用,已验证): 返回单行 JSON,结构如下: ```typescript interface ClaudeJsonOutput { type: 'result'; subtype: 'success' | 'error_max_budget_usd' | 'error_during_execution' | ...; result: string; // 文本回复(直接就是字符串,不是嵌套对象) session_id: string; // Claude Code 分配的会话 UUID total_cost_usd: number; // 本轮费用 duration_ms: number; // 耗时 stop_reason: string | null; // 'end_turn' | 'max_tokens' | ... is_error?: boolean; // 是否出错 errors?: string[]; // 错误详情 num_turns?: number; // 实际轮数(含工具调用) } ``` **关键字段说明**: - `result`:直接是文本回复字符串(不是 `result.message.content`),包含完整的工具调用结果 - `session_id`:Claude Code 分配的真实会话 UUID,**新会话必须用此值更新映射** - `subtype === 'success'` 时正常;其他值时 `is_error` 通常为 `true` **`--output-format stream-json` 输出格式**(流式调用,已验证): 每行一个 JSON 对象,类型包括: | type | 说明 | 是否需要处理 | |------|------|------------| | `system` | hook 事件、会话初始化 | 需过滤(hook 噪声可达数 KB) | | `assistant` | 模型输出(thinking + text) | **需处理**(流式展示) | | `result` | 最终结果(费用、stop_reason) | **需要处理**,标记一轮结束 | **优势**: - ✅ 完整的 CLI 能力(文件系统、命令执行) - ✅ 支持会话恢复(`--resume`) - ✅ 支持流式响应(已实现) - ✅ 支持工具调用 - ✅ 官方支持,稳定可靠 **劣势**: - ⚠️ 每次消息需要 spawn 新进程(~2-5 秒启动延迟) - ⚠️ 流式模式需要处理 hook 噪声 ### 4.2.3 SDK 调用模式(已实现,默认引擎) > **核心差异**:使用 `@anthropic-ai/claude-agent-sdk` npm 包直接调用 Claude Code, > 而非通过 `spawn('claude', ...)` 启动子进程。 > 该模式支持**权限交互**——Claude 在执行工具前暂停,等待用户在飞书中确认。 **调用方式**: ```typescript import { query } from '@anthropic-ai/claude-agent-sdk'; const handler = new PermissionHandler({ allowedTools: ['Read', 'Grep', 'Glob'], timeoutMs: 600_000, }); for await (const message of query({ prompt: text, options: { permissionMode: 'acceptEdits', canUseTool: handler.canUseTool.bind(handler), cwd: expandedCwd, allowedTools: [...], disallowedTools: [...], abortController, ...(sessionId && !isNew ? { resume: sessionId } : {}), ...(settingsPath ? { settings: settingsPath } : {}), }, })) { // message: assistant chunk / result / permission_request } ``` **权限交互机制**: 当 Claude 需要使用工具(Bash、Edit、Write、WebFetch 等)时: 1. SDK 触发 `permissionHandler.onPermissionRequest(prompt)` 2. Bot 在飞书中发送交互式卡片,展示工具名称和操作详情 3. 用户点击 **"允许"** 或 **"拒绝"** 4. SDK 根据用户决定继续或跳过该工具 ```typescript interface PermissionPrompt { toolName: string; // 工具名(Bash / Edit / Write / ...) toolInput: Record; // 工具参数 index: number; // 本轮会话中的序号 isResolved: boolean; // 是否已处理 } type PermissionResult = | { behavior: 'allow'; updatedInput?: Record } | { behavior: 'deny'; message: string }; ``` **自动审批/拒绝规则**: | 规则 | 工具 | 行为 | |------|------|------| | 自动允许 | `AskUserQuestion` | 始终放行(澄清问题不需要审批) | | 白名单 | `Read`, `Grep`, `Glob` 等 | 配置 `claude.allowed_tools`,自动放行 | | 黑名单 | `Bash`, `mcp_*` 等 | 配置 `claude.disallowed_tools`,始终拒绝 | | 超时自动拒绝 | 任意工具 | `sdk.timeout_ms`(默认 10 分钟)后自动拒绝 | **配置**: ```toml [sdk] enabled = true # 默认开启,使用 Agent SDK 直接调用 timeout_ms = 600000 # 权限提示超时(毫秒,默认 10 分钟) permission_mode = "acceptEdits" # 工具权限模式 claude_executable = "claude" # Claude Code 可执行文件路径 [claude] allowed_tools = ["Read", "Grep", "Glob"] # 自动允许的工具列表 disallowed_tools = [] # 始终拒绝的工具列表 ``` **环境变量**: | 环境变量 | 对应配置 | |---------|---------| | `CC_LINKER_SDK_ENABLED` | `sdk.enabled` | **权限卡片交互流程**: ``` 1. Claude 准备调用工具(如 Bash: "rm -rf tmp") 2. SDK 暂停执行,触发 onPermissionRequest 3. Bot 创建飞书权限卡片,展示工具名和操作详情 ┌─────────────────────────────────────┐ │ 🔧 工具权限确认 │ │ 工具: Bash │ │ 操作: rm -rf tmp │ │ │ │ [✅ 允许] [❌ 拒绝] │ └─────────────────────────────────────┘ 4. 用户点击按钮 → Bot 调用 handler.resolveUserDecision(index, approved) 5. SDK 继续或跳过该工具,继续执行 6. 若超时未响应,自动拒绝 ``` **崩溃恢复**: - 权限卡片发送后,`activePermissionHandlers` Map 保存 handler 引用 - 若进程在用户点击前崩溃,handler 仍存活(由 openId 索引) - 用户点击按钮后,handler 将决定传入 SDK,SDK 继续或终止 - 所有权限提示解决后,handler 从 Map 中移除 - 若卡片创建失败,自动拒绝该工具调用 **与 stream-json 模式的互斥**: - `sdk.enabled = true`(默认)时,优先使用 SDK 路径(`handleChatSDK` / `createSessionFromPromptSDK`) - `sdk.enabled = false` 且 `stream.enabled = true` 时,使用 stream-json 路径 - 两者都为 `false` 时,使用 JSON 单次调用路径 - 三种路径共享相同的 Registry、Spool、Card、Delivery 基础设施 ### 4.3 CLI 代理服务实现 #### 4.3.1 会话管理器 **职责**:管理 Claude Code 进程的生命周期,处理新会话创建和已有会话恢复。 **关键设计决策**: 1. `--cwd` 参数不存在,使用 `spawn()` 的 `cwd` 选项 2. `spawn` 的 `cwd` 不会自动展开 `~`,需手动展开为绝对路径 3. 进程在每次消息后自动退出,每次 spawn 新进程 4. 新会话不带 `--resume`,已有会话带 `--resume` 5. `/new [cwd] [-- prompt]` 支持"指定目录 + 可选首问";未提供 `prompt` 时只记录待创建状态,不注入 bootstrap prompt 6. 同一会话同时只允许一个活跃请求(per-session 锁) 7. `pending_new_session` 必须先 CAS 抢占为 `pending_new_session_claimed`,防止连续普通文本创建多个新会话 8. 新会话创建按 `new:` 进行串行,防止用户重复触发 `/new` 9. 全局活跃 Claude 进程数设置上限(默认 2),避免本机资源耗尽 10. 超时控制:活跃检测(5 分钟无输出判定卡死)+ 硬上限(30 分钟兜底) 11. 终止超时/异常进程时必须按**进程组**回收,避免残留 shell / MCP / tool 子进程 12. 消息长度限制:最大 10000 字符,超限拒绝并回复友好提示 13. 新会话获取到 `session_id` 后,优先保证“映射可继续对话”;`jsonl_path` 补齐失败只把会话标记为 `provisioning/degraded` 并进入补偿,不回滚本轮成功回答 14. `sendMessage()` 不负责决定消息目标,目标必须由上游在消息入队时固定并传入,避免运行时读最新映射 ```typescript import { spawn } from 'child_process'; import { join } from 'path'; import os from 'os'; interface ClaudeSession { sessionId: string; cwd: string; status: 'idle' | 'busy' | 'error'; lastActiveAt: Date; processCount: number; } interface SendMessageResult { response: string; costUsd: number; durationMs: number; sessionId: string; // Claude Code 返回的真实 session_id jsonlPath: string | null; // 新会话创建后优先补齐;失败时允许为空并等待后台补偿 sessionStatus: 'active' | 'provisioning' | 'degraded'; } // 超时策略:活跃检测 + 硬上限 // Claude Code 复杂任务可能持续 5-15 分钟(多轮工具调用),固定超时会误杀正常任务 // 因此用"最近一次有输出的时间"来判断进程是否卡死 const STALE_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟无任何 stdout 输出 → 判定卡死 const HARD_TIMEOUT_MS = 30 * 60 * 1000; // 30 分钟硬上限 → 兜底保护 class ClaudeSessionManager { private sessions: Map = new Map(); // per-session 锁:防止同一会话并发请求 private activeRequests = new Map>(); // 全局并发上限:防止同时拉起过多 Claude Code 进程 private readonly maxConcurrentProcesses = 2; private runningProcesses = 0; private processWaiters: Array<() => void> = []; private async resolveJsonlPath(sessionId: string): Promise { // 设计约束: // 1. 按 sessionId 在 ~/.claude/projects/*/.jsonl 中定向查找 // 2. 找不到时做短轮询(例如 200ms / 500ms / 1000ms / 1500ms) // 3. 若仍未找到,则抛错交由上层进入 provisioning/degraded 补偿流程 throw new Error('placeholder'); } /** * 发送消息到会话 * * @param sessionId 会话 UUID,新会话时为 null * @param message 用户消息 * @param cwd 工作目录 * @param isNew 是否为新会话(决定是否带 --resume) */ async sendMessage( sessionId: string | null, message: string, cwd: string, isNew: boolean = false ): Promise { await this.acquireGlobalSlot(); try { // 并发控制:等待同一会话的上一个请求完成 if (sessionId) { while (this.activeRequests.has(sessionId)) { await this.activeRequests.get(sessionId); } let releaseLock: (() => void) | undefined; const lockPromise = new Promise(resolve => { releaseLock = resolve; }); this.activeRequests.set(sessionId, lockPromise); try { return await this._doSendMessage(sessionId, message, cwd, isNew); } finally { this.activeRequests.delete(sessionId); releaseLock?.(); } } // 新会话(sessionId 为 null)不需要锁,因为还没有映射 return await this._doSendMessage(null, message, cwd, isNew); } finally { this.releaseGlobalSlot(); } } private async acquireGlobalSlot(): Promise { if (this.runningProcesses < this.maxConcurrentProcesses) { this.runningProcesses += 1; return; } await new Promise(resolve => this.processWaiters.push(resolve)); this.runningProcesses += 1; } private releaseGlobalSlot(): void { this.runningProcesses = Math.max(0, this.runningProcesses - 1); const waiter = this.processWaiters.shift(); if (waiter) waiter(); } private async _doSendMessage( sessionId: string | null, message: string, cwd: string, isNew: boolean ): Promise { const startTime = Date.now(); // 构建参数 const args = ['-p', message, '--output-format', 'json']; if (!isNew && sessionId) { // 已有会话:恢复历史上下文 args.push('--resume', sessionId); } // 新会话:不带 --resume,让 Claude Code 自行创建 session // ~ 路径展开:spawn 的 cwd 选项不会自动展开 ~ const resolvedCwd = cwd.startsWith('~/') ? join(os.homedir(), cwd.slice(2)) : (cwd === '~' ? os.homedir() : cwd); const proc = spawn('claude', args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: resolvedCwd, // 用 spawn 的 cwd 选项设置工作目录 detached: process.platform !== 'win32', env: { ...process.env, // 保留完整上下文:CLAUDE.md、Memory、MCP servers }, }); return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; let lastOutputAt = Date.now(); let settled = false; const settle = (fn: () => void) => { if (settled) return; settled = true; fn(); }; proc.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); lastOutputAt = Date.now(); // 有输出就刷新活跃时间 }); proc.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); lastOutputAt = Date.now(); }); proc.on('error', (err) => { clearInterval(staleCheck); clearTimeout(hardTimeout); settle(() => reject(new Error(`Claude Code 进程启动失败: ${err.message}`))); }); proc.on('exit', (code) => { clearInterval(staleCheck); clearTimeout(hardTimeout); settle(() => { try { const result = JSON.parse(stdout.trim()); const response = typeof result.result === 'string' ? result.result : ''; const returnedSessionId = result.session_id || sessionId || ''; if (result.is_error && result.errors?.length) { reject(new Error(result.errors.join('; '))); } else { // 文档约束:新会话必须在返回上层前补齐 jsonlPath // 实际实现中,这里应 await this.resolveJsonlPath(returnedSessionId) resolve({ response: response || '(空响应)', costUsd: result.total_cost_usd || 0, durationMs: Date.now() - startTime, sessionId: returnedSessionId, jsonlPath: isNew ? '' : '', }); } } catch (parseErr) { reject(new Error( `解析 Claude 输出失败: ${parseErr}. stderr: ${stderr.slice(-200)}` )); } }); }); // 活跃检测:每 30 秒检查一次,5 分钟无输出 → 判定卡死 const staleCheck = setInterval(() => { if (Date.now() - lastOutputAt > STALE_TIMEOUT_MS) { terminateProcessTree(proc); clearInterval(staleCheck); clearTimeout(hardTimeout); settle(() => reject(new Error('Claude Code 进程卡死(5 分钟无输出)'))); } }, 30_000); // 硬上限:30 分钟兜底,防止极端场景 const hardTimeout = setTimeout(() => { terminateProcessTree(proc); clearInterval(staleCheck); settle(() => reject(new Error('Claude Code 响应超时(30 分钟硬上限)'))); }, HARD_TIMEOUT_MS); }); } listSessions(): ClaudeSession[] { return Array.from(this.sessions.values()); } cleanupIdleSessions(idleTimeoutMs: number = 30 * 60 * 1000): void { const now = Date.now(); for (const [id, session] of this.sessions) { if (now - session.lastActiveAt.getTime() > idleTimeoutMs) { this.sessions.delete(id); } } } } ``` > **实现补充**:`terminateProcessTree(proc)` 在 macOS / Linux 上应优先按进程组发送信号(例如先 `SIGTERM`,超时后再 `SIGKILL`),而不是只杀父进程;启动时也应扫描并清理上次异常退出遗留的孤儿子进程。 #### 4.3.2 HTTP 调试端口(Phase 2) > HTTP API(port 9820)仅用于**外部调试访问**,飞书 Bot 不使用它。 > 飞书 Bot 通过 `import` 直接引用 `ClaudeSessionManager`,无需 HTTP 通信。 > > HTTP 调试端口(port 9820)仅在 Phase 2 实现,用于外部调试访问。 > 实现时需添加简单 token 认证(`config.proxy.token`)。 #### 4.3.3 会话恢复 会话恢复逻辑已内嵌在 `sendMessage` 中: - `isNew=false` 时自动添加 `--resume ` 参数 - `active/archived` 会话直接恢复 - `provisioning/degraded` 会话先触发一次 repair,再决定是否恢复 - Claude Code 从 JSONL 文件读取历史,重建完整上下文 - 无需额外的"恢复"操作,也不需要预先读取 JSONL 文件 ### 4.4 飞书 Bot 实现 #### 4.4.1 飞书长连接模式(已验证,推荐方案) > **验证日期**:2026-05-05 > **来源**:[飞书开放平台官方文档 - 使用长连接接收事件](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-guide-using-long-connection-to-receive-events?lang=zh-CN) **什么是长连接模式**:飞书 SDK 主动与飞书服务器建立 WebSocket 连接,飞书将事件通过该连接推送到客户端。与 webhook 模式(飞书 POST 到开发者服务器)不同,长连接模式无需公网地址、无需 HTTPS、无需 Challenge 验证、SDK 自动处理签名和解密。 **核心机制**: 1. `Lark.WSClient` 启动后自动与飞书服务器建立 WebSocket 连接 2. SDK 自动处理鉴权、心跳、断线重连 3. 注册事件回调函数:`wsClient.start({ "im.message.receive_v1": callback })` 4. 收到消息后 **3 秒内** 处理完成,否则触发超时重推 5. 同一应用多个实例时,消息只会被一个实例接收(集群模式,非广播) **优势**: - ✅ 无需公网地址,本地开发直接可用 - ✅ 无需 HTTPS 证书 - ✅ 无需手动处理签名验证和消息解密 - ✅ 无需实现 Challenge 验证 - ✅ SDK 自动处理断线重连 **⚠️ 3 秒超时约束与解决方案(2026-05-05 验证)**: 飞书官方文档明确:*"长连接模式下接收到消息后,需要在 3 秒内处理完成且不抛出异常,否则会触发超时重推机制"*。 由于 Claude Code 进程启动需要 2-5 秒,加上模型推理时间,回调中直接处理会必然触发超时重推。解决方案: 1. **WSClient 回调只做轻量操作**(< 100ms): - 校验是否为**私聊文本消息** - 校验 `open_id` 是否为 owner(默认要求显式配置,开发态才允许自动绑定) - 检查 `message_id` 的持久化幂等状态(`receipts/` + `pending/processing/replied/done/failed`) - 读取当前 Mapping / List Snapshot,生成 `target snapshot` - 将消息**原子写入 spool 文件并写入 receipt**,成功后立即返回(不抛异常) 2. **Dispatcher + Worker 异步处理**: - 从 spool 目录扫描待处理消息 - 按全局并发限制和会话级串行规则分发 - 调用 `handleCommand()` 或 `handleChat()`(Claude Code 进程由这里 spawn) - 完成后通过 `apiClient.im.v1.message.create()` 发送回复 3. **message_id 去重**: - 以 `receipts/ + pending/processing/replied/done/failed` 为正确性来源 - 内存 `Map` 仅作为热点优化,减少频繁文件系统访问 - 回调中如果发现 `message_id` 已存在于持久化状态 → 说明是重推/重复点击 → 跳过处理 4. **崩溃恢复**: - 进程启动时扫描 `spool/pending`、`spool/processing`、`spool/replied` - 对上次未完成的消息重新入队 - 若存在 `delivery receipt=sent`,则直接 finalize 为 `done` - 只有在回复成功或达到明确失败终态后,才将消息标记为 `done/failed` 核心逻辑(完整实现见 §4.4.2): ```typescript // WSClient 回调:只做轻量操作(< 100ms),立即返回 wsClient.start({ "im.message.receive_v1": async (data) => { const { message, sender } = data.event; if (message.chat_type !== 'p2p') return; // 1. 仅支持私聊 if (message.message_type !== 'text') return; // 2. 校验消息类型 const content = JSON.parse(message.content); const text = content.text?.trim() || ''; if (!text) return; if (recentMessages.has(message.message_id)) return; // 3. 去重 recentMessages.set(message.message_id, Date.now()); await spool.enqueue({ // 4. 先落盘,再返回 messageId: message.message_id, text, openId: sender.sender_id.open_id }); }, }); // Dispatcher:从 spool 取消息,按 "全局有限并发 + 会话级串行" 调度 // 包含:owner 限制、单条超时、错误兜底、崩溃恢复 ``` **配置步骤**: 1. 飞书开放平台 → 创建应用 → 开通机器人 2. 添加权限:`im:message.p2p_msg:readonly`、`im:message:send_as_bot` 3. 事件配置 → 选择**"使用长连接接收事件"** → 添加 `im.message.receive_v1` 4. 版本管理 → 申请发布 #### 4.4.2 核心实现骨架(修订版) > **关键修订**:以下骨架不再采用“处理时读取最新 `UserMapping`”的做法,而是在 **WSClient 回调入队时就固化 `target snapshot`**;同时引入 `delivery receipt` 与 `replied/` 状态,避免飞书已收到回复但本地崩溃后重复发送。 ```typescript type UserMappingEntry = | { type: 'session'; sessionUuid: string; cwd: string; createdAt: string; casToken?: string; lastActiveAt?: string; defaultProvider?: string; } | { type: 'pending_new_session'; cwd: string; createdAt: string; casToken?: string; lastActiveAt?: string; } | { type: 'pending_new_session_claimed'; cwd: string; createdAt: string; casToken?: string; claimedByMessageId?: string; claimedAt?: string; lastActiveAt?: string; }; interface UserMappingFile { version: number; // 映射变更版本号,便于调试和恢复 ownerOpenId?: string; entries: Record; } type TargetSnapshot = | { kind: 'session'; sessionUuid: string; cwd: string; mappingVersion: number } | { kind: 'new_session_claim'; cwd: string; claimMessageId: string; mappingVersion: number } | { kind: 'new_session_creating'; cwd: string; claimedByMessageId: string; mappingVersion: number } | { kind: 'no_target'; mappingVersion: number }; interface SpoolMessage { messageId: string; openId: string; text: string; createdAt: string; targetSnapshot: TargetSnapshot; // 入队时固化,不再依赖后续实时 Mapping } interface DeliveryReceipt { messageId: string; status: 'sending' | 'sent'; requestUuid?: string; sentAt?: string; feishuMessageIds?: string[]; chunks?: Array<{ index: number; status: 'pending' | 'sent'; feishuMessageId?: string; checksum: string; requestUuid: string; }>; } async function onIncomingMessage(event: FeishuMessageEvent): Promise { // 1. 仅处理私聊文本 // 2. owner 校验 // 3. 以 receipts/spool 为准做入站幂等判断 // 4. 若是普通文本且 mapping= pending_new_session,则先 CAS 抢占为 pending_new_session_claimed // 5. 读取当前 UserMapping / List Snapshot,生成 targetSnapshot // - 抢占成功 -> new_session_claim // - 已被别人抢占 -> new_session_creating // 5. 原子写入 spool/pending + receipts/accepted // 6. < 100ms 返回,不在回调内调用 Claude } async function processOne(messageId: string): Promise { const msg = markProcessing(messageId); // 若已存在 delivery receipt=sent,说明上次已经成功回复过飞书 // 直接 finalize 为 done,绝不重复调用 Claude 或重复回消息 if (deliveryAlreadySent(messageId)) { moveToDone(messageId); return; } const response = msg.text.startsWith('/') ? await handleCommand(msg.text, msg.openId, msg.targetSnapshot) : await handleChat(msg.text, msg.openId, msg.targetSnapshot); // 回复前先写 sending,回复成功后写 sent // 同一 message/chunk 的任何重试都必须复用稳定 requestUuid markDeliverySending(messageId); const feishuMessageIds = await replyToFeishu(msg.openId, response, { stableUuid: true }); markDeliverySent(messageId, feishuMessageIds); // 先 moving processing -> replied,保证崩溃恢复时知道“已成功回飞书” moveToReplied(messageId); moveToDone(messageId); } async function handleChat( text: string, openId: string, snapshot: TargetSnapshot ): Promise { switch (snapshot.kind) { case 'session': // 使用 snapshot.sessionUuid,不再读取最新 mapping return await continueExistingSession(snapshot.sessionUuid, snapshot.cwd, text); case 'new_session_claim': // 使用 snapshot.cwd 创建新会话 // 先拿到 session_id,再写 Registry/UserMapping // jsonl_path 若未及时补齐,则 status=provisioning/degraded,后台继续补偿 return await createNewSessionFromSnapshot(openId, snapshot.cwd, text, snapshot.claimMessageId); case 'new_session_creating': return '新会话正在创建,请稍后重试,或执行 /list 查看是否已生成。'; case 'no_target': default: return [ '当前没有活跃会话。', '请先执行以下任一命令:', '1. /list', '2. /switch ', '3. /new ', ].join('\n'); } } async function startupReconcile(): Promise { // 1. processing/ -> pending/ // 2. replied/ + delivery=sent -> done // 3. provisioning/degraded 会话重试补齐 jsonl_path // 4. 修复 UserMapping 与 Registry 不一致 // 5. 恢复最近一次 /list 快照 // 6. 清理过期 done/failed/receipts/deliveries } ``` **实现约束**: - 命令消息(如 `/switch`)只影响**之后**入队的消息,不影响已经入队的旧消息 - `replyToFeishu()` 需要封装飞书限流重试、网络抖动重试和超长消息分片发送 - 对飞书 `im.message.create` 的任何重试,都必须复用稳定 `uuid`,避免网络模糊场景下重复回消息 - 多分片回复必须做**分片级投递记录**;若 Phase 1 实现复杂度过高,则回退为“超过安全阈值只发摘要 + 导出指引”,不允许无状态分片重放 - 新会话创建流程的正确顺序是:`claude 返回 session_id` → `Registry upsert(provisioning)` → `UserMapping 切到 session` → `尝试补齐 jsonl_path` → 成功则 `active`,失败则 `degraded` - `user-mapping.json` 与 `registry.json` 允许短时间不一致,但必须能被 `startupReconcile()` 自动修复 - `pending_new_session_claimed` 必须有超时回滚机制;若长时间未拿到 `session_id` 且关联消息不存在,恢复为 `pending_new_session` ### 4.5 会话管理实现 > **复用评估(2026-05-05)**: > 完全替换 cc-connect 后,以下模块可以复用: | 模块 | 路径 | 复用程度 | 说明 | |------|------|---------|------| | Registry Manager | `src/registry/` | ✅ 保留骨架,需重构 | 文件锁、备份、flush 机制可保留;但类型、默认字段、状态枚举都需重构 | | JSONL Scanner | `src/scanner/jsonl.ts` | ✅ 复用需改 | 移除 `ccConnectUuids` 参数;JSONL 仍用于发现基础会话,但不能单独可靠恢复飞书来源 | | Cache | `src/scanner/cache.ts` | ✅ 完全复用 | mtime 缓存,独立于 cc-connect | | CLI 命令(大部分) | `src/cli/commands/` | ✅ 可复用入口,需改行为 | list, resume, show, sync, status, search, export, clean, init;其中 `resume/status/init/sync/clean` 的状态语义都要调整 | | Utils | `src/utils/` | ✅ 复用需改 | config 移除 `[bridge]` 新增 `[feishu_bot]`、`[runtime]`、`[security]`;paths 新增 `USER_MAPPING_PATH`、`LIST_SNAPSHOT_PATH`、`RUNTIME_OWNER_LOCK_PATH` 等 | | Hook 系统 | `src/hook/` | ✅ 复用需改 | `detectOrigin()` 始终返回 `'cli'`;移除 `--source` 参数 | | Logger / Lock / Validation | `src/utils/` | ✅ 完全复用 | 无需改动 | 已删除的模块: | 模块 | 路径 | 说明 | |------|------|------| | CC-Connect Scanner | `src/scanner/cc-connect.ts` | 已移除,不再需要扫描 cc-connect session 文件 | | Bridge Client | `src/bridge/client.ts` | 已移除,不再需要调用 cc-connect Bridge API | | Feishu Command | `src/cli/commands/feishu-cmd.ts` | 已移除,所有飞书命令在 Bot 进程内直接处理 | 需要新增的模块(Phase 2+): | 模块 | 路径 | 说明 | |------|------|------| | Provider Manager | `src/utils/providers.ts` | 多模型 provider 管理:扫描 `~/.claude/providers/` + CC Switch 集成 | 已完成的新增模块: | 模块 | 路径 | 说明 | |------|------|------| | Claude Session Manager | `src/proxy/session.ts` | Claude Code 进程管理 + SDK/流式/非流式模式 + 权限交互 | | Feishu Bot | `src/feishu/bot.ts` | WSClient 事件处理 + 命令路由 + 消息回复 + 权限卡片回调 | | User Mapping | `src/feishu/mapping.ts` | open_id → session_uuid 映射管理 + CAS 原子更新 | | List Snapshot | `src/feishu/list-snapshot.ts` | `/list` 最近一次结果的序号快照管理 | | Runtime State Coordinator | `src/runtime/state-coordinator.ts` | 运行态唯一写者锁 + Hook 发现事件归并 | | Permission Handler | `src/proxy/permission-handler.ts` | 工具权限管理:白名单/黑名单/超时自动拒绝 + 用户决策回调 | | Card Updater | `src/feishu/card-updater.ts` | 流式卡片 + 权限卡片的发送与更新 | | Stream Parser | `src/proxy/stream-parser.ts` | stream-json 输出解析,过滤 hook 噪声 | | Stream Adapter | `src/proxy/stream-adapter.ts` | SDK 消息流 → StreamChunk 适配 | | Image Processor | `src/feishu/image.ts` | 飞书图片下载、prompt 注入、过期清理 | | Startup Reconciler | `src/runtime/reconciler.ts` | 启动自愈:恢复卡住消息、回滚超时 claim、补齐 jsonl_path | --- ## 五、部署方案 ### 5.1 本地部署 ```bash # 安装 git clone https://github.com/yujuntea/cc-linker.git cd cc-linker bun install bun run build # 初始化 cc-linker init # 启动服务 cc-linker start # 输出: # ✅ Feishu Bot connected via WebSocket # ✅ Registry loaded: 8 sessions ``` ### 5.2 配置文件 `~/.cc-linker/config.toml`: ```toml [general] log_level = "info" # 日志级别: debug | info | warn | error log_path = "~/.cc-linker/cc-linker.log" # 日志文件路径,null 则输出到 stdout claude_bin = "claude" # Claude Code CLI 路径(如果不是默认 PATH) [feishu_bot] # 使用长连接模式(WebSocket),无需公网地址 app_id = "your-app-id" app_secret = "your-app-secret" # Phase 1 为单用户私有模式。生产环境建议显式填写 owner_open_id owner_open_id = "ou_xxx" # 仅本地开发时可开启自动绑定;默认关闭,避免被首个私聊用户误绑定 allow_auto_bind_owner = false # /new 未传 cwd 时使用;建议配置为常用项目目录 default_cwd = "~/Git/your-main-project" [runtime] stale_timeout_ms = 300000 # 进程无输出判定卡死超时(5 分钟) hard_timeout_ms = 1800000 # 硬上限兜底(30 分钟) max_concurrent_sessions = 5 # 全局最大并发 Claude 进程数 idle_timeout_ms = 1800000 # 空闲会话超时(30 分钟) session_lock_timeout_ms = 600000 # 会话锁获取超时(10 分钟) [queue] spool_dir = "~/.cc-linker/spool" max_pending = 100 worker_concurrency = 5 # 默认并发 worker 数 done_retention_hours = 24 done_max_files = 1000 failed_retention_days = 7 failed_max_files = 200 delivery_retention_days = 7 list_snapshot_ttl_minutes = 10 [cli_proxy] # HTTP 调试端口(Phase 2,仅用于外部调试访问,内部调用直接引用模块) port = 9820 host = "localhost" # 单条消息兜底超时(毫秒,实际超时由活跃检测控制) timeout_ms = 1800000 # 调试端口认证 token(Phase 2) token = "" [scanner] max_file_size = 104857600 # 100MB incremental = true # 增量扫描(基于 mtime 缓存) [sdk] # Agent SDK 模式(默认开启,替代 spawn 子进程) # 支持权限交互——Claude 执行工具前暂停,等待用户在飞书中确认 enabled = true # 默认 true timeout_ms = 600000 # 权限提示超时(10 分钟) permission_mode = "acceptEdits" # 工具权限模式 claude_executable = "claude" # Claude Code 可执行文件路径 [claude] # 工具权限控制(SDK 模式和 CLI 模式共用) allowed_tools = ["Read", "Grep", "Glob"] # 自动允许的工具 disallowed_tools = [] # 始终拒绝的工具 [images] # 图片消息处理 enabled = true # 默认 true,自动下载飞书图片 max_size_bytes = 10485760 # 图片大小限制(默认 10MB) cleanup_max_age_hours = 24 # 过期图片清理周期(默认 24 小时) [security] # /new 只能落在这些目录下;空数组表示不限制(不推荐) allowed_roots = ["~/Git", "~/Workspace"] # 明确禁止的目录 denied_roots = ["~", "/", "~/Downloads", "~/Desktop"] # 高风险目录或危险动作是否要求二次确认(Phase 1 先预留开关) confirm_risky_actions = true ``` **环境变量覆盖**(优先级高于配置文件): | 环境变量 | 对应配置项 | |---------|-----------| | `CC_LINKER_LOG_LEVEL` | `general.log_level` | | `CC_LINKER_LOG_PATH` | `general.log_path` | | `CC_LINKER_FEISHU_APP_ID` | `feishu_bot.app_id` | | `CC_LINKER_FEISHU_APP_SECRET` | `feishu_bot.app_secret` | | `CC_LINKER_FEISHU_OWNER_OPEN_ID` | `feishu_bot.owner_open_id` | | `CC_LINKER_FEISHU_DEFAULT_CWD` | `feishu_bot.default_cwd` | | `CC_LINKER_MAX_CONCURRENT_SESSIONS` | `runtime.max_concurrent_sessions` | | `CC_LINKER_SESSION_LOCK_TIMEOUT_MS` | `runtime.session_lock_timeout_ms` | | `CC_LINKER_SDK_ENABLED` | `sdk.enabled` | | `CC_LINKER_SDK_PERMISSION_MODE` | `sdk.permission_mode` | | `CC_LINKER_SDK_TIMEOUT_MS` | `sdk.timeout_ms` | | `CC_LINKER_SDK_CLAUDE_EXECUTABLE` | `sdk.claude_executable` | | `CC_LINKER_STREAM_ENABLED` | `stream.enabled` | | `CC_LINKER_STREAM_THROTTLE_MS` | `stream.throttle_ms` | | `CC_LINKER_STREAM_SHOW_THINKING` | `stream.show_thinking` | | `CC_LINKER_STREAM_MAX_CARD_BYTES` | `stream.max_card_bytes` | | `CC_LINKER_STREAM_FALLBACK_TO_TEXT` | `stream.fallback_to_text` | | `CC_LINKER_CLAUDE_PERMISSION_MODE` | `claude.permission_mode` | | `CC_LINKER_CLAUDE_ALLOWED_TOOLS` | `claude.allowed_tools`(逗号分隔) | | `CC_LINKER_CLAUDE_DISALLOWED_TOOLS` | `claude.disallowed_tools`(逗号分隔) | | `CC_LINKER_IMAGES_ENABLED` | `images.enabled` | | `CC_LINKER_IMAGES_MAX_SIZE` | `images.max_size_bytes` | | `CC_LINKER_IMAGES_CLEANUP_HOURS` | `images.cleanup_max_age_hours` | ### 5.3 飞书配置 > **Phase 1 部署范围**:仅支持 **1 个开发者 + 1 台机器 + 1 个飞书私聊 Bot** 的个人私有部署。 > 不支持群聊,不支持多人共享同一个实例。 1. 在飞书开放平台创建应用(**企业自建应用**) 2. 开通**机器人**能力 3. 获取 App ID 和 App Secret 4. 在 `~/.cc-linker/config.toml` 中显式配置 `feishu_bot.owner_open_id` 5. 添加权限: - `im:message.p2p_msg:readonly`(读取私聊消息) - `im:message:send_as_bot`(以机器人身份发送消息) 6. 事件配置 → 选择**"使用长连接接收事件"** → 添加 `im.message.receive_v1` 7. 版本管理 → 申请发布 > **开发环境**:使用长连接模式,无需公网地址、无需 ngrok、无需 HTTPS > `cc-linker start` 后,WSClient 自动通过 WebSocket 连接飞书服务器 > **Phase 1 限制**:仅处理私聊文本消息;群聊消息与非文本消息不处理 --- ## 六、开发计划 ### 6.1 Phase 0:模型收敛与恢复机制设计(3-5 天) - [ ] 收敛 Registry / UserMapping / Spool 三套状态模型 - [ ] 定义 `target snapshot`、`delivery receipt`、`provisioning/degraded` 会话状态 - [ ] 定义 `pending_new_session -> pending_new_session_claimed -> session` 的一次性消费语义 - [ ] 定义运行态单写者模型与 Hook/CLI 离线写入边界 - [ ] 明确启动自愈策略(processing 恢复、已发送未 finalize 恢复、jsonl_path 补偿) - [ ] 输出实现顺序和故障注入测试清单 ### 6.2 Phase 1:核心功能(4-6 周) > **修正**:由原来的 3-4 周调整为 4-6 周。原因不是功能点变多,而是要把“可跑通”提升到“可恢复、可补偿、不重复回复”的可用状态。 - [x] 调研 Claude Code SDK/API(已完成,2026-05-05 验证) - [x] 调研飞书长连接模式(已完成,WSClient 方案确认) - [x] 验证 `@larksuiteoapi/node-sdk` 在 Bun 下的兼容性(已完成,2026-05-05) - [x] 验证 `result.session_id` 字段存在性(已完成,2026-05-05) - [x] 验证 `--resume` 不存在 UUID 的行为(已完成,2026-05-05:返回错误) - [ ] 移除 cc-connect 相关代码(bridge/client.ts, scanner/cc-connect.ts, feishu-cmd.ts) - [ ] 更新类型定义和配置(types.ts, config.ts, paths.ts) - [ ] 实现 Claude Session Manager(进程管理 + JSON 解析器 + `~` 路径展开 + 全局并发上限 + jsonl_path 补偿)(1-1.5 周) - [ ] 实现飞书 Bot 模块(WSClient + API Client + 命令处理 + 单用户 owner 绑定)(0.5-1 周) - [ ] 实现本地可靠 spool 队列(原子落盘 + 目标快照 + 入站/出站幂等 + Dispatcher)(1 周) - [ ] 实现 `pending_new_session` claim 机制(CAS 抢占 + claimed 超时回滚)(0.5 周) - [ ] 实现 Startup Reconciler(processing 恢复、delivery 恢复、mapping/registry 修复)(0.5 周) - [ ] 实现 cc-linker start 命令 + 优雅停机(0.5 周) - [ ] 实现基本飞书命令(list, new, switch, resume, status),其中 `new` 支持可选首条 prompt(0.5 周) - [ ] 端到端测试:飞书发送消息 → spool 落盘 → Dispatcher 调度 → Claude Code 处理 → 飞书回复(0.5 周) - [ ] 故障注入测试:`/switch` 竞态、首条 prompt 连续发送、飞书发送成功后崩溃、jsonl_path 延迟出现、重启恢复(0.5 周) - [ ] 故障注入测试:多分片回复部分成功后崩溃、`resume` 修复失败回退、列表快照过期(0.5 周) ### 6.3 Phase 2:完善功能(1-2 周) CLI 侧(已实现,直接复用): - [x] 会话搜索 `cc-linker search` - [x] 会话导出 `cc-linker export` - [x] 会话清理 `cc-linker clean` - [x] 状态查看 `cc-linker status` 飞书侧(需新增): - [ ] `/search <关键词>` —— 飞书端会话搜索 - [x] 错误处理优化(飞书 API 限流重试、进程崩溃友好提示)—— 已前移到 Phase 1 - [x] 流式响应到飞书(升级为 `--output-format stream-json`,使用卡片消息实时更新)—— **§4.6 详细设计已完成,已实现** - [x] Stream Parser 实现(`src/proxy/stream-parser.ts`) - [x] Card Updater 实现(`src/feishu/card-updater.ts`) - [x] Session Manager 流式方法(`sendStreamingMessage`) - [x] Feishu Bot 流式集成 - [x] delivery receipt 扩展 - [ ] 端到端测试 + 故障注入 - [x] SDK 模式与权限交互(`@anthropic-ai/claude-agent-sdk`,默认执行引擎)—— **§4.2.3 详细设计已完成,已实现** - [x] `ClaudeSessionManager.sendSDKMessage()` 实现 - [x] Permission Handler 实现(`src/proxy/permission-handler.ts`) - [x] 权限卡片创建与更新(`CardUpdater.createPermissionCard`) - [x] Feishu Bot 权限卡片回调处理 - [x] 白名单/黑名单/超时自动拒绝配置 - [x] 崩溃恢复:保留 `activePermissionHandlers` 直到所有权限提示解决 - [ ] 端到端测试 + 故障注入 - [ ] HTTP 调试端口(port 9820) - [ ] `--daemon` 后台运行模式 + `cc-linker stop` 命令(基础实现已有,需完善日志轮转) - [ ] 日志轮转(文件大小切割 + 旧日志清理) ### 6.4 Phase 3:优化体验(1 周) - [ ] 消息格式优化(卡片消息、快捷操作按钮) - [ ] 性能优化(进程预热、缓存优化) - [ ] 文档完善 - [ ] 测试完善 ### 6.5 Phase 4:高级功能(2-3 周) - [ ] 多用户支持 - [ ] 权限管理 - [ ] 会话共享 - [ ] 会话协作 --- ## 七、风险与挑战 ### 7.1 技术风险 #### 7.1.1 进程管理模式(已实现 → 低风险) > **结论**:每次消息 spawn 新进程是唯一的调用模式(已验证)。 > SDK 模式(默认)使用 `@anthropic-ai/claude-agent-sdk` 直接调用; > 备选方案使用 `--output-format json` 或 `--output-format stream-json`,无 hook 噪声问题。 **影响**:用户体验上会有 2-5 秒的启动延迟,飞书端 Phase 1 无法实时看到"正在输入"状态。 **缓解措施**: 1. 默认使用 SDK 模式(`@anthropic-ai/claude-agent-sdk`),支持流式权限交互 2. 备选 `--output-format json`(单行 JSON,无噪声,直接 JSON.parse) 3. 备选 `--output-format stream-json` 实现流式响应到飞书卡片消息(已实现) #### 7.1.2 会话状态同步(低风险 → 无风险) > **修正**:这不是风险。`--resume` 参数已经验证可以正确恢复会话上下文, > Claude Code 会自动从 JSONL 文件读取历史。完整的能力(文件系统、命令执行)都可用。 #### 7.1.3 并发处理与路由竞态(中风险) > **修正**:仅有 per-session 锁还不够。如果只有单个串行消费者,会导致一个慢请求阻塞所有请求; > 如果完全放开并发,又可能把本机资源耗尽。除此之外,若在处理时读取最新 `UserMapping`,还会导致消息被路由到错误会话。Phase 1 改为: > **全局有限并发(默认 2)+ 会话级串行(同一会话/同一用户新会话创建串行)**。 **缓解措施**: 1. Dispatcher 只在可用 worker 存在时分发新消息 2. 消息入队时固化 `target snapshot`,后续处理只读 snapshot,不读最新 Mapping 3. `session_uuid` 作为串行键;新会话创建时使用 `new:` 作为临时串行键 4. `pending_new_session` 必须通过 CAS 抢占为 `pending_new_session_claimed`;后续普通文本不得再创建第二个新会话 5. 若用户首条 prompt 已 claim 但会话尚未物化,后续消息只返回“创建中”提示,不参与竞争 6. Claude 进程数设置上限,避免本机同时拉起过多子进程 7. 队列积压时拒绝新消息,不丢弃旧消息 #### 7.1.4 Registry / Mapping 跨文件一致性(中风险) **风险**: 1. 新会话创建成功后,`registry.json` 与 `user-mapping.json` 分开写,可能出现半完成状态 2. 进程在两个文件之间崩溃时,用户后续消息可能找不到刚刚创建的会话 3. 若 Bot、CLI 命令、Hook 同时改写本地状态文件,运行中可能出现互相覆盖 **缓解措施**: 1. 新会话先写 Registry(`status=provisioning`),再切换 UserMapping,最后尝试补齐 `jsonl_path` 2. 启动时执行 Reconciler,修复 Mapping 指向不存在会话、会话缺失 `jsonl_path` 等不一致 3. 运行态采用单写者模型:主进程持有 `owner.lock` 时,写命令拒绝离线直写 4. Hook 不直接写 `registry.json`,只写 session discovery event,由主进程统一归并 5. 允许短时不一致,但必须做到“可检测、可补偿、可恢复” #### 7.1.5 进程管理(低风险 → 中风险 → 上调) > **修正**:从"低风险"上调为"中风险"。虽然不再需要管理长期运行的进程, > 但需要处理: > 1. 进程启动失败(claude 命令不可用、权限不足) > 2. 进程超时(API 响应慢、模型处理时间长) > 3. 进程异常退出(内存不足、系统信号) > 4. 并发进程数控制(防止资源耗尽) **缓解措施**: 1. 实现进程健康检查(`claude --version`) 2. 超时控制:活跃检测(5 分钟无 stdout 输出 → 判定卡死)+ 30 分钟硬上限兜底 - 不用固定超时——Claude Code 复杂任务(多轮工具调用)可能持续 5-15 分钟,固定超时会误杀 - 通过 `lastOutputAt` 时间戳跟踪进程是否还在产出内容 3. 超时/异常终止时按**进程组**回收,避免残留 shell / MCP / tool 子进程 4. 启动时扫描并清理上次异常退出遗留的孤儿子进程 5. 进程数限制(每个会话最多 1 个活跃进程,per-session 锁) 6. 优雅降级(进程失败时回复飞书用户友好错误提示) #### 7.1.6 飞书长连接 3 秒超时与消息可靠性(中风险 → 已收敛) > **修正**:仅靠"内存队列 + 立即返回"并不安全。若进程在回调返回后崩溃,消息会丢失。 > Phase 1 改为:回调中先将消息原子写入本地 spool,再返回;启动时扫描 spool 做崩溃恢复。 **缓解措施**: 1. 回调只做:私聊校验、owner 校验、持久化幂等检查、生成 `target snapshot`、spool 落盘 2. 落盘成功并写入 `receipt` 后,才视为入队成功 3. 去重以 `receipts/ + pending/processing/replied/done/failed` 为准,内存缓存只做热点优化 4. 重启后自动恢复 `pending/processing` 中未完成消息 5. 处理成功或明确失败后再更新 `receipt` 终态 #### 7.1.7 出站回复重复发送(中风险) **风险**: 1. 飞书消息发送成功后,如果进程在 `markDone` 前崩溃,重启后同一消息可能再次执行并重复回复 2. 飞书接口在“服务端可能已接收,但客户端未拿到响应”的网络模糊场景下,简单重试可能产生重复消息 **缓解措施**: 1. 引入 `delivery receipt`,记录 `sending/sent` 2. 对 `im.message.create` 使用稳定的客户端 `uuid`,同一消息/同一分片的任何重试都复用该 `uuid` 3. 先写 `delivery receipt`,再把 spool 从 `processing` 迁移到 `replied/done` 4. 启动恢复时若发现 `delivery receipt=sent`,直接 finalize 为 `done`,不再调用 Claude / 飞书 #### 7.1.8 多分片回复重复/部分重复(中风险) **风险**: 1. 长回复被拆成多条飞书消息时,可能只成功发送前几段就崩溃 2. 恢复时如果只有整体 `sent` 标记,无法判断哪些分片已经送达 **缓解措施**: 1. `delivery receipt` 细化到分片级,记录每一片的 `index/status/feishuMessageId/checksum` 2. Phase 1 若不实现分片级幂等,则超过安全阈值时降级为“摘要 + 导出指引”,不做无状态长文分片 3. 恢复时只重试尚未标记 `sent` 的分片 #### 7.1.9 WebSocket 长连接稳定性(低风险) **风险**: 1. WebSocket 连接可能因网络问题断开 2. 飞书服务器可能主动断开连接(超时、维护) **缓解措施**: 1. SDK 自动处理断线重连(`WSClient` 内置重连逻辑) 2. 断线期间的消息不会丢失——飞书服务器会在连接恢复后重新推送(超时重推机制) 3. 监控 `onError` 事件并记录日志 #### 7.1.10 `@larksuiteoapi/node-sdk` 兼容性(已验证,无风险) > **验证日期**:2026-05-05。在 Bun v1.3.13 下安装 SDK v1.62.1,import/WSClient 实例化/Client 实例化均正常,无需降级方案。 #### 7.1.11 Claude Code Hook 噪声(Phase 1 已验证 → 无风险;Phase 2 流式需处理) > **验证(2026-05-05)**:Phase 1 使用 `--output-format json`(非 `--verbose`), > stdout 仅输出干净的单行 JSON,**无 hook 内容混入**。无需任何过滤逻辑。 > > Phase 2 升级到 `--output-format stream-json` 时需处理 hook 噪声: > 1. 流式解析器过滤 `type: system` 行 > 2. 使用 `--exclude-dynamic-system-prompt-sections` 减少 hook 输出 #### 7.1.12 飞书流式卡片更新(已实现,中风险) > **状态更新**:流式响应已实现并集成到 CardUpdater。以下风险仍需关注。 **风险**: 1. 飞书 patch API 限制 5 QPS / message,stream-json 输出可能远超此频率 2. 卡片 body 最大 30KB,复杂回复可能超限 3. patch 请求失败(网络/限流)时,用户体验可能卡顿 **缓解措施**: 1. 实现 `ThrottledCardUpdater`,默认 1500ms 节流间隔 2. 超过 `max_card_bytes`(25KB)时自动降级为普通文本分片发送 3. patch 失败指数退避重试,最终降级为文本 4. delivery receipt 记录 stream 状态,崩溃恢复时避免重复发送 #### 7.1.13 stream-json 输出格式稳定性(已实现,低风险) **风险**:Claude Code 的 `stream-json` 输出格式可能随版本更新而变化。 **缓解措施**: 1. Stream Parser 做防御性解析:未知 type 直接跳过,不崩溃 2. 锁定 Claude Code 版本或监听变更日志 3. 单元测试覆盖 stream-json 解析的边界情况 #### 7.1.14 SDK 模式与权限交互(已实现,中风险) > **状态更新**:SDK 模式已作为默认执行引擎实现并启用。以下风险仍需关注。 **风险**: 1. `@anthropic-ai/claude-agent-sdk` npm 包 API 可能随 Claude Code 版本变化 2. 权限卡片超时或用户不响应时,Claude 进程会挂起等待 3. SDK 模式与 spawn 模式的行为差异可能导致边界不一致 4. 权限卡片创建失败时需要自动拒绝,不能阻塞 Claude 进程 **缓解措施**: 1. 锁定 `@anthropic-ai/claude-agent-sdk` 版本,监听变更日志 2. 权限提示设置 `permission_timeout_ms` 超时(默认 10 分钟),超时自动拒绝 3. 权限卡片创建失败时自动拒绝该工具调用,不阻塞主流程 4. SDK 模式和 spawn 模式共享相同的 Registry、Spool、Delivery 基础设施,保证行为一致性 5. `sdk.enabled` 配置开关,可随时回退到 spawn 模式 ### 7.2 产品风险 #### 7.2.1 用户体验(中风险) **风险**:飞书消息格式可能不如终端友好,操作可能不够直观。 **影响**:用户可能不愿意使用飞书端。 **缓解措施**: 1. 优化消息格式,使用飞书卡片消息 2. 提供快捷操作按钮 3. 支持数字索引快速操作 4. `/new [cwd] [-- prompt]` 支持"创建会话 + 首次提问"一步完成,减少飞书端来回操作 5. 超长回复按分片发送;超过安全阈值时回退为摘要 + 终端导出指引 #### 7.2.2 性能(中风险) **风险**:飞书消息可能有延迟,Claude API 调用可能较慢。 **影响**:用户体验下降。 **缓解措施**: 1. 实现消息队列,异步处理请求 2. 使用缓存减少重复计算 3. 优化 Claude API 调用参数 #### 7.2.3 稳定性(中风险) **风险**:服务可能不稳定,出现崩溃或无响应。 **影响**:用户无法使用。 **缓解措施**: 1. 实现健康检查和自动重启 2. 添加监控和告警机制 3. 实现优雅降级策略 #### 7.2.4 产品边界误解(中风险) **风险**:如果不明确说明 Phase 1 仅支持个人私有部署,读者会误以为该方案已支持团队共享、多用户隔离和群聊。 **影响**:错误的产品预期会导致后续权限、安全和交互设计反复返工。 **缓解措施**: 1. 在产品概述、部署方案、飞书配置中明确写出"1 个开发者 + 1 台机器 + 1 个私聊 Bot" 2. Phase 1 在运行时绑定唯一 `ownerOpenId` 3. 明确排除群聊、多用户、共享会话 ### 7.3 依赖风险 #### 7.3.1 Claude Code CLI 更新(低风险) **风险**:Claude Code CLI 可能更新,导致 `--input-format stream-json` 参数变化。 **影响**:需要适配新版本。 **缓解措施**: 1. 锁定 CLI 版本 2. 关注 CLI 更新日志 3. 及时适配新版本 #### 7.3.2 飞书 API 变化(低风险) **风险**:飞书 API 可能变化,导致集成失效。 **影响**:需要适配新版本。 **缓解措施**: 1. 使用官方 SDK 2. 关注 API 更新日志 3. 及时适配新版本 --- ## 八、附录 ### 8.1 参考文档 - [飞书开放平台文档](https://open.feishu.cn/document/) - [Claude Code 文档](https://docs.anthropic.com/claude-code) - [Claude Code 程序化调用](https://code.claude.com/docs/en/cli) - [Bun 文档](https://bun.sh/docs) ### 8.2 术语表 | 术语 | 说明 | |------|------| | CLI 会话 | 通过 Claude Code CLI 创建的会话 | | 飞书会话 | 通过飞书 Bot 创建的会话 | | CLI 代理服务 | 代理 Claude Code CLI 的模块(内部调用,无独立端口) | | Registry | 统一会话索引(`~/.cc-linker/registry.json`) | | JSONL | Claude Code 会话历史文件格式 | | 单次调用 | `claude -p --output-format json`,每次消息 spawn 新进程 | | 流式调用 | `claude --print --input/output-format stream-json`,每次消息 spawn 新进程 | | 长连接模式 | 飞书 WSClient 通过 WebSocket 接收事件,无需公网地址 | | WSClient | 飞书官方 SDK 的 WebSocket 客户端 | | UserMapping | 飞书 open_id → 当前对话目标的映射文件(会话或待创建目录,`~/.cc-linker/user-mapping.json`) | | List Snapshot | 最近一次 `/list` 结果的序号快照,用于支持 `/switch 1` 这类快捷参数 | | open_id | 飞书用户的唯一标识 | | pending_new_session | 用户已通过 `/new ` 指定目录,但尚未发送首条正式消息的待创建状态 | | Stream Parser | 解析 Claude stream-json 输出,过滤 hook 噪声,提取 thinking/text 的组件 | | Card Updater | 管理飞书卡片消息的发送 + patch + 节流控制的组件 | | Throttle | 限制卡片更新频率的机制,避免触发飞书 API 限流(5 QPS) | | SDK 模式 | 使用 `@anthropic-ai/claude-agent-sdk` npm 包直接调用 Claude Code,支持权限交互(默认执行引擎) | | Permission Handler | 工具权限管理器:白名单/黑名单/超时自动拒绝 + 用户决策回调 | | Permission Card | 飞书交互式卡片,展示工具名称和操作详情,供用户允许/拒绝 | | Stream Adapter | SDK 消息流 → StreamChunk 格式适配 | | Image Processor | 飞书图片下载、prompt 注入、过期清理(`~/.cc-linker/images/`) | | Startup Reconciler | 启动自愈:恢复卡住消息、回滚超时 claim、补齐 jsonl_path | | Target Snapshot | 消息入队时固化的目标快照,防止 `/switch` 与排队消息竞态 | | Delivery Receipt | 出站投递记录,防止"已回复但崩溃"导致重复发送 | | Provider | Claude 模型提供者配置(如不同模型 via CC Switch 或自定义 config) | ### 4.6 流式响应实现(已实现) > **核心目标**:用户在飞书发送消息后,不再经历"黑盒等待",而是实时看到 Claude 的思考过程和回复进展。 #### 4.6.1 流式卡片生命周期 一条飞书消息在流式模式下经历 **4 个卡片状态**: ``` 状态流转: ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ processing │───►│ streaming │───►│ complete │ │ error │ │ "正在处理..." │ │ "思考/回复" │ │ "处理完成" │ │ "处理失败" │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ │ ▲ ▲ │ Claude 进程启动 │ 第一个 assistant │ type=result │ 进程异常/超时/ │ 后发送初始卡片 │ chunk 到达 │ 到达 │ 解析错误 └────────────────────┴─────────────────────┴────────────────────┘ ``` **各阶段说明**: | 状态 | 触发时机 | 展示内容 | 卡片操作 | |------|---------|---------|---------| | `processing` | Claude 进程成功 spawn | "⏳ 正在处理..." + 预计耗时 | 无 | | `streaming` | 第一个 `type=assistant` chunk 到达 | thinking/text 逐步追加 + 已用时间 | 无 | | `complete` | `type=result` chunk 到达 | 完整回复 + 费用/耗时统计 | 可附快捷按钮 | | `error` | 进程崩溃/超时/解析失败 | 错误描述 + 建议操作 | 可重试 | **关键时序**: ``` 1. Dispatcher 调度消息 2. SessionManager.spawn Claude 进程 └─ args: '--print --input-format stream-json --output-format stream-json --verbose --resume ' 3. 进程启动成功 → 立即发送 processing 卡片(im.v1.message.create) └─ 返回 card_message_id,记录 delivery 4. 逐行读取 stdout: ├─ type=system → 忽略(hook 噪声) ├─ type=assistant: │ ├─ 首次到达 → 切换卡片为 streaming 状态(im.v1.message.patch) │ └─ 后续到达 → 节流器判断:距上次 patch ≥ throttle_ms → 更新卡片 └─ type=result: └─ 切换卡片为 complete 状态(im.v1.message.patch),写入 delivery sent 5. finalize: spool 移入 done/ ``` #### 4.6.2 卡片模板设计 飞书交互式卡片使用 JSON 格式,通过 `msg_type: 'interactive'` 发送。 **状态 1:processing(处理中)** ```json { "config": { "wide_screen_mode": true }, "header": { "title": { "tag": "plain_text", "content": "⏳ 正在处理..." }, "template": "blue" }, "elements": [ { "tag": "markdown", "content": "Claude 正在处理你的请求,预计 **2-10 秒**..." } ] } ``` **状态 2:streaming(流式更新中)** ```json { "config": { "wide_screen_mode": true }, "header": { "title": { "tag": "plain_text", "content": "💭 处理中" }, "template": "blue" }, "elements": [ { "tag": "markdown", "content": "**思考过程:**\n> 正在分析项目结构...\n\n---\n**回复:**\n根据项目结构,建议..." }, { "tag": "markdown", "content": "⏱ 已用时 12s" } ] } ``` **状态 3:complete(处理完成)** ```json { "config": { "wide_screen_mode": true }, "header": { "title": { "tag": "plain_text", "content": "✅ 处理完成" }, "template": "green" }, "elements": [ { "tag": "markdown", "content": "完整回复内容..." }, { "tag": "hr" }, { "tag": "markdown", "content": "费用: **$0.15** | 耗时: **23s** | 轮数: **3**" } ] } ``` **状态 4:error(处理失败)** ```json { "config": { "wide_screen_mode": true }, "header": { "title": { "tag": "plain_text", "content": "❌ 处理失败" }, "template": "red" }, "elements": [ { "tag": "markdown", "content": "错误原因:Claude Code 进程启动失败\n\n请检查:\n1. `claude` 命令是否可用\n2. 工作目录是否正确" } ] } ``` #### 4.6.3 节流策略 **问题**:`stream-json` 每秒可能产生数十行输出,但飞书 patch API 限制为 **5 QPS / message**(每消息每秒最多更新 5 次)。 **策略**: ```typescript class ThrottledCardUpdater { private lastPatchAt = 0; private pendingUpdate: string | null = null; private readonly throttleMs: number; // 默认 1500ms async schedule(content: string): Promise { const now = Date.now(); const elapsed = now - this.lastPatchAt; if (elapsed >= this.throttleMs) { // 立即 patch await this.patch(content); this.lastPatchAt = now; } else { // 缓存最新内容,等节流结束后发送 this.pendingUpdate = content; setTimeout(async () => { if (this.pendingUpdate) { await this.patch(this.pendingUpdate); this.pendingUpdate = null; this.lastPatchAt = Date.now(); } }, this.throttleMs - elapsed); } } } ``` **默认配置**: ```toml [stream] enabled = true throttle_ms = 1500 # 卡片更新最小间隔 show_thinking = true # 是否在卡片中展示 thinking 内容 max_card_bytes = 25000 # 卡片内容最大字节数(留 5KB 余量给 30KB 上限) fallback_to_text = true # 超过 max_card_bytes 时降级为普通文本消息 ``` **节流效果评估**: | 场景 | stream-json 行数/s | 实际 patch 频率 | 延迟感知 | |------|-------------------|----------------|---------| | 纯文本回复 | ~10-20 | 1 次/1.5s | 流畅 | | 多轮工具调用 | ~5-10 | 1 次/1.5s | 流畅 | | 长时间思考 | ~1-2 | 1 次/1.5s | 平滑 | #### 4.6.4 stream-json 解析器 **输入**:Claude Code `--print --output-format stream-json --verbose` 的 stdout **输出**:结构化的流式 chunk ```typescript interface StreamChunk { type: 'thinking' | 'text'; content: string; // 增量内容 isComplete: boolean; // 是否为该块的最后一段 } interface StreamResult { response: string; // 完整文本回复 sessionId: string; costUsd: number; durationMs: number; stopReason: string | null; numTurns?: number; } class StreamJsonParser { /** * 解析一行 stream-json 输出 * 返回 null 表示应忽略(system hook 噪声) */ parseLine(line: string): StreamChunk | null { const obj = JSON.parse(line); if (obj.type === 'system') return null; // 过滤 hook 噪声 if (obj.type === 'assistant') { // assistant 消息可能包含 thinking 或 text block const blocks = obj.message?.content ?? []; for (const block of blocks) { if (block.type === 'thinking') { return { type: 'thinking', content: block.text, isComplete: false }; } if (block.type === 'text') { return { type: 'text', content: block.text, isComplete: false }; } } } if (obj.type === 'result') { return null; // result 由外层单独处理 } return null; } } ``` **hook 噪声过滤**:`type=system` 的行包含 hook 事件,直接跳过。Phase 2 还可使用 `--exclude-dynamic-system-prompt-sections` 减少 hook 输出量。 #### 4.6.5 飞书 API 调用方式 ```typescript // 1. 发送初始卡片(processing 状态) const createResp = await client.im.v1.message.create({ receive_id_type: 'open_id', receive_id: openId, msg_type: 'interactive', content: JSON.stringify(processingCard), }); const cardMessageId = createResp.data?.message_id; // 2. 更新卡片内容(streaming / complete / error 状态) await client.im.v1.message.patch({ path: { message_id: cardMessageId }, data: { content: JSON.stringify(updatedCard) }, }); ``` **API 约束**(来自 SDK 类型定义): | 约束项 | 值 | |--------|-----| | 卡片 body 上限 | 30KB | | 单条消息更新频控 | 5 QPS | | 可修改时间范围 | 14 天内发送的消息 | | 前置条件 | 需开启机器人能力 | #### 4.6.6 与 delivery receipt 的交互 现有 `spool/deliveries/` 记录分片级投递状态。流式模式下: ```json { "messageId": "om_xxx", "status": "sent", "chunks": [ { "index": 0, "status": "sent", "feishuMessageId": "om_card_xxx", "checksum": "sha256(complete_response)", "requestUuid": "stable-uuid", "streaming": true } ] } ``` **关键规则**: 1. 初始卡片发送成功后,写入 delivery `status=sending`,记录 `feishuMessageId` 2. 中间 patch 不产生新 delivery 记录(复用同一 message_id) 3. complete/error 卡片 patch 成功后,更新 delivery `status=sent` 4. 崩溃恢复: - 若 delivery 存在且 `status=sent` → 不再处理 - 若 delivery 存在但 `status=sending` → 说明卡片已发送但处理未完成,需检查 spool 状态决定是否重新处理 - 若 delivery 不存在 → 正常恢复流程 **超长回复降级**: 当累积回复内容超过 `max_card_bytes`(默认 25KB)时: 1. 停止 patch 更新卡片 2. 将卡片标记为 "内容过长,请点击查看完整回复" 3. 超出的内容按 Phase 1 的分片策略作为普通文本消息发送 4. 每个文本分片独立记录 delivery #### 4.6.7 错误处理 | 错误场景 | 处理方式 | |---------|---------| | Claude 进程启动失败 | 不发送 processing 卡片,直接回复错误文本消息 | | 进程中途崩溃 | patch 卡片为 error 状态,记录 failed | | 进程超时(5 分钟无输出) | 终止进程组,patch 卡片为 "超时" | | 进程超时(30 分钟硬上限) | 终止进程组,patch 卡片为 "超时" | | patch 请求失败(网络) | 指数退避重试(最多 3 次),失败后降级为普通文本 | | patch 请求 429(限流) | 延长节流间隔,等待后重试 | | stream-json 解析失败 | 忽略异常行,继续解析后续行 | | `--resume` 无效 UUID | patch 卡片为 "会话已不存在,请 /switch" | | 费用超预算 | patch 卡片为 "费用超限" | #### 4.6.8 配置扩展 ```toml [stream] enabled = true throttle_ms = 1500 show_thinking = true max_card_bytes = 25000 fallback_to_text = true # 新增:是否在流式卡片上附快捷操作按钮 show_action_buttons = true # 新增:流式模式下是否保留 processing 卡片(false 则最终替换为 complete 卡片) keep_processing_card = false ``` --- ### 4.7 新增/修改模块清单(流式 + 权限交互) | 模块 | 路径 | 操作 | 说明 | |------|------|------|------| | Stream Parser | `src/proxy/stream-parser.ts` | **新增** | stream-json 行解析,过滤 hook 噪声,累积文本 | | Card Updater | `src/feishu/card-updater.ts` | **新增** | 飞书卡片发送 + patch + 节流控制 | | Session Manager | `src/proxy/session.ts` | **修改** | 新增 `sendStreamingMessage()` 接受 `onProgress` 回调 | | Feishu Bot | `src/feishu/bot.ts` | **修改** | `handleChat` 中接入流式路径,配置开关 | | Config | `src/utils/config.ts` | **修改** | 新增 `[stream]` 段落 | | Delivery Receipt | `src/queue/spool.ts` | **修改** | 扩展 delivery 记录 stream 状态 | --- ### 4.8 Claude Code 程序化调用示例 #### 模式 A:SDK 调用(默认,`@anthropic-ai/claude-agent-sdk`) ```typescript import { query } from '@anthropic-ai/claude-agent-sdk'; for await (const message of query({ prompt: text, options: { permissionMode: 'acceptEdits', canUseTool: handler.canUseTool.bind(handler), cwd: '/Users/xxx/Git/cc-linker', resume: sessionId, // 已有会话时 }, })) { // message: assistant chunk / result / permission_request } ``` **特点**:支持交互式权限确认,`canUseTool` 回调暂停执行等待用户决策。 #### 模式 B:单次调用(`--output-format json`) ```bash # 已有会话:带 --resume 恢复上下文 claude -p "你的问题" --resume --output-format json # 新会话:不带 --resume,让 Claude Code 自行创建 session claude -p "你的问题" --output-format json # 输出为单行 JSON: # {"type":"result","subtype":"success","result":"文本回复","session_id":"8c4b1297-...","total_cost_usd":0.05,...} # ↑ 这个 session_id 就是 Claude Code 分配的真实 UUID # 新会话时用此值建立后续 --resume 映射 ``` #### 模式 C:流式调用(`--output-format stream-json`) ```bash # 流式输出:每行一个 JSON 对象,可实时获取 assistant 消息 claude --print --input-format stream-json --output-format stream-json \ --verbose --resume # 输出为多行 JSON: # {"type":"system","subtype":"hook_started",...} ← 需过滤 # {"type":"system","subtype":"hook_response",...} ← 需过滤 # {"type":"system","subtype":"init",...} ← 会话初始化 # {"type":"assistant","message":{...}} ← 模型输出(thinking) # {"type":"assistant","message":{...}} ← 模型输出(text) # {"type":"result","subtype":"...","session_id":"...",...} ← 最终结果 ``` > **⚠️ 重要**:`--print` 模式下 Claude Code 会在单次输入-输出后自动退出, > 不支持"长期运行的双向流式 IPC"。每次飞书消息需要 spawn 新进程。 ### 8.4 飞书长连接模式 #### 初始化 WSClient ```typescript import * as Lark from "@larksuiteoapi/node-sdk"; const wsClient = new Lark.WSClient({ appId: "YOUR_APP_ID", appSecret: "YOUR_APP_SECRET", loggerLevel: Lark.LoggerLevel.info, }); wsClient.start({ "im.message.receive_v1": async (data) => { const { message, sender } = data.event; // 处理消息... }, }); ``` #### 调用 API 发送消息 ```typescript const client = new Lark.Client({ appId: "YOUR_APP_ID", appSecret: "YOUR_APP_SECRET", }); await client.im.v1.message.create({ receive_id_type: 'open_id', receive_id: 'ou_xxx', msg_type: 'text', content: JSON.stringify({ text: 'Hello' }), }); ``` #### 关键注意事项 | 项目 | 说明 | |------|------| | 事件类型 | `im.message.receive_v1` | | 超时重推 | 收到消息后需在 **3 秒内** 处理完成 | | 集群模式 | 同一应用多个实例,消息只被一个实例接收 | | 无需公网IP | 长连接模式无需配置公网可访问地址 | | 免解密验签 | SDK 已封装鉴权逻辑,无需手动处理 | | 飞书后台配置 | 事件配置 → 选择**"使用长连接接收事件"** | ### 8.5 验证记录 | 验证项 | 日期 | 环境 | 结论 | |--------|------|------|------| | `-p` / `--print` 关系 | 2026-05-05 | Claude Code v2.1.126 | **同一个 flag 的短/长形式**(`claude --help` 确认),两者等价 | | `--input-format stream-json` | 2026-05-05 | Claude Code v2.1.126 | 需要 `--print --verbose`,进程会退出 | | `--output-format stream-json` | 2026-05-05 | Claude Code v2.1.126 | 多行 JSON 输出,含 hook 噪声(superpowers skill 内容可达数 KB) | | `--output-format json` | 2026-05-05 | Claude Code v2.1.126 | 单行 JSON 输出。**关键发现**: `result.result` 字段直接是文本回复字符串,不是 `result.message.content` | | `--output-format json` + 工具调用 | 2026-05-05 | Claude Code v2.1.126 | `result.result` 包含完整响应(含工具调用结果),`num_turns` 反映实际轮数 | | `-p` + `--resume` + `--output-format json` | 2026-05-05 | Claude Code v2.1.126 | 组合使用正常,会话上下文正确恢复(验证:跨两次调用记住了"张三") | | `--cwd` 参数 | 2026-05-05 | Claude Code v2.1.126 | **不存在**,应使用 spawn cwd 选项 | | 飞书长连接 3 秒超时 | 2026-05-05 | 飞书官方文档 | **确认**: "3 秒内处理完成且不抛出异常,否则触发超时重推"。解决方案:回调只做入队列 + 异步处理 | | 飞书长连接模式 | 2026-05-05 | 官方文档 + SDK 验证 | WSClient 自动处理鉴权、重连,无需公网 | | `@larksuiteoapi/node-sdk` Bun 兼容性 | 2026-05-05 | Bun v1.3.13 + SDK v1.62.1 | import/WSClient/Client 实例化全部正常,**完全兼容** | | 新会话不带 `--resume` 的行为 | ✅ 2026-05-05 | Claude Code v2.1.126 | 自动创建 session,返回 `session_id`(有效 UUID),新会话流程成立 | | `result.session_id` 字段存在性 | ✅ 2026-05-05 | Claude Code v2.1.126 | 新会话和 `--resume` 场景下均存在,值为有效 UUID | | `--resume` 不存在 UUID | ✅ 2026-05-05 | Claude Code v2.1.126 | 返回错误(exit code 1),stderr: `No conversation found with session ID: xxx`,**不创建新会话** | | `--output-format json` hook 噪声 | ✅ 2026-05-05 | Claude Code v2.1.126 | stdout 仅输出单行 JSON,**无 hook 内容混入** | | spawn `cwd` 为 `~` | ✅ 2026-05-05 | Node.js v23 | `spawn` 不会自动展开 `~`,需手动展开为绝对路径 | | WSClient 回调数据结构 | ✅ 2026-05-05 | SDK v1.62.1 类型定义 | 通过 SDK 类型定义确认:`content` 始终为 JSON 字符串,`message_type` 区分消息类型 | | `im.v1.message.patch` API | ✅ 2026-05-23 | SDK v1.62.1 类型定义 | 确认存在:`client.im.v1.message.patch({ path: { message_id }, data: { content } })`。卡片 body 上限 30KB,5 QPS / message | | 飞书卡片更新频控 | ✅ 2026-05-23 | 飞书官方文档 | 单条消息更新频率 **5 QPS**,需节流。仅支持修改 14 天内发送的消息 | --- ## 九、审查遗留问题(待决策) > 所有设计决策已完成(9.1-9.8),以下是需要在 Phase 1 开发中验证的技术项: ### 9.1 用户身份映射(已决策) **结论**:Phase 1 使用 `~/.cc-linker/user-mapping.json` 记录**单用户私有模式**状态: - `ownerOpenId`:Bot 唯一拥有者,建议通过配置显式指定 - `mappings[open_id]`:当前目标,可为 `type=session`、`type=pending_new_session` 或 `type=pending_new_session_claimed` **owner 绑定流程**: 1. 默认要求在配置中显式填写 `ownerOpenId` 2. 仅在本地开发且显式开启 `allow_auto_bind_owner = true` 时,才允许首次私聊自动绑定 3. 后续若 `open_id !== ownerOpenId`,直接拒绝处理 4. 群聊消息一律忽略 **新会话创建流程**(修订): 1. 用户显式发送 `/new [cwd] [-- prompt]` 2. 解析规则:`--` 左侧为 `cwd`,右侧为首条 `prompt` 3. 若命令未带 `cwd`,则使用 `feishu_bot.default_cwd` 4. 若没有可用 `cwd`,返回引导,不创建会话 5. 若用户提供了 `prompt`,则创建后立即执行;否则仅写入 `pending_new_session` 6. 若后续普通文本命中 `pending_new_session`,必须先在锁下把它 CAS 抢占为 `pending_new_session_claimed` 7. 真正调用 Claude Code 创建会话时,使用用户的首条 prompt,不注入 bootstrap prompt 8. Claude Code 自行创建 session,返回 `result.session_id` 9. 先以 `status=provisioning` 写入 Registry,再把映射切换到 `type=session` 10. 立即尝试补齐 `jsonl_path`;成功则 `active`,失败则 `degraded` 并进入后台补偿 11. 持久化完整元数据(`origin='feishu'`、`cwd`、`project_name`、`last_active` 等) 12. 若在拿到 `session_id` 前失败,则把 `pending_new_session_claimed` 回滚回 `pending_new_session` **关键变更(相比旧设计)**: - 不再对"无映射的普通文本消息"隐式创建新会话 - 不再默认在 `~` 下启动弱上下文会话 - 新会话必须显式指定或推导出明确的项目目录 - `/new ` 不再偷偷发送 bootstrap prompt;只有用户真实首问才会创建会话 - `/new -- ` 仍支持"创建 + 首问"一步完成 - 入队时必须固化 `target snapshot`,防止排队消息被 `/switch` 改道 - `pending_new_session` 只能被第一条普通文本消费一次,后续消息必须等待或显式重试 ### 9.2 部署架构(已决策) **结论**:同一进程,内部模块直接引用 - `cc-linker start` 启动后,Feishu Bot(WSClient)和 SessionManager 在同一进程内 - 通过 `import` 直接调用,无需 HTTP 通信 - WSClient 自动连接飞书服务器(长连接模式) - HTTP 调试端口(port 9820)留到 Phase 2 实现 ### 9.3 `--bare` vs 完整上下文(已决策) **结论**:**不用 `--bare`,保留完整上下文** - 必须加载 CLAUDE.md(项目上下文)、Memory(历史记忆)、MCP servers(工具能力) - SDK 模式和 `--output-format json` 均无 hook 噪声问题 - `--output-format stream-json` 模式下通过过滤 `type: system` 行解决 hook 噪声(已实现) - 这是 cc-linker 的核心价值:在飞书中具备完整 CLI 能力 ### 9.4 飞书 SDK 选择(已决策) **结论**:使用 `@larksuiteoapi/node-sdk` 的 `WSClient` 和 `Client` 理由: - `WSClient` 是飞书官方封装的长连接客户端,自动处理鉴权、心跳、重连 - `Client` 封装了飞书 API 调用,无需手动处理 `tenant_access_token` - 直接用 `fetch` 实现也可以,但需要自己管理 token 生命周期 - 建议 Phase 1 先用 SDK,后续如果发现兼容性问题再切换为 fetch ### 9.5 费用控制(已决策) **结论**:**不需要费用控制** 飞书 Bot 调用 Claude Code 与终端直接使用 Claude Code 是同一个 API、同一个定价、同一个账号,费用完全一致。这不是额外开销,就是正常的 Claude Code 使用成本。加日限额等于限制自己使用已付费的服务,没有意义。 > 注:如果后续面向多用户场景(团队成员各自使用),可以考虑按用户维度统计费用作为参考数据,但不做限额。 ### 9.6 服务生命周期(已决策) **Phase 1 运行方式**:前台运行 + `Ctrl+C` 停止 - `cc-linker start` 启动后在前台运行 - 启动流程开始时获取 `runtime.owner_lock_path`,成为运行态唯一写者 - 为避免 `startupReconcile()` 与离线 CLI 写命令发生竞态,实际顺序应为:**先获取 `owner.lock`,再执行 `startupReconcile()`** - `Ctrl+C`(SIGINT)或 `kill`(SIGTERM)时: 1. 标记 `isShuttingDown = true`,停止接收新消息 2. Dispatcher 不再拉起新 worker 3. 等待当前消息处理完成(最多 30 秒) 4. 停止消费 `session-events/`,Flush Registry / UserMapping / spool 状态到磁盘 5. 释放 `owner.lock` 6. 超过宽限期仍未完成时,保留 `processing` 文件,等待下次启动恢复 7. 退出 **Phase 1 启动自愈**: - 在获取 `owner.lock` 后执行 `startupReconcile()` - 将 `processing/` 搬回 `pending/` - 将 `replied/` 且 `delivery receipt=sent` 的消息直接 finalize 为 `done` - 重试 `provisioning/degraded` 会话的 `jsonl_path` 补齐 - 检查过期的 `pending_new_session_claimed`,必要时回滚为 `pending_new_session` - 归并 `session-events/` 中尚未消费的 Hook 发现事件 - 修复 `user-mapping.json` 与 `registry.json` 的弱一致性问题 **Phase 1 日志**: - 日志输出到 `~/.cc-linker/cc-linker.log`(通过 `config.general.log_path` 配置) - 使用简单的文件追加写入(`Bun.file()` 或 `fs.appendFileSync`) - Phase 2 补充日志轮转(文件大小切割 + 旧日志清理) **Phase 2 规划**: - `--daemon` 后台运行模式(PID 文件 `~/.cc-linker/cc-linker.pid`) - `cc-linker stop` 命令(读取 PID 文件,发送 SIGTERM) - 日志轮转:超过 10MB 归档,最多保留 3 个归档,启动时清理 7 天前的旧日志 ### 9.7 安全加固(已明确) - **内部认证**:同一进程内模块直接引用,不需要内部 token - **HTTP API(9820)**:Phase 2 实现,添加简单 token 认证(`config.proxy.token`) - **owner 限制**:默认通过配置显式指定 `ownerOpenId`;仅开发态允许自动绑定 - **群聊排除**:Phase 1 仅支持私聊,群聊消息直接忽略 - **输入清洗**:非文本消息已在事件回调中过滤;消息长度限制 10000 字符,超限拒绝并回复提示 - **工作目录白名单**:`/new` 默认只能使用 `security.allowed_roots` 下的目录 - **危险目录黑名单**:`security.denied_roots` 默认拒绝 `~`、`/`、`~/Desktop`、`~/Downloads` - **危险动作确认**:为高风险目录/任务预留二次确认能力,避免飞书侧误触 - **Token 存储**:支持环境变量 `CC_LINKER_FEISHU_APP_SECRET` 覆盖配置文件,避免明文存储;Docker/K8s 部署时通过 secret 注入 - **列表快照**:`/list` 生成的序号快照只保留最近一次,默认 10 分钟过期,避免旧索引误操作到错误会话 ### 9.9 流式响应设计(Phase 2 已决策) **结论**:使用 `--output-format stream-json` + 飞书卡片 patch 实现流式响应 - **触发条件**:`[stream].enabled = true` 时启用,否则回退 Phase 1 的 `--output-format json` - **Claude 调用参数**:`claude --print --output-format stream-json --verbose --resume ` - **卡片更新机制**: 1. 进程启动后立即发送 processing 卡片(`im.v1.message.create`) 2. 首个 assistant chunk 到达时切换为 streaming(`im.v1.message.patch`) 3. 后续 chunk 按 `throttle_ms` 节流更新卡片 4. result 到达时切换为 complete(`im.v1.message.patch`) - **节流**:默认 1500ms,对应 ~0.67 次/秒 patch,远低于飞书 5 QPS 上限 - **thinking 展示**:默认启用,用引用格式(`> `)展示在卡片中 - **超长降级**:超过 25KB 时停止 patch,切换为普通文本分片发送 - **delivery 扩展**:processing 卡片发送后记 `sending`,complete patch 后记 `sent` - **回退策略**:若 patch 连续失败 3 次,降级为普通文本消息发送 **与 Phase 1 的兼容性**: - 现有 `--output-format json` 路径不受影响 - 流式和非流式路径共用同一个 `sendMessage()` 入口,通过 `stream: boolean` 参数区分 - delivery receipt 新增 `streaming` 字段,不影响 Phase 1 的解析逻辑 **结论**:Phase 1 实现**本地可恢复**的消息可靠性保障,而不是纯内存队列 - **消息长度限制**:最大 10000 字符,超限返回友好提示 - **持久化入队**:WSClient 回调中先写入 `spool/pending`,成功后再返回 - **目标快照**:入队时记录 `target snapshot`,后续处理不读最新 Mapping - **新会话一次性消费**:`pending_new_session` 只能由第一条普通文本 claim;claim 成功后转为 `pending_new_session_claimed` - **持久化去重**:为每条消息写入 `spool/receipts/.json`,作为跨重启幂等依据 - **队列上限**:待处理消息最大 100 条,超过时拒绝新消息并通知用户;**不丢弃旧消息** - **崩溃恢复**:启动时自动扫描 `pending/processing`,恢复上次未完成的消息 - **出站幂等**:飞书回复前后分别写入 `spool/deliveries/.json` 与 `replied/` 状态,防止重复回消息;同一分片重试必须复用稳定 `uuid` - **多分片保护**:长回复若拆分发送,必须记录每片发送状态;否则降级为摘要 + 导出指引 - **成功归档保留**:`spool/done` 默认保留 24 小时,且最多保留 1000 条,满足当天排障即可 - **失败归档保留**:`spool/failed` 默认保留 7 天,且最多保留 200 条,便于排查偶发错误 - **幂等收据保留**:`spool/receipts` 默认保留 7 天,覆盖重推和人工重复点击场景 - **归档清理时机**:启动时清理一次,运行期间每小时清理一次 - **超时控制**:活跃检测(5 分钟无输出判定卡死)+ 30 分钟硬上限,超时后回复错误提示 - **进程树回收**:超时或异常退出时按进程组回收子进程,避免残留 shell / MCP / tool 进程 - **错误兜底**:任何处理异常都回复飞书用户(而非只写日志) - **并发控制**:全局有限并发 + 同一会话串行,兼顾吞吐和本机资源保护 - **去重保护**:以 `receipt/spool` 持久化状态为准,防止飞书超时重推导致的重复处理 - **新会话补偿**:若 `session_id` 已返回但 `jsonl_path` 一时未找到,会话进入 `provisioning/degraded`,后台持续补齐,避免“用户成功创建但产品丢会话” - **大响应回传**:超长文本按分片发送;超过上限时降级为摘要和终端导出指引 - **--resume 失效处理**:`--resume` 不存在的 UUID 时 Claude Code 返回错误(exit code 1,stderr: `No conversation found`),需向飞书用户返回明确提示 **Phase 2 流式扩展**: - **初始卡片幂等**:processing 卡片发送成功后记录 delivery `status=sending`,崩溃恢复时据此判断是否需要重新处理 - **patch 不产生新 message_id**:中间更新复用同一 `card_message_id`,不产生独立 delivery 记录 - **complete 最终化**:result 到达后 patch 为 complete 状态,更新 delivery `status=sent`,写入 checksum - **超长降级**:超过 `max_card_bytes` 时停止 patch,切换为普通文本分片发送,每个分片独立记录 delivery - **流式节流**:默认 1500ms 更新一次卡片,避免触发飞书 5 QPS 限流 --- ## 十、Phase 1 待验证技术项 > 所有核心假设均已验证通过。以下是 Phase 1 开发中需要注意的实现细节: ### 10.1 `--output-format json` 实际输出格式 ✅ 已验证 **验证日期**:2026-05-05 **验证结论**: ```bash claude -p "回复OK即可" --output-format json # 输出: # {"type":"result","subtype":"success","result":"OK","total_cost_usd":0.0689...,"duration_ms":1618,...} ``` **关键发现**: 1. `result.result` 字段直接是文本回复字符串(不是 `result.message.content`) 2. 带工具调用场景下 `result.result` 包含完整响应(含工具调用结果) 3. `-p` + `--resume` + `--output-format json` 组合使用正常,会话上下文正确恢复 4. `result.subtype` 为 `"success"` 时正常;为 `"error_max_budget_usd"` 等错误类型时 `result.is_error=true` 5. `result.total_cost_usd` 和 `result.duration_ms` 可用于费用统计 6. `result.session_id` 字段存在 ✅ 已验证(见 §10.4) 7. stdout 仅输出单行 JSON,无 hook 内容混入 ✅ 已验证 **对实现的影响**: - `sendMessage()` 应直接读取 `result.result` 提取文本,读取 `result.session_id` 提取会话 ID - 新会话用返回的 `session_id` 更新 UserMapping,后续消息用此值 `--resume` ### 10.2 `@larksuiteoapi/node-sdk` 在 Bun 下的兼容性 ✅ 已验证 **验证日期**:2026-05-05 **环境**:Bun v1.3.13 + `@larksuiteoapi/node-sdk` v1.62.1 **验证结论**:完全兼容,无需降级方案。 ```bash bun add @larksuiteoapi/node-sdk # v1.62.1 安装成功,无警告 ``` ```typescript import * as Lark from '@larksuiteoapi/node-sdk'; // WSClient: 构造和实例化正常 const wsClient = new Lark.WSClient({ appId: 'test', appSecret: 'test', loggerLevel: Lark.LoggerLevel.info }); // Client: 构造正常,im 模块可用 const client = new Lark.Client({ appId: 'test', appSecret: 'test' }); // LoggerLevel: 枚举值完整 (fatal/error/warn/info/debug/trace) // 无 node-gyp 编译失败,无 Node.js 原生依赖问题 ``` ### 10.3 WSClient 回调数据结构 ✅ SDK 类型已确认 **验证方式**:通过 SDK 类型定义(`node_modules/@larksuiteoapi/node-sdk/types/index.d.ts:291305`)确认 **确认的数据结构**: ```typescript { event_id?: string; event_type?: string; sender: { sender_id?: { open_id?: string; // 飞书用户 ID user_id?: string; union_id?: string; }; sender_type: string; // "User" / "App" 等 }; message: { message_id: string; message_type: string; // "text" / "image" / "file" / "audio" / "media" / "sticker" / ... content: string; // 始终为 JSON 字符串 // ... 其他字段 }; } ``` **确认结论**: 1. `message.message_type` 支持多种类型(text/image/file/audio/media/sticker/merge_forward/forward/share_chat/share_user/system/video 等) 2. `content` 始终为 JSON 字符串: - `text`: `{"text":"hello"}` - `image`: `{"image_key":"img_xxx"}` - `file`: `{"file_key":"file_xxx"}` - 其他类型类似——**所有类型的 `content` 都是合法 JSON 字符串**,`JSON.parse()` 安全 3. 非文本消息的 `content` 不包含 `text` 字段 4. `sender.sender_id.open_id` 是我们在回调中使用的用户标识 **对实现的影响**:当前代码中 `JSON.parse(message.content)` 是安全的。已在 §4.4.2 中先校验 `message_type === 'text'` 再解析 `content`。 ### 10.4 新会话不带 `--resume` 的行为 ✅ 已验证 **验证日期**:2026-05-05 **验证结论**: ```bash claude -p "回复OK即可" --output-format json 2>/dev/null # 输出: # {"type":"result","subtype":"success","result":"OK","session_id":"471fdbef-cff4-4120-8659-46176639719a",...} ``` 1. Claude Code 自动创建新 session ✅ 2. 返回的 JSON 中包含 `session_id` 字段 ✅ 3. `session_id` 为有效 UUID 格式(`471fdbef-cff4-4120-8659-46176639719a`)✅ 4. `--resume` 场景下 `session_id` 同样存在(返回原始 UUID)✅ **结论**:新会话流程的设计假设成立。用返回的 `session_id` 建立映射,后续消息用 `--resume ` 恢复。 ### 10.5 `--resume` 不存在的 UUID 的行为 ✅ 已验证 **验证日期**:2026-05-05 **验证结论**: ```bash claude -p "测试" --resume 00000000-0000-0000-0000-000000000000 --output-format json # exit code: 1 # stderr: No conversation found with session ID: 00000000-0000-0000-0000-000000000000 ``` 1. **返回错误**,不创建新会话 2. exit code 为 1,stderr 包含 `No conversation found with session ID: xxx` 3. 对实现的影响:当用户 `/switch` 到一个已删除/损坏的会话时,`--resume` 会报错。 需在 `handleChat` 中捕获此错误,向飞书用户返回友好提示:"会话已不存在,请 /switch 切换到其他会话"