release: v0.5.0

This commit is contained in:
牟威霖 2026-07-03 16:26:12 +08:00
parent bf9a37d633
commit 700b72e55c
24 changed files with 5670 additions and 133 deletions

38
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: CI
on:
push:
pull_request:
jobs:
verify:
strategy:
matrix:
os: [macos-latest, windows-latest]
node: [22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm run verify
package:
needs: verify
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- run: npm run package
- uses: actions/upload-artifact@v4
with:
name: mv-obcc-ide
path: release/

View file

@ -3,7 +3,7 @@
**mv-SenceAI** 是一款专为 Obsidian 打造的 AI 笔记、科研插件。它能够在您的本地代码环境与 Obsidian 知识库之间建立无缝的数据通道。
本插件包含三个相对独立的核心能力:
1. **IDE 桥接 (IDE Bridge)**:为 Claude Code 提供 Obsidian 当前的上下文信息(如当前标签、选区内容),并支持通过标准 MCP 协议调用特定工具以及审核代码差异Diff
1. **IDE 桥接 (IDE Bridge)**:为 Claude Code 与 Codex CLI 提供 Obsidian 当前的上下文信息(如当前标签、选区内容)。Claude Code 侧支持标准 MCP 主动工具和差异审核DiffCodex CLI 侧支持 `/ide` 上下文读取,并通过标准 MCP 使用 Obsidian 工具
2. **划词助手 (LLM Assistant)**:完全独立于 IDE 桥接的内置功能。允许您在 Obsidian 的各种视图Markdown、PDF、Web Viewer中选中文本后通过自定义提示词直接流式调用 OpenAI 或 Anthropic 兼容的语言模型 API。
3. **行内补全 (Inline Completion)**:在 Markdown 编辑器中显示 ghost text 续写建议,可通过左侧功能区按钮按需点亮,并支持接受、取消、拒绝后重新生成。
@ -16,7 +16,7 @@
### 方法一插件已经上架obsidian官方插件社区搜索mv-SenceAI 即可找到并安装
### 方法二:手动从 Release 安装
1. 前往 GitHub 仓库的 [Releases](https://github.com/aitingtingya/mv-senceai/releases) 页面,下载最新版本(如 `0.4.0`)的以下三个资产文件:
1. 前往 GitHub 仓库的 [Releases](https://github.com/aitingtingya/mv-senceai/releases) 页面,下载最新版本(如 `0.4.5`)的以下三个资产文件:
- `main.js`
- `manifest.json`
- `styles.css`
@ -51,6 +51,13 @@
- 在 Claude Code 终端输入 `/ide`,应提示已连接到 Obsidian。
- 输入 `claude mcp list`,应当能看到 `mv-senceai-ide`(或其提供的工具)已连接。
### 4. Codex CLI 侧配置
1. 确保本机已安装并登录 Codex CLI。
2. 在插件设置底部启用 `启用 Codex IDE 功能`
3. 在与该 Obsidian Vault 对应的本地目录中启动 Codex CLI。
4. 在 Codex CLI 中输入 `/ide`,应提示已连接,并在后续消息中自动带入当前 Obsidian 标签、选区和打开标签上下文。
5. 输入 `/mcp`,应能看到本插件的 MCP 工具;这些工具包括读取最近选区、列出打开标签、打开文件和读取 Web Viewer 页面。
---
## 使用指南
@ -59,12 +66,13 @@
- **被动状态感知**:开启后,您可以选择让插件追踪 Markdown、PDF 或是 Web Viewer。插件会将当前的标签页和选区状态被动同步给 Claude Code。
- *多实例支持*:开启“支持所有活动页面”后,插件会精确绑定 Claude PID 和会话。当同时运行多个 Claude 时,每个会话只会隐藏自己的终端,依然可以读取其他终端的信息。
- **主动工具 (MCP)**Claude Code 可以通过 HTTP MCP 协议主动调用插件提供的工具,例如:
- **主动工具 (MCP)**Claude Code 与 Codex CLI 可以通过 HTTP MCP 协议主动调用插件提供的工具,例如:
- `getLatestSelection`:读取最后一次非空选区。
- `getOpenEditors`:获取所有已打开的标签列表。
- `openFile`:在 Obsidian 中定位并打开特定的 Vault 文件。
- `readCurrentWebPage`:无需刷新或跳转,直接将 Obsidian Web Viewer 中正在浏览的网页读取为 Markdown 格式。
- **差异可视化审核 (Diff)**:当 Claude 提议修改文件时,如果是需要授权的操作,插件会在 Obsidian 侧弹出基于 CodeMirror MergeView 的差异比对界面。您可以在界面内进行编辑和最终确认,确认后的内容会由 Claude 写入硬盘。
- **Codex CLI 支持**Codex 集成使用 CLI `/ide` 的本地 IPC 上下文协议,并把插件的 MCP HTTP 服务写入 Codex 配置。连接或配置失败不会阻塞插件启动,也不会影响 Claude、划词助手或行内补全。
### ✍️ 划词助手功能
@ -118,9 +126,9 @@
- **视觉隔离策略**
“切换标签时保留选区高亮”功能仅为视觉辅助,在您切换到终端等标签时,原页面的选词高亮依然保留。这不影响内部发送给 Claude 的实际内容。
- **配置的隔离性**
划词助手的网络错误、API Key 暴露或调用失败,均只影响划词助手自身,**绝对不会**波及或影响 Claude Code 桥接通道的稳定性。
划词助手的网络错误、API Key 暴露或调用失败,均只影响划词助手自身,**绝对不会**波及或影响 Claude Code / Codex CLI 桥接通道的稳定性。
- **桌面权限说明**
插件会读取和更新 Claude Code 的项目配置、会话信息与 IDE lock 文件,以建立和恢复桥接连接。进程识别与 MCP 注册仅通过 Node.js `execFile` 调用明确的可执行文件和参数,不拼接或执行任意 shell 命令
Claude Code 集成会读取和更新 Claude Code 的项目配置、会话信息与 IDE lock 文件,以建立和恢复桥接连接。Codex CLI 集成会创建本地 `/ide` IPC socket/pipe并在 `~/.codex/config.toml` 中维护一个指向本插件 `/mcp` 服务的受管理配置块;不会启动 `codex app-server` 子进程
## 鸣谢
@ -130,10 +138,10 @@
# mv-SenceAI (English Documentation)
**mv-SenceAI** is a desktop bridge connecting your local vaults to Claude Code. It runs passively in the background to streamline your developer workflow in Obsidian.
**mv-SenceAI** is a desktop bridge connecting your local vaults to Claude Code and Codex CLI. It runs passively in the background to streamline your developer workflow in Obsidian.
This plugin provides three key capabilities:
1. **IDE Bridge**: Feeds contextual information (active tab, selections) from your vault to Claude Code, supports MCP tools, and enables visual diff reviews (via CodeMirror MergeView).
1. **IDE Bridge**: Feeds contextual information (active tab, selections) from your vault to Claude Code and Codex CLI. Claude Code uses the existing IDE/MCP bridge; Codex CLI uses `/ide` context IPC plus standard MCP tools.
2. **LLM Assistant (Selection Reader)**: A completely independent feature to call OpenAI or Anthropic compatible APIs directly from Obsidian views (Markdown, PDF, Web Viewer) using custom prompt templates, streaming responses into a floating output window.
3. **Inline Completion**: A separate Markdown-only ghost-text completion module with a ribbon toggle and configurable accept, cancel, and reject/regenerate keys.
@ -182,8 +190,15 @@ If you prefer to clone and compile the source code yourself:
>
> - **Web Reader Limits**: Standard iframe and canvas reading limits apply.
> - **PDF Reading**: Scanned PDF pages without text layers cannot be read.
> - **Config Isolation**: Errors or issues with the LLM selection assistant will not affect the main Claude Code IDE bridge.
> - **Desktop Permissions**: The plugin reads and updates Claude Code project settings, session metadata, and IDE lock files to manage the bridge. Process detection and MCP registration use Node.js `execFile` with explicit executables and arguments; the plugin does not construct or execute arbitrary shell commands.
> - **Config Isolation**: Errors or issues with the LLM selection assistant will not affect the Claude Code or Codex CLI IDE bridges.
> - **Desktop Permissions**: The Claude Code bridge reads and updates Claude Code project settings, session metadata, and IDE lock files. The Codex bridge creates a local `/ide` IPC socket/pipe and maintains a managed MCP server block in `~/.codex/config.toml`; it does not start `codex app-server`.
### Codex CLI Bridge
- Enable **Codex IDE** at the bottom of the plugin settings.
- Start Codex CLI in the matching vault directory and run `/ide`; Codex should connect to Obsidian context.
- Run `/mcp` in Codex CLI to confirm the mv-SenceAI MCP tools are visible.
- Codex receives active file, selection, cursor, and open-tab context through `/ide`; it uses Obsidian actions through standard MCP.
### LLM Assistant Interaction

102
TEST-REPORT.md Normal file
View file

@ -0,0 +1,102 @@
# MV OBCC IDE 0.2.7 测试报告
日期2026-06-13
## 环境
- macOS 26.5.1Apple Silicon
- Obsidian 1.12.7
- Claude Code 2.1.174
- Node.js 22.18.0
- Mimo Anthropic 兼容上游
## 自动验证
- TypeScript strict 类型检查:通过
- Vitest65/65 通过
- 生产 esbuild通过
- `npm audit`0 个漏洞
- 从源码 ZIP 全新执行 `npm ci``npm run verify`:通过
- 安装 ZIP、源码 ZIP 和插件文件 SHA-256 校验:通过
- GitHub Actions 同时配置 `macos-latest``windows-latest`
测试覆盖消息角色移动、上游地址优先级、代理循环规避、旧配置迁移、MCP 鉴权和初始化、Windows `.cmd`/`.bat`/`.exe` 参数传递、MCP 注册复验、macOS CLI 调用回归、WebSocket IDE 鉴权、定向客户端消息、端口冲突、macOS/Windows PID 解析、Claude session 文件校验、Hook 合并恢复、活动页面类型、终端标记、路径、lock 文件、Claude 设置恢复、Diff 状态幂等、网页精确标签读取、跨平台最近选区缓存和持久视觉高亮状态机。
## macOS 真实联调
- 插件加载、稳定端口和 IDE lock通过
- `/health``/healthz` 健康检查:通过
- `/ide` 连接与 `getDiagnostics` 内部调用:通过
- HTTP MCP 自动注册和 `claude mcp list` Connected通过
- MCP 关闭后移除注册且端点返回 404重新开启后自动恢复通过
- MCP 无令牌返回 401正确令牌初始化返回 200通过
- 真实模型调用 `mcp__mv-obcc-ide__getCurrentSelection`:通过
- 主动工具开关同时影响 `tools/list` 和直接调用:通过
- 全部打开标签包含 Markdown、PDF、网页、终端和插件页通过
- 图片标签标题、类型和绝对路径:通过
- 后台 DeferredView 文件路径恢复:通过
- Obsidian Web Viewer 选区:通过
- 切换到无选区标签后保留最后一次非空网页选区:通过
- GitHub 当前页面使用 Obsidian Reader 转 Markdown通过
- 网页读取前后 URL 与 WebContents ID 完全不变:通过
- PDF 文件、页码和同一 DOM 文本选区:通过
- 中文设置页面和自动上游来源显示:通过
- 兼容转换抓包:正文出现一次、只保留 user 消息:通过
- Diff 拒绝:源文件保持原文,通过
- Diff 接受:采用右侧最后编辑的全文并只写入一次,通过
- Claude 内部 `close_tab` 生命周期:通过
- `acceptEdits`:直接写入且不创建 Diff 标签,通过
- 两个 Claude 进程同时连接同一 IDE 端口:通过
- 两个进程分别拥有独立 PID、TTY 和 Claude session 文件:通过
- Terminal 3.26.0 支持 OSC 标题更新:通过
## 0.2.3 变更验证
- Windows MCP 注册不再使用 `shell: true`
- `.cmd``.bat` 通过显式 `cmd.exe /d /s /c` 调用HTTP Authorization header 保持为单一参数。
- `.exe` 继续通过参数数组直接调用。
- 注册成功必须经过 `claude mcp get mv-obcc-ide` 对 URL、令牌和 Connected 状态的复验。
- macOS 保持直接 `execFile(executable, args)`;对应回归测试通过。
- Windows 实机操作由用户按验证手册完成,本报告不声称 Windows 实机通过。
## 0.2.4 变更验证
- 网页工具只读取精确保存的 `WorkspaceLeaf`;相同 URL 或标题的其他标签不会被误读。
- 网页正文来自当前 WebContents 的 `document.body.innerText`,不再调用 Reader因此隐藏的预载任务内容不会混入结果。
- 自动测试覆盖视口上方、视口内和视口下方的已加载正文,确认返回整个滚动文档而非当前视口。
- 网页默认取消原有字符上限;超过 20 万字符的测试正文完整返回。只有设置正整数上限时才截断。
- Windows HTTP MCP 没有可靠 `sessionId` 时,`getLatestSelection` 回退到全局最近非空选区;空选区不会覆盖缓存。
- 有 `sessionId` 的 macOS/IDE 客户端继续严格按会话隔离,不回退到其他会话。
- 本节为代码和自动化验证,不声称完成新的 Windows 或 macOS 真机交互测试。
## 0.2.5 变更验证
- Markdown 使用独立 CodeMirror 状态字段保存视觉范围;失焦产生的空选区不会清除,源编辑器内的空点、键盘折叠或输入会清除。
- Markdown 视觉范围会随文档修改映射,被选文字完全删除后自动移除。
- PDF 只在 Selection 的锚点仍位于当前 PDF 视图时处理空选区,切换到终端或其他标签产生的空 Selection 会被忽略。
- Web Viewer 页面内部只在真实非空选区时更新高亮;空的 `selectionchange` 保留高亮,页面内 `pointerup``keyup` 后的空选区才清除。
- 视觉高亮拥有独立设置和存储路径,不读写 `SelectionState`、最近选区缓存、IDE WebSocket 或 MCP。
- PDF 和 Web Viewer 使用 CSS Custom Highlight 并按能力静默降级Markdown 不依赖该 API。
- 本节完成类型检查、自动测试和生产构建,不声称完成新的 Windows 或 macOS 真机视觉验收。
## 0.2.6 变更验证
- Markdown 备用 Decoration 在真实 CodeMirror 选区仍非空时也会生成,修复切换终端后原生选区不再着色、备用层又被抑制的问题。
- 编辑器聚焦时通过 CSS 隐藏备用层,由 Obsidian 原生选区显示;编辑器失焦时备用层恢复主题的 `--text-selection` 颜色,避免双重着色。
- 自动测试直接检查真实非空选区下的 Decoration 范围,并验证聚焦与失焦 CSS 规则。
- PDF、Web Viewer、`SelectionState`、最近选区缓存、IDE WebSocket 和 MCP 代码未修改。
- 本节完成类型检查、自动测试和生产构建,不声称完成新的 macOS 真机视觉验收。
## 0.2.7 变更验证
- 修复 Markdown ViewPlugin 在 CodeMirror `update()` 内嵌套 `dispatch()` 导致插件实例崩溃、保存范围从未建立的问题。
- 保存范围改为在原始 transaction 内由 StateField 同步更新,不创建第二个 transaction也不修改真实 selection。
- 高亮改用 `outerDecorations`,尽量包裹 Live Preview 的普通 Markdown 渲染 Decoration。
- 编辑器内使用 `:focus-within` 隐藏备用颜色;焦点进入终端或其他标签时恢复主题的 `--text-selection`
- jsdom 真实 EditorView 测试验证:非空选择产生实际高亮 DOM、CodeMirror exception sink 无异常、外部元素取得焦点后真实 selection 保持不变。
- 开关关闭后停止记录并清除范围;重新开启时从当前真实 selection 恢复。PDF、Web、IDE、MCP 和最近选区逻辑未修改。
- 本节完成类型检查、自动测试和生产构建;最终视觉效果仍由 Obsidian 真机验收。
## Windows 状态
代码包含 Windows 路径处理、Claude 可执行文件候选和 Windows CI当前机器没有 Windows 虚拟机,因此没有声称 Windows 实机通过。请按 [`WINDOWS-VALIDATION.md`](WINDOWS-VALIDATION.md) 在 Windows Codex 环境继续验证。

119
WINDOWS-VALIDATION.md Normal file
View file

@ -0,0 +1,119 @@
# Windows 实机验证手册
在真实 Windows 10/11 机器完成本清单后,才能把 Windows 状态标记为“实机通过”。
## 环境记录
记录以下版本与路径:
- Windows 版本和内部版本号
- Obsidian 桌面版版本
- Claude Code 版本
- Node.js 版本
- 测试 vault 路径,必须同时包含空格和中文,例如
`C:\Users\<name>\Documents\Test Vault 中文`
## 构建与安装
1. 在源码目录运行 `npm ci`
2. 运行 `npm run verify`确认类型检查、65 项测试及生产构建全部通过。
3. 运行 `npm run package`
4. 在 `release` 目录运行以下命令,并与 `SHA256SUMS` 逐项比较:
```powershell
Get-FileHash -Algorithm SHA256 .\mv-obcc-ide-0.2.7.zip
Get-FileHash -Algorithm SHA256 .\mv-obcc-ide-0.2.7-source.zip
Get-FileHash -Algorithm SHA256 .\mv-obcc-ide-0.2.7\main.js
Get-FileHash -Algorithm SHA256 .\mv-obcc-ide-0.2.7\manifest.json
Get-FileHash -Algorithm SHA256 .\mv-obcc-ide-0.2.7\styles.css
```
5. 将 `release\mv-obcc-ide-0.2.7\` 复制到 `<vault>\.obsidian\plugins\mv-obcc-ide\`
6. 只启用 `MV OBCC IDE`,关闭其他 Claude Code IDE 桥接插件。
7. 检查 `%USERPROFILE%\.claude\ide\` 中生成了对应端口的 lock 文件。
## MCP 与设置
1. 打开插件设置,确认全部文案为中文。
2. 确认 Claude 可执行文件能自动识别;若失败,填写 `claude.cmd``claude.exe` 完整路径。
3. 点击“重新注册”确认状态显示“MCP 已注册并验证;重新启动 Claude Code 后生效”。
4. 运行 `claude mcp list``claude mcp get mv-obcc-ide`,确认 URL、Authorization header 和 Connected 状态正确。
5. 重启 Claude Code确认出现 `mcp__mv-obcc-ide__getLatestSelection`、`getOpenEditors`、`openFile` 和已启用的网页工具。
6. 逐一关闭和开启主动工具,重新启动 Claude Code确认工具清单同步变化。
7. 关闭“启用 MCP 主动工具”,确认 local MCP 注册被删除;重新开启后确认恢复。
8. 检查 MCP 只监听 `127.0.0.1`,且无 Bearer token 时返回 401。
## 上游自动解析
1. 将插件中的手动上游地址留空。
2. 分别在用户、项目和 local Claude 设置中配置不同 URL确认显示来源遵循 local > project > user。
3. 兼容模式下确认 `.claude\settings.local.json` 指向插件本地端口。
4. 确认插件不会把本地代理地址当成真实上游形成循环。
5. 用脱敏抓包验证选区正文只在 `user` 消息出现一次。
6. 切换回原生模式,确认插件只恢复自己接管过的值。
## 标签与选区
1. 保持“支持所有活动页面”关闭,分别验证 Markdown、PDF、Web Viewer 三个开关。
2. 开启“支持所有活动页面”,确认三个页面类型开关变为不可用。
3. 检查 `.claude\settings.local.json` 中只新增本插件标记的 `SessionStart``UserPromptSubmit` Hook。
4. 同时从两个终端启动 Claude A 和 Claude B。
5. 确认 `%USERPROFILE%\.claude\sessions\<PID>.json` 分别映射两个 session。
6. 在 A 中交互时确认 A 保留上一个非自身终端页面;切到 B 的终端后A 可以读取 B。
7. 对 B 执行对称测试;退出后确认其他 Claude 设置和 Hook 没有被破坏。
随后继续通用标签测试:
1. 打开 Markdown、PDF、PNG/JPG、Web Viewer、终端、搜索和一个第三方插件标签。
2. 调用 `getOpenEditors`,确认所有标题、类型和活动状态正确。
3. 确认后台延迟加载的 Markdown、PDF 和图片仍返回 Windows 绝对路径。
4. 在 Markdown 中选择中文、公式、Unicode 和 CRLF 多行文本。
5. 在 PDF 文本层选择文字,确认文件、页码和正文正确。
6. 在 Web Viewer 中选择文字,确认 URL、标题和正文正确。
7. 选中一段文本并发送任意消息,等 Claude 回复结束、右下角不再显示选区状态后,再调用 `getLatestSelection`,确认仍返回刚才的最后一次非空选区。
8. 清空选区并继续聊天,再次调用 `getLatestSelection`,确认空选区没有覆盖上一步结果。
## 视觉高亮
1. 确认“切换标签时保留选区高亮”默认开启。
2. 分别在 Markdown 源码模式、Live Preview、PDF 和 Web Viewer 中划词,然后切到普通标签和终端插件标签,确认源页面高亮仍保留。
3. 回到源页面空点,确认只清除该标签的视觉高亮;重新划词时确认替换该标签原高亮。
4. 在多个标签中分别划词,确认每个标签最多保留一个且互不影响。
5. Markdown 中编辑选中文字前后的内容,确认范围随编辑移动;完全删除选中文字后高亮消失。
6. 刷新或导航 Web Viewer、滚动触发 PDF 文本层重建,确认失效高亮安全清理且插件无报错。
7. 关闭设置开关,确认全部视觉高亮立即清除;重新开启后无需重启。
8. 重复 `getLatestSelection` 和状态感知测试,确认视觉高亮不改变发送给 Claude 的正文。
## 网页转 Markdown
1. 打开普通文章、GitHub 页面和需要登录的页面。
2. 记录读取前的 URL、标题和页面状态。
3. 调用 `readCurrentWebPage`,确认返回的是当前这个精确标签的 Markdown。
4. 确认页面没有刷新、没有跳转、登录状态未丢失。
5. 在长页面的顶部调用工具,确认结果同时包含视口下方已经加载但尚未滚动到的正文。
6. 打开两个 URL 或标题相同的标签,内容保持不同;切换到其中一个再离开网页,确认工具只读取最后追踪的精确标签。
7. 测试 SPA 动态内容,确认读取当前可见 DOM不包含 `display: none` 的预载或 Reader 隐藏内容。
8. 测试 Canvas、跨域 iframe 和图片页面,确认返回明确的不可提取或部分提取状态。
9. 将“网页工具最大返回字符数”留空或设为 `0`,确认不截断;填写正整数后确认 `truncated` 标记和长度正确。
## 文件导航与 Diff
1. 用 `openFile` 测试含空格和中文的 Windows 路径。
2. 测试指定行、文本范围、仓库外路径拒绝和不存在文件。
3. 启动不预批准 `Edit` 的 Claude Code修改现有文件。
4. 确认 Obsidian 出现可编辑 Diff拒绝后文件不变。
5. 再次修改,在右侧更改候选文本后接受,确认只写入最终文本一次。
6. 关闭 Diff 标签,确认 Claude 收到 `DIFF_REJECTED`
7. 并发打开两个 Diff 并逆序处理。
8. 测试新文件和源文件审核期间被外部修改的冲突。
9. 使用 `acceptEdits` 重试,确认直接写入且不额外弹 Diff。
## 生命周期
1. 保持 Obsidian 开启并重启 Claude Code。
2. 保持 Claude Code 关闭并重启 Obsidian。
3. 制造首选端口冲突,确认 lock、MCP URL 和实际端口一致。
4. 禁用插件,确认服务和 lock 消失,未完成 Diff 被拒绝。
5. 重新启用插件,确认 MCP 自动修复注册。
每项记录通过/失败、截图、脱敏日志和复现步骤。Windows 实机完成前不要修改测试报告中的未验证声明。

246
main.ts
View file

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import {
MarkdownView,
Notice,
Plugin,
type WorkspaceLeaf,
@ -60,11 +60,23 @@ import {
import {
activeWorkspaceLeaf,
currentWorkspaceContext,
getOpenWorkspaceTabs,
} from "./src/workspace-context";
import { SelectionHighlightController } from "./src/selection-highlights";
import { TerminalSessionTracker } from "./src/terminal-session-tracker";
import { LlmFeature } from "./src/llm-feature";
import { InlineCompletionFeature } from "./src/inline-completion/inline-completion-feature";
import {
CodexIdeProvider,
type CodexIdeContextSnapshot,
} from "./src/codex-ide-provider";
import {
ensureCodexMcpRegistration,
removeCodexMcpRegistration,
ensureCodexShellAlias,
removeCodexShellAlias,
} from "./src/codex-mcp-registration";
import { syncAgentRulesInWorkspace } from "./src/agent-rules-manager";
import type {
BridgeClientContext,
BridgeSettings,
@ -78,7 +90,12 @@ export default class MvSenceAiIdePlugin extends Plugin {
settings: BridgeSettings = { ...DEFAULT_SETTINGS };
port = 0;
mcpStatus = "尚未检查";
codexMcpStatus = "Codex MCP 未启用";
claudeIdeError: string | null = null;
codexIdeError: string | null = null;
private server: BridgeServer | null = null;
private bridgeAuthToken: string | null = null;
private bridgeHasClaudeLock = false;
private readonly latestSelections = new Map<string, SelectionState>();
private latestWebLeaf: WorkspaceLeaf | null = null;
private readonly lastContexts = new Map<string, SelectionState>();
@ -90,30 +107,40 @@ export default class MvSenceAiIdePlugin extends Plugin {
private selectionHighlighter: SelectionHighlightController | null = null;
private llmFeature: LlmFeature | null = null;
private inlineCompletion: InlineCompletionFeature | null = null;
private codexIdeProvider: CodexIdeProvider | null = null;
private mcpRegistrationTimer: number | null = null;
private mcpRegistrationInFlight: Promise<void> | null = null;
private codexMcpRegistrationTimer: number | null = null;
private codexMcpRegistrationInFlight: Promise<void> | null = null;
private unloaded = false;
async onload(): Promise<void> {
this.unloaded = false;
const loaded = (await this.loadData()) as Partial<BridgeSettings> | null;
const rawLoaded = (await this.loadData()) as
| (Partial<BridgeSettings> & { codex?: unknown })
| null;
const { codex: _legacyCodex, ...loaded } = rawLoaded ?? {};
this.settings = {
...DEFAULT_SETTINGS,
...(loaded ?? {}),
...loaded,
activityTracking: {
...DEFAULT_SETTINGS.activityTracking,
...(loaded?.activityTracking ?? {}),
...(loaded.activityTracking ?? {}),
},
toolToggles: {
...DEFAULT_SETTINGS.toolToggles,
...(loaded?.toolToggles ?? {}),
...(loaded.toolToggles ?? {}),
},
toolContextLimits: {
...DEFAULT_SETTINGS.toolContextLimits,
...(loaded?.toolContextLimits ?? {}),
...(loaded.toolContextLimits ?? {}),
},
llm: migrateLlm(loaded?.llm),
inlineCompletion: migrateInlineCompletion(loaded?.inlineCompletion),
ideIntegrations: {
...DEFAULT_SETTINGS.ideIntegrations,
...(loaded.ideIntegrations ?? {}),
},
llm: migrateLlm(loaded.llm),
inlineCompletion: migrateInlineCompletion(loaded.inlineCompletion),
};
if (
process.platform === "win32" &&
@ -143,6 +170,25 @@ export default class MvSenceAiIdePlugin extends Plugin {
() => this.settings.toolContextLimits.readCurrentWebPage,
);
const pluginDir = path.join(
getVaultRoot(this.app),
this.app.vault.configDir,
"plugins",
this.manifest.id,
);
const customTmpDir = path.join(pluginDir, "tmp");
const socketPath = path.join(
customTmpDir,
"codex-ipc",
`ipc-${typeof process.getuid === "function" ? process.getuid() : 0}.sock`,
);
this.codexIdeProvider = new CodexIdeProvider({
getSnapshot: () => this.codexIdeContextSnapshot(),
socketPath,
onLog: (message) => console.error("[mv-senceai-ide]", message),
});
this.registerEvent(
this.app.workspace.on("active-leaf-change", () => {
this.terminalTracker?.scan();
@ -211,7 +257,13 @@ export default class MvSenceAiIdePlugin extends Plugin {
this.llmFeature.registerCommands();
this.llmFeature.registerMenus();
await this.startBridge();
await this.syncLocalServices();
void this.syncCodexIdeProvider();
void syncAgentRulesInWorkspace(
getVaultRoot(this.app),
this.settings.ideIntegrations.syncClaudeRules,
this.settings.ideIntegrations.syncCodexRules,
);
this.terminalTracker.scan();
this.selectionHighlighter.sync(true);
this.scheduleBroadcast();
@ -224,19 +276,26 @@ export default class MvSenceAiIdePlugin extends Plugin {
}
this.unloaded = true;
this.clearScheduledMcpRegistration();
this.clearScheduledCodexMcpRegistration();
this.selectionHighlighter?.destroy();
this.selectionHighlighter = null;
this.llmFeature?.dispose();
this.llmFeature = null;
this.inlineCompletion?.dispose();
this.inlineCompletion = null;
void removeCodexShellAlias();
void this.finishUnload();
}
async saveAndApplySettings(): Promise<void> {
await this.applyClaudeSettingsBestEffort(true);
await this.saveData(this.settings);
this.scheduleMcpRegistration(true);
await this.syncLocalServices(true);
void this.syncCodexIdeProvider();
void syncAgentRulesInWorkspace(
getVaultRoot(this.app),
this.settings.ideIntegrations.syncClaudeRules,
this.settings.ideIntegrations.syncCodexRules,
);
}
refreshLlmFeature(): void {
@ -255,7 +314,7 @@ export default class MvSenceAiIdePlugin extends Plugin {
async restartBridge(): Promise<void> {
await this.stopBridge();
await this.startBridge();
await this.syncLocalServices(true);
this.previousBroadcasts.clear();
this.scheduleBroadcast();
}
@ -269,6 +328,7 @@ export default class MvSenceAiIdePlugin extends Plugin {
private async finishUnload(): Promise<void> {
try {
await this.codexIdeProvider?.stop();
await this.restoreClaudeSettings();
await this.closeDiffs();
await this.stopBridge();
@ -296,10 +356,60 @@ export default class MvSenceAiIdePlugin extends Plugin {
await this.saveData(this.settings);
}
private async startBridge(): Promise<void> {
private shouldRunLocalServer(): boolean {
return (
this.settings.ideIntegrations.claudeCode ||
(this.settings.ideIntegrations.codex && this.settings.mcpEnabled)
);
}
private async syncLocalServices(notifyClaude = false): Promise<void> {
this.claudeIdeError = null;
if (this.shouldRunLocalServer() && !this.server) {
try {
await this.startBridge();
} catch (error) {
this.claudeIdeError = error instanceof Error ? error.message : String(error);
console.error("[mv-senceai-ide] Claude IDE bridge start failed", error);
}
} else if (!this.shouldRunLocalServer() && this.server) {
await this.stopBridge();
}
await this.syncClaudeIntegration(notifyClaude);
this.scheduleCodexMcpRegistration();
}
private async syncClaudeIntegration(notify = false): Promise<void> {
if (!this.settings.ideIntegrations.claudeCode || !this.server || !this.port) {
this.clearScheduledMcpRegistration();
if (this.bridgeHasClaudeLock && this.port) removeLockFile(this.port);
this.bridgeHasClaudeLock = false;
if (!this.settings.ideIntegrations.claudeCode) {
this.mcpStatus = "Claude Code IDE 已关闭";
await this.restoreClaudeSettings();
}
return;
}
cleanStaleObsidianLocks();
if (!this.bridgeHasClaudeLock) {
writeLockFile(
this.port,
getVaultRoot(this.app),
this.bridgeAuthToken ?? randomUUID(),
);
this.bridgeHasClaudeLock = true;
}
await this.applyClaudeSettingsBestEffort(notify);
if (!this.unloaded) await this.saveData(this.settings);
this.scheduleMcpRegistration();
}
private async startBridge(): Promise<void> {
const vaultRoot = getVaultRoot(this.app);
const authToken = randomUUID();
this.bridgeAuthToken = authToken;
this.server = new BridgeServer({
authToken,
mcpAuthToken: this.settings.mcpAuthToken,
@ -317,19 +427,17 @@ export default class MvSenceAiIdePlugin extends Plugin {
onLog: (message) => console.error("[mv-senceai-ide]", message),
});
this.port = await this.server.start();
writeLockFile(this.port, vaultRoot, authToken);
await this.applyClaudeSettingsBestEffort();
await this.saveData(this.settings);
this.scheduleMcpRegistration();
console.log(`[mv-senceai-ide] listening on 127.0.0.1:${this.port}`);
}
private async stopBridge(): Promise<void> {
const port = this.port;
this.port = 0;
this.bridgeAuthToken = null;
await this.server?.stop();
this.server = null;
if (port) removeLockFile(port);
if (port && this.bridgeHasClaudeLock) removeLockFile(port);
this.bridgeHasClaudeLock = false;
}
private async applyClaudeSettings(): Promise<void> {
@ -449,6 +557,12 @@ export default class MvSenceAiIdePlugin extends Plugin {
this.mcpRegistrationTimer = null;
}
private clearScheduledCodexMcpRegistration(): void {
if (this.codexMcpRegistrationTimer === null) return;
activeWindow.clearTimeout(this.codexMcpRegistrationTimer);
this.codexMcpRegistrationTimer = null;
}
private scheduleMcpRegistration(force = false): void {
this.clearScheduledMcpRegistration();
if (!this.port) return;
@ -505,10 +619,88 @@ export default class MvSenceAiIdePlugin extends Plugin {
}
}
private scheduleCodexMcpRegistration(): void {
this.clearScheduledCodexMcpRegistration();
this.codexMcpStatus =
this.settings.ideIntegrations.codex && this.settings.mcpEnabled
? "Codex MCP 后台检查中"
: "Codex MCP 未启用";
this.codexMcpRegistrationTimer = activeWindow.setTimeout(() => {
this.codexMcpRegistrationTimer = null;
void this.runCodexMcpRegistration();
}, 0);
}
private async runCodexMcpRegistration(): Promise<void> {
if (this.codexMcpRegistrationInFlight) {
await this.codexMcpRegistrationInFlight;
return;
}
const task = this.syncCodexMcpRegistration();
this.codexMcpRegistrationInFlight = task;
try {
await task;
} finally {
if (this.codexMcpRegistrationInFlight === task) {
this.codexMcpRegistrationInFlight = null;
}
}
}
private async syncCodexMcpRegistration(): Promise<void> {
if (!this.settings.ideIntegrations.codex || !this.settings.mcpEnabled) {
const result = await removeCodexMcpRegistration();
this.codexMcpStatus = result.ok
? "Codex MCP 未启用"
: `Codex MCP 清理失败:${result.message}`;
return;
}
const url = this.currentMcpUrl();
if (!url) {
this.codexMcpStatus = "Codex MCP 等待本地服务";
return;
}
const result = await ensureCodexMcpRegistration(
url,
this.settings.mcpAuthToken,
);
this.codexMcpStatus = result.ok
? result.message
: `Codex MCP 配置失败:${result.message}`;
}
private currentMcpUrl(): string | null {
return this.port ? `http://127.0.0.1:${this.port}/mcp` : null;
}
private async syncCodexIdeProvider(): Promise<void> {
if (!this.codexIdeProvider) return;
this.codexIdeError = null;
if (!this.settings.ideIntegrations.codex) {
await this.codexIdeProvider.stop();
await removeCodexShellAlias();
return;
}
try {
await this.codexIdeProvider.start();
const pluginDir = path.join(
getVaultRoot(this.app),
this.app.vault.configDir,
"plugins",
this.manifest.id,
);
const customTmpDir = path.join(pluginDir, "tmp");
await ensureCodexShellAlias(customTmpDir, this.settings.codexExecutable || "codex");
} catch (error) {
this.codexIdeError = error instanceof Error ? error.message : String(error);
console.error("[mv-senceai-ide] Codex IDE provider failed", error);
await removeCodexShellAlias();
}
}
private async broadcastSelection(): Promise<void> {
const generation = ++this.broadcastGeneration;
const leaf = activeWorkspaceLeaf(this.app);
@ -583,6 +775,20 @@ export default class MvSenceAiIdePlugin extends Plugin {
return latestSelectionForContext(this.latestSelections, context);
}
private async codexIdeContextSnapshot(): Promise<CodexIdeContextSnapshot> {
const leaf = activeWorkspaceLeaf(this.app);
const activeState =
(await currentWorkspaceContext(this.app, leaf)) ??
currentSelection(this.app);
const current = this.resolveTrackedState(undefined, leaf, activeState);
if (current) this.rememberState("global", current);
return {
vaultRoot: getVaultRoot(this.app),
current,
openEditors: getOpenWorkspaceTabs(this.app).tabs,
};
}
private resolveTrackedState(
context: BridgeClientContext | undefined,
leaf: WorkspaceLeaf | null,
@ -608,6 +814,10 @@ export default class MvSenceAiIdePlugin extends Plugin {
}
private async syncMcpRegistration(force = false): Promise<void> {
if (!this.settings.ideIntegrations.claudeCode) {
this.mcpStatus = "Claude Code IDE 已关闭";
return;
}
if (!this.port) return;
if (!this.settings.mcpEnabled) {
if (force || this.settings.registeredMcpUrl) {

View file

@ -1,9 +1,9 @@
{
"id": "mv-obcc",
"name": "mv-SenceAI",
"version": "0.4.2",
"version": "0.5.0",
"minAppVersion": "1.7.2",
"description": "Connects local vaults to Claude Code with page tracking, session-aware terminals, MCP tools, editable diffs, and optional upstream compatibility.",
"description": "Connects local vaults to Claude Code and Codex CLI with page tracking, Obsidian tools, editable diffs, and optional upstream compatibility.",
"author": "MV",
"isDesktopOnly": true
}

4412
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,16 @@
{
"name": "mv-obcc",
"version": "0.4.2",
"description": "A focused Claude Code IDE bridge for Obsidian.",
"version": "0.5.0",
"description": "A focused Claude Code and Codex CLI IDE bridge for Obsidian.",
"main": "main.js",
"type": "module",
"scripts": {
"build": "node esbuild.config.mjs production",
"dev": "node esbuild.config.mjs",
"lint": "eslint main.ts src",
"typecheck": "tsc --noEmit",
"verify": "npm run typecheck && npm run build",
"verify": "npm run lint && npm run typecheck && npm run build",
"deploy:local": "npm run build && node scripts/deploy-local.mjs",
"package": "node scripts/package.mjs"
},
"dependencies": {
@ -21,8 +23,11 @@
"@types/archiver": "^6.0.3",
"@types/node": "^22.18.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/parser": "^8.61.0",
"archiver": "^7.0.1",
"esbuild": "^0.28.1",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.3.0",
"obsidian": "^1.8.7",
"typescript": "^5.9.2"
}

130
src/agent-rules-manager.ts Normal file
View file

@ -0,0 +1,130 @@
import fs from "node:fs";
import path from "node:path";
const TAG_BEGIN = "# BEGIN mv-SenceAI 规则";
const TAG_END = "# END mv-SenceAI 规则";
const RULE_CONTENT = `${TAG_BEGIN}
Obsidian Markdown PythonJavascriptMatlabHTMLMathematica
1. 使 \`-<lang_suffix>.md\` 结尾(例如 \`test-py.md\`\`helper-js.md\`\`plot-m.md\`),严禁直接创建原始代码后缀(如 \`.py\`\`.js\`\`.m\`)的文件。
2. Markdown 使
- Python ( -py.md ):
#
\`\`\`python
#
print("Hello World")
\`\`\`
- Matlab ( -m.md ):
%
\`\`\`matlab
%
disp('Hello World')
\`\`\`
- Mathematica ( -nb.md -wls.md ):
(*
\`\`\`mathematica
*)
(*
Plot[Sin[x], {x, 0, 2 Pi}]
*)
- HTML ( -html.md ):
<!--
\`\`\`html
-->
<!--
<div>Hello World</div>
-->
${TAG_END}`;
export async function syncAgentRulesInWorkspace(
vaultRoot: string,
syncClaude: boolean,
syncCodex: boolean,
): Promise<void> {
await syncRuleFamily(
[
path.join(vaultRoot, "CLAUDE.md"),
path.join(vaultRoot, ".claude", "CLAUDE.md"),
],
syncClaude,
);
await syncRuleFamily(
[
path.join(vaultRoot, "AGENTS.md"),
path.join(vaultRoot, ".codex", "AGENTS.md"),
],
syncCodex,
);
}
async function syncRuleFamily(
paths: readonly [string, ...string[]],
enabled: boolean,
): Promise<void> {
if (!enabled) {
await Promise.all(
paths.filter((filePath) => fs.existsSync(filePath)).map((filePath) =>
updateFileRule(filePath, false),
),
);
return;
}
const existing = paths.filter((filePath) => fs.existsSync(filePath));
const target =
existing.find((filePath) => fileHasManagedBlock(filePath)) ??
existing[0] ??
paths[0];
await updateFileRule(target, true);
}
function fileHasManagedBlock(filePath: string): boolean {
try {
const content = fs.readFileSync(filePath, "utf8");
return content.includes(TAG_BEGIN) && content.includes(TAG_END);
} catch {
return false;
}
}
async function updateFileRule(filePath: string, enabled: boolean): Promise<void> {
try {
let content = "";
if (fs.existsSync(filePath)) {
content = fs.readFileSync(filePath, "utf8");
}
// Strip existing rule block if present
const start = content.indexOf(TAG_BEGIN);
if (start >= 0) {
const end = content.indexOf(TAG_END, start);
if (end >= 0) {
content =
content.slice(0, start).trimEnd() +
"\n\n" +
content.slice(end + TAG_END.length).trimStart();
}
}
if (enabled) {
content = content.trimEnd() + "\n\n" + RULE_CONTENT + "\n";
} else {
content = content.trimEnd() + "\n";
}
// Clean up empty lines at the end
content = content.trim() === "" ? "" : content;
if (content === "") {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} else {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, content, "utf8");
}
} catch (e) {
console.error(`[mv-obcc] Failed to update rule file ${filePath}`, e);
}
}

View file

@ -75,8 +75,8 @@ export class BridgeServer {
private websocketServer: WebSocketServer | null = null;
private clients = new Map<WebSocket, BridgeClientContext>();
private mcpSessions = new Map<string, BridgeClientContext>();
private readonly identityByRemotePort = new Map<
number,
private readonly identityBySocket = new WeakMap<
net.Socket,
Promise<Pick<BridgeClientContext, "processId" | "sessionId">>
>();
private heartbeat: NodeJS.Timeout | null = null;
@ -122,7 +122,6 @@ export class BridgeServer {
this.websocketServer?.close();
this.websocketServer = null;
this.mcpSessions.clear();
this.identityByRemotePort.clear();
await new Promise<void>((resolve) => {
if (!this.server) return resolve();
this.server.close(() => resolve());
@ -160,7 +159,7 @@ export class BridgeServer {
this.options.onClientContextChanged?.({ ...context });
const remotePort = request.socket.remotePort;
if (remotePort) {
void this.resolveContextIdentity(context, remotePort).then(() => {
void this.resolveContextIdentity(context, request.socket, remotePort).then(() => {
this.options.onClientContextChanged?.({ ...context });
});
}
@ -368,7 +367,9 @@ export class BridgeServer {
channel: "mcp",
};
const remotePort = request.socket.remotePort;
if (remotePort) await this.resolveContextIdentity(context, remotePort);
if (remotePort) {
await this.resolveContextIdentity(context, request.socket, remotePort);
}
}
const rpcResponse = await this.options.onMcpMessage(rpcRequest, {
...context,
@ -405,9 +406,10 @@ export class BridgeServer {
private async resolveContextIdentity(
context: BridgeClientContext,
socket: net.Socket,
remotePort: number,
): Promise<void> {
let pending = this.identityByRemotePort.get(remotePort);
let pending = this.identityBySocket.get(socket);
if (!pending) {
const resolver =
this.options.resolveClientIdentity ?? resolveClaudeClientIdentity;
@ -421,7 +423,7 @@ export class BridgeServer {
: {},
)
.catch(() => ({}));
this.identityByRemotePort.set(remotePort, pending);
this.identityBySocket.set(socket, pending);
}
Object.assign(context, await pending);
}

300
src/codex-ide-provider.ts Normal file
View file

@ -0,0 +1,300 @@
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import type { OpenEditorTab, SelectionState } from "./types";
export interface CodexIdeContextSnapshot {
vaultRoot: string;
current: SelectionState | null;
openEditors: OpenEditorTab[];
}
interface CodexIdeProviderOptions {
getSnapshot: () => Promise<CodexIdeContextSnapshot>;
socketPath?: string;
clientId?: string;
onLog?: (message: string) => void;
}
interface PendingFrame {
buffer: Buffer;
}
const CODEX_TUI_CLIENT_ID = "codex-tui";
const DEFAULT_CLIENT_ID = "mv-senceai-obsidian";
const MAX_FRAME_BYTES = 256 * 1024 * 1024;
export function defaultCodexIdeSocketPath(): string {
if (process.platform === "win32") {
return "\\\\.\\pipe\\codex-ipc";
}
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
return path.join(os.tmpdir(), "codex-ipc", `ipc-${uid}.sock`);
}
export function encodeCodexIdeFrame(message: unknown): Buffer {
const payload = Buffer.from(JSON.stringify(message), "utf8");
const header = Buffer.allocUnsafe(4);
header.writeUInt32LE(payload.length, 0);
return Buffer.concat([header, payload]);
}
function decodeCodexIdeFrames(state: PendingFrame): unknown[] {
const messages: unknown[] = [];
while (state.buffer.length >= 4) {
const length = state.buffer.readUInt32LE(0);
if (length > MAX_FRAME_BYTES) {
throw new Error("Codex IDE context frame is too large.");
}
if (state.buffer.length < 4 + length) break;
const payload = state.buffer.subarray(4, 4 + length);
messages.push(JSON.parse(payload.toString("utf8")));
state.buffer = state.buffer.subarray(4 + length);
}
return messages;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function isPathInside(child: string, parent: string): boolean {
const resolvedChild = path.resolve(child);
const resolvedParent = path.resolve(parent);
const relative = path.relative(resolvedParent, resolvedChild);
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
}
function selectionRange(state: SelectionState) {
return {
start: {
line: state.selection.start.line,
character: state.selection.start.character,
},
end: {
line: state.selection.end.line,
character: state.selection.end.character,
},
};
}
function descriptorFromSelection(state: SelectionState) {
return {
label: state.title || path.basename(state.relativePath || state.filePath),
path: state.relativePath || state.url || state.filePath,
fsPath:
state.resourceType === "markdown" ||
state.resourceType === "pdf" ||
state.resourceType === "file"
? state.filePath
: undefined,
};
}
function descriptorFromTab(tab: OpenEditorTab) {
return {
label: tab.label || tab.relativePath || tab.url || tab.uri,
path: tab.relativePath || tab.url || tab.uri,
fsPath: tab.filePath,
};
}
function buildIdeContext(snapshot: CodexIdeContextSnapshot) {
return {
activeFile: snapshot.current
? {
...descriptorFromSelection(snapshot.current),
selection: selectionRange(snapshot.current),
activeSelectionContent: snapshot.current.selection.text,
selections: [selectionRange(snapshot.current)],
}
: null,
openTabs: snapshot.openEditors.map(descriptorFromTab),
};
}
async function activeUnixSocketExists(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, 150);
socket.once("connect", () => {
clearTimeout(timer);
socket.end();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timer);
resolve(false);
});
});
}
async function prepareUnixSocket(socketPath: string): Promise<void> {
const directory = path.dirname(socketPath);
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
try {
fs.chmodSync(directory, 0o700);
} catch {
// Best effort: Codex validates ownership and write bits before connecting.
}
if (!fs.existsSync(socketPath)) return;
if (await activeUnixSocketExists(socketPath)) {
throw new Error(`Codex IDE socket is already in use: ${socketPath}`);
}
fs.unlinkSync(socketPath);
}
export class CodexIdeProvider {
private server: net.Server | null = null;
private socketPath = "";
constructor(private readonly options: CodexIdeProviderOptions) {}
get isRunning(): boolean {
return this.server !== null;
}
async start(): Promise<void> {
if (this.server) return;
const socketPath = this.options.socketPath ?? defaultCodexIdeSocketPath();
if (process.platform !== "win32") {
await prepareUnixSocket(socketPath);
}
const server = net.createServer((socket) => this.handleConnection(socket));
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
server.off("error", onError);
server.off("listening", onListening);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onListening = () => {
cleanup();
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(socketPath);
});
this.server = server;
this.socketPath = socketPath;
this.options.onLog?.(`Codex IDE provider listening on ${socketPath}`);
}
async stop(): Promise<void> {
const server = this.server;
const socketPath = this.socketPath;
this.server = null;
this.socketPath = "";
await new Promise<void>((resolve) => {
if (!server) return resolve();
server.close(() => resolve());
});
if (socketPath && process.platform !== "win32") {
try {
fs.unlinkSync(socketPath);
} catch {
// Already removed or owned by another process.
}
}
}
private handleConnection(socket: net.Socket): void {
const state: PendingFrame = { buffer: Buffer.alloc(0) };
socket.on("data", (chunk) => {
try {
state.buffer = Buffer.concat([state.buffer, Buffer.from(chunk)]);
for (const message of decodeCodexIdeFrames(state)) {
void this.handleMessage(socket, message).catch((error) => {
this.options.onLog?.(`Codex IDE request failed: ${String(error)}`);
socket.destroy();
});
}
} catch (error) {
this.options.onLog?.(`Codex IDE frame error: ${String(error)}`);
socket.destroy();
}
});
socket.on("error", (error) => {
this.options.onLog?.(`Codex IDE socket error: ${error.message}`);
});
}
private async handleMessage(socket: net.Socket, message: unknown): Promise<void> {
const request = asRecord(message);
const type = typeof request.type === "string" ? request.type : "";
if (type === "client-discovery-request") {
this.write(socket, {
type: "client-discovery-response",
requestId: request.requestId,
response: { canHandle: true },
});
return;
}
if (type !== "request") return;
const requestId = String(request.requestId ?? "");
const method = String(request.method ?? "");
if (!requestId) return;
if (method !== "ide-context") {
this.writeError(socket, requestId, "no-handler-for-request");
return;
}
const params = asRecord(request.params);
const workspaceRoot = String(params.workspaceRoot ?? "");
const snapshot = await this.options.getSnapshot();
if (!workspaceRoot || !isPathInside(workspaceRoot, snapshot.vaultRoot)) {
this.writeError(socket, requestId, "no-client-found", method);
return;
}
this.write(socket, {
type: "response",
requestId,
resultType: "success",
method,
handledByClientId: this.options.clientId ?? DEFAULT_CLIENT_ID,
result: {
type: "broadcast",
ideContext: buildIdeContext(snapshot),
},
});
}
private writeError(
socket: net.Socket,
requestId: string,
error: string,
method?: string,
): void {
this.write(socket, {
type: "response",
requestId,
resultType: "error",
...(method ? { method } : {}),
handledByClientId: this.options.clientId ?? DEFAULT_CLIENT_ID,
error,
});
}
private write(socket: net.Socket, message: unknown): void {
const sourceClientId =
asRecord(message).type === "request" ? CODEX_TUI_CLIENT_ID : undefined;
socket.write(
encodeCodexIdeFrame(
sourceClientId ? { ...(message as Record<string, unknown>), sourceClientId } : message,
),
);
}
}

View file

@ -0,0 +1,165 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export interface CodexMcpRegistrationResult {
ok: boolean;
message: string;
}
interface CodexMcpRegistrationRuntime {
configPath?: string;
}
const MANAGED_BEGIN = "# BEGIN mv-SenceAI managed Codex MCP server";
const MANAGED_END = "# END mv-SenceAI managed Codex MCP server";
const SERVER_NAME = "mv_senceai_obsidian";
export function defaultCodexConfigPath(): string {
return path.join(os.homedir(), ".codex", "config.toml");
}
function tomlString(value: string): string {
return JSON.stringify(value);
}
function managedBlock(url: string, authToken: string): string {
return [
MANAGED_BEGIN,
`[mcp_servers.${SERVER_NAME}]`,
`url = ${tomlString(url)}`,
`http_headers = { Authorization = ${tomlString(`Bearer ${authToken}`)} }`,
"enabled = true",
"startup_timeout_sec = 10",
"tool_timeout_sec = 60",
MANAGED_END,
].join("\n");
}
function stripManagedBlock(content: string): string {
const start = content.indexOf(MANAGED_BEGIN);
if (start < 0) return content;
const end = content.indexOf(MANAGED_END, start);
if (end < 0) return content;
const before = content.slice(0, start).trimEnd();
const after = content.slice(end + MANAGED_END.length).trimStart();
return [before, after].filter(Boolean).join("\n\n") + (before || after ? "\n" : "");
}
function upsertManagedBlock(content: string, block: string): string {
const stripped = stripManagedBlock(content).trimEnd();
return stripped ? `${stripped}\n\n${block}\n` : `${block}\n`;
}
function readIfExists(filePath: string): string {
try {
return fs.readFileSync(filePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
throw error;
}
}
function writeFileAtomic(filePath: string, content: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
const temporary = `${filePath}.mv-senceai.tmp`;
fs.writeFileSync(temporary, content, { mode: 0o600 });
fs.renameSync(temporary, filePath);
}
export async function ensureCodexMcpRegistration(
url: string,
authToken: string,
runtime: CodexMcpRegistrationRuntime = {},
): Promise<CodexMcpRegistrationResult> {
try {
const configPath = runtime.configPath ?? defaultCodexConfigPath();
const current = readIfExists(configPath);
writeFileAtomic(configPath, upsertManagedBlock(current, managedBlock(url, authToken)));
return { ok: true, message: "Codex MCP 已配置" };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : String(error),
};
}
}
export async function removeCodexMcpRegistration(
runtime: CodexMcpRegistrationRuntime = {},
): Promise<CodexMcpRegistrationResult> {
try {
const configPath = runtime.configPath ?? defaultCodexConfigPath();
const current = readIfExists(configPath);
const next = stripManagedBlock(current);
if (next !== current) writeFileAtomic(configPath, next);
return { ok: true, message: "Codex MCP 已移除" };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : String(error),
};
}
}
const ALIAS_MANAGED_BEGIN = "# BEGIN mv-obcc managed Codex environment";
const ALIAS_MANAGED_END = "# END mv-obcc managed Codex environment";
export async function ensureCodexShellAlias(tmpDir: string, codexExecutable: string = "codex"): Promise<void> {
const home = os.homedir();
const targets = [
path.join(home, ".zshrc"),
path.join(home, ".bash_profile"),
path.join(home, ".bashrc"),
];
const block = [
ALIAS_MANAGED_BEGIN,
`alias codex="TMPDIR='${tmpDir}' ${codexExecutable}"`,
ALIAS_MANAGED_END,
].join("\n");
for (const target of targets) {
if (!fs.existsSync(target)) continue;
try {
let content = fs.readFileSync(target, "utf8");
// strip old block first
const start = content.indexOf(ALIAS_MANAGED_BEGIN);
if (start >= 0) {
const end = content.indexOf(ALIAS_MANAGED_END, start);
if (end >= 0) {
content = content.slice(0, start).trimEnd() + "\n\n" + content.slice(end + ALIAS_MANAGED_END.length).trimStart();
}
}
content = content.trimEnd() + "\n\n" + block + "\n";
fs.writeFileSync(target, content, "utf8");
} catch (e) {
console.error(`[mv-obcc] Failed to write shell alias to ${target}`, e);
}
}
}
export async function removeCodexShellAlias(): Promise<void> {
const home = os.homedir();
const targets = [
path.join(home, ".zshrc"),
path.join(home, ".bash_profile"),
path.join(home, ".bashrc"),
];
for (const target of targets) {
if (!fs.existsSync(target)) continue;
try {
let content = fs.readFileSync(target, "utf8");
const start = content.indexOf(ALIAS_MANAGED_BEGIN);
if (start >= 0) {
const end = content.indexOf(ALIAS_MANAGED_END, start);
if (end >= 0) {
content = content.slice(0, start).trimEnd() + "\n\n" + content.slice(end + ALIAS_MANAGED_END.length).trimStart();
fs.writeFileSync(target, content.trimEnd() + "\n", "utf8");
}
}
} catch (e) {
console.error(`[mv-obcc] Failed to remove shell alias from ${target}`, e);
}
}
}

View file

@ -68,6 +68,12 @@ export const DEFAULT_SETTINGS = {
toolContextLimits: {
readCurrentWebPage: null,
},
ideIntegrations: {
claudeCode: true,
codex: false,
syncClaudeRules: true,
syncCodexRules: true,
},
llm: {
enabled: false,
providers: [
@ -140,6 +146,7 @@ export const DEFAULT_SETTINGS = {
mcpEnabled: true,
mcpAuthToken: "",
claudeExecutable: "",
codexExecutable: "codex",
registeredMcpUrl: null,
windowsMcpRegistrationVersion: 0,
};

View file

@ -1,23 +0,0 @@
export type DiffDecision = "accept" | "reject";
export class DiffSession {
private resolved = false;
constructor(
readonly id: string,
readonly originalContents: string,
readonly proposedContents: string,
private readonly resolver: (decision: DiffDecision, contents: string) => void,
) {}
resolve(decision: DiffDecision, contents = this.proposedContents): boolean {
if (this.resolved) return false;
this.resolved = true;
this.resolver(decision, contents);
return true;
}
get isResolved(): boolean {
return this.resolved;
}
}

View file

@ -28,6 +28,8 @@ export interface InlineCompletionControllerDeps {
handlers: KeymapHandlers;
/** Called on every view update; feature code classifies trigger/cancel/ignore. */
onViewUpdate?: (update: ViewUpdate) => void;
/** Called when a CodeMirror editor view is destroyed. */
onViewDestroy?: (view: EditorView) => void;
}
export class InlineCompletionController {
@ -57,6 +59,7 @@ export class InlineCompletionController {
return {
destroy() {
controller.views.delete(view);
controller.deps.onViewDestroy?.(view);
},
};
}),

View file

@ -21,7 +21,6 @@ import {
import {
displayableCompletionStreamText,
finalCompletionText,
NO_COMPLETION_SENTINEL,
} from "./inline-completion-protocol";
import { buildRejectUserMessage } from "./inline-reject-prompt";
import { readSuggestion } from "./inline-suggestion-state";
@ -87,6 +86,7 @@ export class InlineCompletionFeature {
onRequest: (view) => this.handleManualRequest(view),
},
onViewUpdate: (update) => this.handleViewUpdate(update),
onViewDestroy: (view) => this.handleViewDestroy(view),
});
}
@ -488,6 +488,12 @@ export class InlineCompletionFeature {
if (clearVisible) this.controller.clearSuggestion(view);
}
private handleViewDestroy(view: EditorView): void {
this.cancelView(view, false);
this.conversations.delete(view);
this.activeRequestIds.delete(view);
}
/** Cancel all timers/requests and clear all visible suggestions. */
private cancelAll(): void {
for (const timer of this.debounceTimers.values()) {
@ -496,6 +502,8 @@ export class InlineCompletionFeature {
this.debounceTimers.clear();
for (const ac of this.abortControllers.values()) ac.abort();
this.abortControllers.clear();
this.conversations.clear();
this.activeRequestIds.clear();
this.controller.clearAll();
}
}

View file

@ -1,8 +1,4 @@
import {
DEFAULT_SETTINGS,
DEFAULT_INLINE_SYSTEM_PROMPT_BODY,
DEFAULT_INLINE_NO_COMPLETION_PROMPT,
} from "../constants";
import { DEFAULT_SETTINGS } from "../constants";
import type {
InlineCompletionKeymap,
InlineCompletionSettings,

View file

@ -16,8 +16,8 @@ class GhostTextWidget extends WidgetType {
return other.text === this.text;
}
toDOM(): HTMLElement {
const span = document.createElement("span");
toDOM(view: EditorView): HTMLElement {
const span = view.dom.ownerDocument.createElement("span");
span.textContent = this.text;
span.className = "mv-senceai-ghost-text";
return span;

View file

@ -35,22 +35,6 @@ export type NormalizedKey =
| { kind: "code"; code: string }
| { kind: "key"; key: string };
const KEY_NORMALIZED: ReadonlySet<string> = new Set([
"Escape",
"Enter",
"Tab",
"Backspace",
"Space",
"Home",
"End",
"PageUp",
"PageDown",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
]);
/**
* Normalize a stored key into a match strategy.
* - Single ASCII letter match `event.code` as `KeyX` (mac Option-safe).

View file

@ -133,7 +133,6 @@ export class LlmResultSurface implements LlmResultSink {
private errorMessage = "";
private fallbackReason = "";
private rootEl: HTMLElement | null = null;
private titleEl: HTMLElement | null = null;
private statusEl: HTMLElement | null = null;
private hostEl: HTMLElement | null = null;
private toolbarEl: HTMLElement | null = null;
@ -273,7 +272,6 @@ export class LlmResultSurface implements LlmResultSink {
}
this.rootEl?.remove();
this.rootEl = null;
this.titleEl = null;
this.statusEl = null;
this.hostEl = null;
this.toolbarEl = null;
@ -407,7 +405,6 @@ export class LlmResultSurface implements LlmResultSink {
this.doc.body.appendChild(root);
this.rootEl = root;
this.titleEl = title;
this.statusEl = status;
this.hostEl = host;
this.toolbarEl = toolbar;

View file

@ -43,27 +43,70 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
containerEl.empty();
addHeading(containerEl, "mv-SenceAI IDE");
new Setting(containerEl)
.setName("桥接状态")
.setDesc("插件启用后被动运行。建议先启动 Obsidian再启动 Claude Code。")
.addText((text) => {
text
.setValue(
this.plugin.port
? `已连接127.0.0.1:${this.plugin.port}`
: "未连接",
)
.setDisabled(true);
text.inputEl.addClass("mv-senceai-status");
});
containerEl.createEl("div", {
text: "🔌 IDE 桥接",
cls: "mv-senceai-section-title setting-item-name",
});
const claudeSetting = new Setting(containerEl)
.setName("启用 Claude Code IDE 功能")
.setDesc("默认开启。关闭后不写 Claude IDE lock、不注册 Claude MCP、不接管 Claude 设置。")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.ideIntegrations.claudeCode)
.onChange(async (value) => {
this.plugin.settings.ideIntegrations.claudeCode = value;
await this.plugin.saveAndApplySettings();
this.display();
}),
);
const claudeStatusEl = claudeSetting.settingEl.createEl("span", {
cls: "mv-senceai-status-indicator",
});
if (!this.plugin.settings.ideIntegrations.claudeCode) {
claudeStatusEl.setText("状态:已禁用");
claudeStatusEl.addClass("mv-senceai-status-muted");
} else if (this.plugin.claudeIdeError) {
claudeStatusEl.setText(`● 启动失败: ${this.plugin.claudeIdeError}`);
claudeStatusEl.addClass("mv-senceai-status-error");
} else {
claudeStatusEl.setText("● 运行中");
claudeStatusEl.addClass("mv-senceai-status-success");
}
const codexSetting = new Setting(containerEl)
.setName("启用 Codex IDE 功能")
.setDesc("默认关闭。开启后支持 Codex CLI /ide并把本插件 MCP 工具写入 Codex 配置。")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.ideIntegrations.codex)
.onChange(async (value) => {
this.plugin.settings.ideIntegrations.codex = value;
await this.plugin.saveAndApplySettings();
this.display();
}),
);
const codexStatusEl = codexSetting.settingEl.createEl("span", {
cls: "mv-senceai-status-indicator",
});
if (!this.plugin.settings.ideIntegrations.codex) {
codexStatusEl.setText("状态:已禁用");
codexStatusEl.addClass("mv-senceai-status-muted");
} else if (this.plugin.codexIdeError) {
codexStatusEl.setText(`● 启动失败: ${this.plugin.codexIdeError}`);
codexStatusEl.addClass("mv-senceai-status-error");
} else {
codexStatusEl.setText("● 运行中");
codexStatusEl.addClass("mv-senceai-status-success");
}
addHeading(containerEl, "功能与工具");
addHeading(containerEl, "被动:状态感知");
addHeading(containerEl, "被动");
addHeading(containerEl, "状态感知");
new Setting(containerEl)
.setName("支持所有活动页面")
.setDesc(
@ -121,6 +164,34 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
);
}
addHeading(containerEl, "非 MD 源码编写");
new Setting(containerEl)
.setName("同步 Claude Code (CLAUDE.md) 规则")
.setDesc("启用后自动在 CLAUDE.md 中注入中文规约,指导 AI 使用 md 代码文件(文件名-后缀.md保存非 Markdown 代码。注意:开启本项会直接创建或修改此规则文件。")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.ideIntegrations.syncClaudeRules)
.onChange(async (value) => {
this.plugin.settings.ideIntegrations.syncClaudeRules = value;
await this.plugin.saveAndApplySettings();
this.display();
}),
);
new Setting(containerEl)
.setName("同步 Codex (AGENTS.md) 规则")
.setDesc("启用后自动在 AGENTS.md 中注入中文规约,指导 AI 使用 md 代码文件(文件名-后缀.md保存非 Markdown 代码。注意:开启本项会直接创建或修改此规则文件。")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.ideIntegrations.syncCodexRules)
.onChange(async (value) => {
this.plugin.settings.ideIntegrations.syncCodexRules = value;
await this.plugin.saveAndApplySettings();
this.display();
}),
);
addHeading(containerEl, "视觉辅助");
new Setting(containerEl)
.setName("切换标签时保留选区高亮")
@ -139,7 +210,7 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
new Setting(containerEl)
.setName("启用 MCP 主动工具")
.setDesc(
"Claude Code 会过滤普通 IDE 工具,因此主动工具通过标准 MCP 提供。改变后请重新启动 Claude Code。",
"主动工具通过标准 MCP 提供给 Claude Code 和 Codex CLI。改变后请重启对应客户端或重新执行 /mcp。",
)
.addToggle((toggle) =>
toggle
@ -254,6 +325,19 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
await this.plugin.saveData(this.plugin.settings);
}),
);
new Setting(containerEl)
.setName("Codex 可执行文件")
.setDesc("通常自动检测为 codex。自定义安装位置可在此填写完整路径。")
.addText((text) =>
text
.setPlaceholder("codex")
.setValue(this.plugin.settings.codexExecutable)
.onChange(async (value) => {
this.plugin.settings.codexExecutable = value.trim();
await this.plugin.saveData(this.plugin.settings);
}),
);
}
addHeading(containerEl, "上游兼容");
@ -344,6 +428,8 @@ export class MvSenceAiIdeSettingTab extends PluginSettingTab {
}),
);
containerEl.createEl("div", {
text: "🤖 API 提供商(划词助手与行内补全共用)",
cls: "mv-senceai-section-title setting-item-name",

View file

@ -19,6 +19,17 @@ export interface ToolContextLimits {
readCurrentWebPage: number | null;
}
export interface IdeIntegrationSettings {
/** Claude Code IDE bridge. Fresh installs keep the original behavior enabled. */
claudeCode: boolean;
/** Codex CLI /ide context provider and managed MCP config. Fresh installs default off. */
codex: boolean;
/** Whether to sync markdown coding rules to CLAUDE.md */
syncClaudeRules: boolean;
/** Whether to sync markdown coding rules to AGENTS.md */
syncCodexRules: boolean;
}
export type LlmProviderType = "openai" | "anthropic";
/** How a model wants "thinking" params applied (gated by the template toggle). */
@ -163,11 +174,13 @@ export interface BridgeSettings {
preserveSelectionHighlights: boolean;
toolToggles: ToolToggles;
toolContextLimits: ToolContextLimits;
ideIntegrations: IdeIntegrationSettings;
llm: LlmFeatureSettings;
inlineCompletion: InlineCompletionSettings;
mcpEnabled: boolean;
mcpAuthToken: string;
claudeExecutable: string;
codexExecutable: string;
registeredMcpUrl: string | null;
windowsMcpRegistrationVersion: number;
}

View file

@ -53,11 +53,6 @@
height: 100%;
}
.mv-senceai-status {
font-family: var(--font-monospace);
font-size: var(--font-ui-smaller);
}
.mv-senceai-persistent-selection {
background-color: var(--text-selection);
}
@ -371,6 +366,22 @@
font-weight: 700;
}
.mv-senceai-status-indicator {
margin-left: 12px;
}
.mv-senceai-status-muted {
color: var(--text-muted);
}
.mv-senceai-status-error {
color: var(--text-error);
}
.mv-senceai-status-success {
color: var(--text-success);
}
/* 划词助手API 提供商卡片 */
.mv-senceai-llm-provider {
border: 1px solid var(--background-modifier-border);

View file

@ -18,5 +18,6 @@
"0.3.8": "1.7.2",
"0.4.0": "1.7.2",
"0.4.1": "1.7.2",
"0.4.2": "1.7.2"
"0.4.5": "1.7.2",
"0.5.0": "1.7.2"
}