opencode-idea 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,59 +1,75 @@
1
1
  # opencode-idea
2
2
 
3
- OpenCode 插件,面向 JetBrains 项目 (IntelliJ IDEA / PyCharm / WebStorm 等),做三件事:
3
+ OpenCode 插件,面向 JetBrains 项目 (IntelliJ IDEA / PyCharm / WebStorm 等),做两件事:
4
4
 
5
5
  1. **接入 IDE MCP** —— 用 `/open-in-idea` 把当前项目在 IntelliJ IDEA 中打开,并把这个 IDE 的 MCP 服务 (60 个工具:
6
6
  检索、读、改、构建、重构、调试、数据库……)注册进 OpenCode。
7
7
  2. **优先使用 IDE 工具** —— IDE 可用时,向模型注入"项目内操作优先使用 IDEA MCP"的系统提示引导 + 能力映射;IDE 不可用时回退原生工具
8
8
  (正常回退,无需声明)。
9
- 3. **注入 IDE 环境变量** —— 从 IDE 集成终端读取它实际运行的环境,注入每个 OpenCode shell。
9
+
10
+ > **项目命令走 IDE 终端**:跑 `java` / `python` / `npm` 这类项目命令时,引导模型用 `idea_execute_terminal_command`
11
+ > (`executeInShell: true`)而不是原生 shell —— 它在 IDE 集成终端里执行,**天然继承用户在 IDE 里配好的 JDK / Node / Python 版本**
12
+ > (含 fnm / SDKMAN 这类靠 shell init 切版本的场景)。详见「项目命令执行」。
10
13
 
11
14
  非 JetBrains 项目完全不受影响。 **手动接入**:插件启动不探测、不连接 IDE,只有跑 `/open-in-idea` 才接入。跑过一次后,该**项目**
12
- 被「标记」(持久化在 `ctx.storage`,重启 OpenCode 后仍在):之后每条用户消息都默认 IDE 仍在,一旦发现 IDE MCP 未连接,插件会在
13
- 构建本次请求前自动重跑一遍接入流程并按需补一条通知。`/close-in-idea` 删除该标记。没跑过 `/open-in-idea` 的项目保持全手动、零自主动作。
15
+ 被「标记」(持久化在 `ctx.storage`,重启 OpenCode 后仍在):之后插件在后台**每 5s 探活**该 IDE——**掉线就注销 `idea` MCP**(杀掉不会自愈的
16
+ stdio 僵尸桥 + 移除 `idea_*` 工具,让模型调不到、**不会挂起**),并锁存"待重建";**下一条用户消息**读到锁存后**静默重建**(用户无感)。
17
+ `/close-in-idea` 注销 IDE MCP + 删除标记。没跑过 `/open-in-idea` 的项目保持全手动、零自主动作。
14
18
 
15
19
  ## 工作原理
16
20
 
17
21
  ```text
18
22
  setup(启动, 每项目一次):
19
23
  a. 读项目标记(ctx.storage: project/<项目路径>; OpenCode 重启后仍生效)
20
- b. 读 IDE 集成终端环境 → 通过 shell create.before 钩子注入每个 shell(null: 未连 IDE 时为空)
21
- c. 注册 /open-in-idea 与 /close-in-idea 命令 + session context/prompt 钩子
24
+ b. 注册 /open-in-idea 与 /close-in-idea 命令 + session context 钩子 + tool execute.after 钩子
22
25
  不探测、不注册 MCP
23
26
 
24
- /open-in-idea(手动触发):
25
- 0. 写项目标记 = true(持久化)+ 清内存缓存(下次读取者回源)
26
- 1. 探测本地 JetBrains IDE MCP 服务(/stream)
27
- 2. 已就绪 → 直接连接
28
- 3. 未就绪 → open -a "IntelliJ IDEA" <项目目录>(复用已运行实例),然后立即提示:
29
- 在 IDEA 中开启 MCP 服务 + Brave Mode
30
- 4. 本次命令 fast-fail 返回;用户手动开启后再次执行 `/open-in-idea`
31
- 5. MCP 就绪时:
32
- a. 注册 IDE MCP(url /stream,header IJ_MCP_SERVER_PROJECT_PATH 锁定当前项目)
33
- b. 读 IDE 终端环境 → 更新注入每个 shell 的环境变量
34
- c. 启用系统提示引导(直接调用 `idea_*` 工具)+ IDEA MCP 工具描述前缀
35
- 6. 发一条结果通知(用户看到干净气泡,模型只回"收到")
27
+ /open-in-idea(手动触发, 阻塞到就绪):
28
+ 1. 写项目标记 = true + 同步内存缓存;注册 `idea-run-config` skill
29
+ 2. 探测本地 JetBrains IDE MCP 服务(/stream)
30
+ - 已就绪 → 再问 IDE「当前项目是否已打开」(`get_project_modules`,带项目路径)
31
+ · 已打开 → 不重复 open;未打开 → open -a 打开
32
+ - 未就绪(冷启动)→ open -a 拉起,再轮询等到就绪(上限 60s)
33
+ 4. **先注销旧的 idea MCP(杀掉旧的 stdio 桥子进程),再重新注册**
34
+ (`idea stdioMcpServer`,env 带项目路径 + 端口;每项目一个子进程)
35
+ —— 每次执行都重建,因为断掉的桥不会自愈、也不能靠 refresh 复活
36
+ 5. 等 `idea_*` 工具进入注册表;读 IDE 终端环境更新 shell 环境;启用引导
37
+ 6. 发结果通知(用户看到干净气泡,模型只回"收到")
36
38
 
37
39
  /close-in-idea(手动触发):
38
- 1. 删项目标记 + 清内存缓存
39
- 2. 发一条结果通知(不改动已建立的连接)
40
-
41
- 后续用户消息(仅在被标记的项目里):
42
- 1. 项目已标记 → 默认 IDE 仍在线
43
- 2. 查 OpenCode 里 `idea` MCP server 状态;不是 connected/pending(真断了)→ 在 prompt 钩子里同步重跑上面的接入流程
44
- 3. 成功 → 本条消息即可用 `idea_*` 工具,并补一条"已自动重新连接"通知(排在用户消息之后)
45
- 4. 失败 → 静默,沿用 fast-fail 恢复提示(不会每条消息都重复拉起 IDE)
40
+ 1. 删项目标记 + 同步内存缓存 + 注销 IDE MCP + 清空注入的环境
41
+ 2. 发结果通知
42
+
43
+ 工具失败触发(仅在被标记的项目里;需先 /open-in-idea 过一次):
44
+ 1. `idea_*` 工具调用失败 → tool execute.after 钩子**分类**:
45
+ - IDE MCP 不可用(掉了)→ 重跑接入流程
46
+ - IDE 在、但项目没打开("Unable to determine the target project" / "doesn't correspond to any open project")→ 让 IDEA 重新打开该项目
47
+ - 普通业务错误(File not found 等)→ 不动作
48
+ 2. 触发成功 → 把该错误改写为"已重连 / 已重新打开,请重试",模型同一回合重试;失败 → 保持原错误
49
+
50
+ 后台探活(心跳;仅"已标记 + 桥已注册"的项目):
51
+ 1. 每 5s 探测 IDE 端口(/stream);连续 2 次不通 →
52
+ 2. **注销 `idea` MCP**:杀掉僵尸桥子进程 + 从注册表移除 `idea_*` 工具
53
+ (→ 模型调不到,也就不会挂起;这一步是"从根上避免挂起"的关键)
54
+ 3. 锁存 `needRebuild = true`(不会因端口恢复而清,因为桥不会自愈)
55
+ 4. 下一条用户消息:纯内存读到锁存 → 自动重跑接入流程(IDE 不在则 `open -a` 拉起),成功后清锁存
56
+ —— 全程静默,用户无感
57
+ ※ 这是**每个 Location(项目)各跑一份**探活;IDE 只有一个,所以所有项目的桥几乎同时被注销、再各自重建
46
58
  ```
47
59
 
48
60
  ## 在 IDE 中打开项目 (`/open-in-idea`)
49
61
 
50
62
  在会话里输入 `/open-in-idea`:
51
63
 
52
- 1. 先探测 `/stream`;已就绪 → 直接加载能力;
53
- 2. 未就绪 → 用 `open -a "IntelliJ IDEA" <项目目录>` 拉起或激活 IDEA —— **复用已运行的实例**(不会重复开新实例);
54
- 3. 如果 MCP 仍未就绪,命令会 **立即 fast-fail**,提示在 IDEA 的 `Settings → MCP Server` 开启 MCP 服务并启用 **Brave
55
- Mode**;
56
- 4. 用户手动完成设置后,再次执行 `/open-in-idea`;探测成功后才注册 IDE MCP、重读 IDE 环境并启用系统引导与 live tool catalog。
64
+ 1. **让运行中的 IDEA 实例打开/激活当前项目**:`open -a "IntelliJ IDEA" <项目目录>` —— **复用已运行的实例**(不会重复开新实例),
65
+ 项目已打开时只把它切到前台。**无论 MCP 是否已就绪都会执行这一步**:端点能应答只说明 IDEA 在运行(可能开的是**别的**项目),
66
+ 不代表当前项目已打开;
67
+ 2. 再探测 `/stream`;已就绪 → 直接进入下一步;
68
+ 3. 未就绪 → 说明 IDEA 是冷启动,继续用 `open -a` 拉起并**轮询等到 MCP 就绪**(冷启动约数十秒,上限 60s);
69
+ 4. 等不到(例如 IDE 里 MCP 服务未开启)→ 提示在 IDEA 的 `Settings → MCP Server` 开启 MCP 服务并启用 **Brave Mode**,用户设置好后
70
+ 再次执行 `/open-in-idea`;
71
+ 5. 就绪后 **先注销旧的 `idea` MCP(杀掉旧的 stdio 桥子进程)→ 再重新注册**(每次执行都重建,因为断掉的桥不会自愈),然后重读
72
+ IDE 环境、启用系统引导,并**等到 `idea_*` 工具进入注册表**才返回。
57
73
 
58
74
  > **通知不触发 AI 干活**:默认 `feedback: "message"` 把结果作为一条 **标注清楚的通知**发到会话里 —— 用户看到干净的通知气泡
59
75
  > (经 `metadata.displayText`),模型只看到"这是通知,请只回复『收到』"的指令,因此不会执行任何操作、不会调用工具。
@@ -68,8 +84,8 @@ Brave Mode 后再次执行 `/open-in-idea` 即可接上。
68
84
 
69
85
  ## 关闭接入 (`/close-in-idea`)
70
86
 
71
- 输入 `/close-in-idea`:删除当前项目的接入标记(持久化 + 内存缓存一并清),之后 IDE MCP 即使断开也**不再自动重连**。命令只删标记,
72
- **不主动断开已建立的连接**——当前已注册的工具会保留到连接自然中断为止。想重新接入,再次执行 `/open-in-idea`。
87
+ 输入 `/close-in-idea`:**注销当前项目的 IDE MCP** + 删除项目标记(持久化 + 内存缓存一并清),并清空注入的环境变量。
88
+ 之后 `idea_*` 工具不再可用,也不会再因失败自动接入。想重新接入,再次执行 `/open-in-idea`。
73
89
 
74
90
  ## IDE 工具优先 (直接调用引导)
75
91
 
@@ -108,39 +124,65 @@ IDE MCP 只能 **查**(`get_run_configurations`)和 **执行**(`execute_run_conf
108
124
  `setup` 时(读回标记)就会注册。未标记的项目不受影响。文档放在 `skills/<id>/SKILL.md`(每个 skill 一个目录,`name` /
109
125
  `description` 走 YAML frontmatter),由 `src/skill/loader.js` 扫描加载 —— **加新 skill 只需加一个目录,不用改代码**。
110
126
 
111
- ## 环境变量注入
127
+ ## 项目命令执行 (IDE 终端)
128
+
129
+ 跑项目相关的命令时,模型使用 **`idea_execute_terminal_command`**,而不是 OpenCode 原生 shell。判据是**命令是否依赖项目的
130
+ 工具链 / 运行时 / 依赖环境**,而不是语言或工具名:
112
131
 
113
- **唯一来源:IDE 集成终端**(`execute_terminal_command printenv`)。它反映 IDE 实际运行的环境 —— 这些环境往往在 .idea
114
- 配置里读不到。
132
+ - **走 IDE 终端**:任何会调用项目工具链的命令 —— Java / Python / Node / Go 的运行时,以及 Maven / Gradle、uv / pip /
133
+ poetry、cargo、bundler、make / cmake、包管理器、构建、测试、代码生成等。
134
+ - **走原生 shell**:纯操作系统级的通用命令(`ls` / `cat` / `cp` / `mkdir` / `git` 等)。
115
135
 
116
- 抽取的变量 (`src/mcp/ide-env.js` 的 `SDK_ENV_KEYS`):`JAVA_HOME` / `JRE_HOME` / `JDK_HOME` / `VIRTUAL_ENV` / `PYTHONPATH` /
117
- `PYTHONHOME` / `GOROOT` / `GOPATH` / `GOBIN` / `NODE_PATH` / `NVM_BIN` / `NVM_DIR` / `MAVEN_HOME` / `M2_HOME` /
118
- `GRADLE_HOME` / `SDKMAN_DIR` / `CONDA_PREFIX`。
136
+ 原因:
119
137
 
120
- - 注入通过 V2 的 `shell` `create.before` 钩子:只补这些变量 + 前置 PATH,不覆盖你原有的 PATH。
121
- - 需要 IDE MCP 可达 + **Brave Mode**;拿不到时环境为空 (不影响其他功能)。
138
+ - **`executeInShell: true`**(必开):命令在用户真实 shell(`zsh`/`bash`)里执行,**继承 IDE 集成终端的环境** —— 也就是用户在
139
+ IDE 里配好的 JDK / Node / Python / Go / Maven / uv 等。原生 shell 未必能拿到同一套版本,尤其是 fnm / SDKMAN / pyenv 这类
140
+ "选择写在 shell init 里"的版本管理器,靠几个环境变量复现不出来。
141
+ - `timeout`(毫秒)按预期耗时给足;`maxLinesCount` 控制返回行数,超 2000 行会截断。
142
+ - `reuseExistingTerminalWindow: true` 复用同一个 IDE 终端窗口,不刷屏。
143
+ - 返回 `command_exit_code` + `command_output`;`cd` **不跨调用保留**,多步操作用 `&&` 串在一条命令里。
144
+ - 需要 IDE 里开启 **Brave Mode**,否则每条命令都要用户确认一次。
122
145
 
123
- > 与 [opencode-env-loader](https://github.com/syllr/opencode-env-loader) 的关系:两者独立。env-loader 负责
124
- > `.opencode/env-loader/*` 的 **人工** KEY=VALUE 覆盖;本插件负责从 **IDE 实际环境**推导。可以同时使用。
146
+ 引导文本由 `src/ide-guidance.js` 注入(`### 终端` 段),只在 IDE MCP 可用时生效。
125
147
 
126
148
  ## 前置条件
127
149
 
128
150
  - JetBrains IDE 已开启 MCP 服务 (IDE 设置里的 MCP Server / AI Assistant MCP),版本需 **2026.2+**(提供 Streamable HTTP 端点
129
151
  `/stream`);若关闭,`/open-in-idea` 会立即提示如何重新开启。
130
152
  - 要读 IDE 终端环境,需要在 IDE 里开启 **Brave Mode**(否则 `execute_terminal_command` 会等确认);MCP 未就绪时通知会同时提醒开启它。
131
- - 插件注册给 OpenCode 的 MCP 配置形如:
153
+ - 插件注册给 OpenCode 的 MCP 配置。默认用 **stdio 桥**:
132
154
 
133
155
  ```jsonc
134
156
  {
135
- "type": "remote",
136
- "url": "http://127.0.0.1:64342/stream",
137
- "headers": { "IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>" },
157
+ "type": "local",
158
+ // OpenCode 的 `LocalConfig.command` 是【字符串数组】:可执行文件 + 参数
159
+ // (源码 `client.ts` 里 `const [command, ...args] = config.command`)。没有单独的 `args` 字段。
160
+ "command": ["/Applications/IntelliJ IDEA.app/Contents/MacOS/idea", "stdioMcpServer"],
161
+ "environment": {
162
+ "IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>",
163
+ "IJ_MCP_SERVER_PORT": "64342",
164
+ },
138
165
  }
139
166
  ```
140
167
 
141
- > **为什么是 `/stream` 而不是 `/sse`**:OpenCode V2 的远程 MCP 客户端 **只支持 Streamable HTTP**,没有旧版 SSE 传输。IDE 的
142
- > `/sse` 是旧版 SSE (POST 会返回 405),V2 连不上 (报 `Error POSTing to endpoint`)。IDE 2026.2 起额外提供 `/stream` 这个
143
- > Streamable HTTP 端点。 **插件的探测也走 `/stream`**(一次 `initialize`),协议统一。
168
+ `idea stdioMcpServer` 是 IDE 自带的桥:OpenCode 通过 **stdin/stdout 子进程管道**跟它通信,它在后端连 IDE 的 MCP。
169
+ **客户端侧是持久管道,不存在 HTTP 空闲断流**——这正是默认选它的原因。每个项目一个插件实例、各带自己的
170
+ `IJ_MCP_SERVER_PROJECT_PATH`,所以每个项目会起一个独立的 stdio 客户端进程。
171
+
172
+ > **为什么不用远程 `/stream`**:Streamable HTTP 的客户端会挂一条用于 server→client 通知的 SSE 流;这条流**空闲一段时间会
173
+ > 被关**,而**远程传输在客户端侧不重连**(OpenCode 只为 legacy session 过期重连)。于是"闲置一会儿"就会掉。stdio 桥没有这个
174
+ > 问题。`transport: "http"` 可切回远程模式:
175
+ >
176
+ > ```jsonc
177
+ > {
178
+ > "type": "remote",
179
+ > "url": "http://127.0.0.1:64342/stream",
180
+ > "headers": { "IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>" },
181
+ > }
182
+ > ```
183
+ >
184
+ > **为什么不用 `/sse`**:OpenCode V2 的远程 MCP 客户端**只支持 Streamable HTTP**,没有旧版 SSE 传输。IDE 的 `/sse` 是旧版
185
+ > SSE (POST 返回 405),V2 连不上。插件对 IDE 的**探测**仍走 `/stream`(一次 `initialize`),用来确认 IDE MCP 已就绪并拿到端口。
144
186
 
145
187
  插件按 `ports` 顺序选择第一个可用端口,但会并发探测以避免多个端口的超时叠加;默认 `[64342]`。
146
188
 
@@ -202,12 +244,16 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
202
244
  "package": "opencode-idea",
203
245
  "options": {
204
246
  "ports": [64342], // IDE MCP 端口;默认 [64342]
205
- "injectEnv": true,
206
247
  "injectGuidance": true, // IDE 可用时向系统提示注入「优先使用 IDEA MCP」引导
207
248
  "openInIde": "idea", // false 关闭;或任意 macOS 应用名(如 "IntelliJ IDEA")
249
+ "transport": "stdio", // "stdio"(默认,本地桥,不会空闲断流)| "http"(远程 Streamable HTTP)
250
+ "ideExecutable": undefined, // 可显式指定 IDE 可执行文件;默认从 .app 里推断
208
251
  "launchCooldownMs": 120000, // 启动保护冷却
209
252
  "launchMaxAttempts": 5,
210
253
  "mcpProbeTimeoutMs": 1000, // 单个 MCP 端口的短探测超时
254
+ "executionTimeoutMs": 600000, // 单次 idea_* 工具调用硬上限;默认 10 分钟(OpenCode 默认 12 小时)
255
+ "heartbeatIntervalMs": 5000, // 后台端口探活间隔
256
+ "heartbeatFailures": 2, // 连续失败几次后注销 idea MCP
211
257
  "feedback": "message", // "message"(默认,会话通知,AI 只回"收到")| false(静默)
212
258
  },
213
259
  },
@@ -217,36 +263,45 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
217
263
 
218
264
  不传 options 时全部使用下表默认值。
219
265
 
220
- | 选项 | 类型 | 默认 | 说明 |
221
- | ------------------- | -------------------- | ----------- | ---------------------------------------------------------------- |
222
- | `ports` | `number[]` | `[64342]` | 依次探测的 IDE MCP 端口 |
223
- | `injectEnv` | `boolean` | `true` | 是否注入 IDE 终端环境变量 |
224
- | `injectGuidance` | `boolean` | `true` | IDE 可用时是否向系统提示注入「优先使用 IDEA MCP」引导 |
225
- | `openInIde` | `boolean \| string` | `"idea"` | 拉起哪个 IDE;`false` 关闭,`"idea"` 映射到 IntelliJ IDEA |
226
- | `launchCooldownMs` | `number` | `120000` | 启动保护冷却:冷却期内不重复 spawn |
227
- | `launchMaxAttempts` | `number` | `5` | 单次会话内最多 spawn 次数,超出后停止重试 |
228
- | `mcpProbeTimeoutMs` | `number` | `1000` | 单个 MCP 端口的短探测超时 |
229
- | `feedback` | `"message" \| false` | `"message"` | 结果反馈:`message` 会话通知(用户可见,AI 只回"收到");`false` 静默 |
266
+ | 选项 | 类型 | 默认 | 说明 |
267
+ | --------------------- | -------------------- | ----------- | --------------------------------------------------------------------------------------- |
268
+ | `ports` | `number[]` | `[64342]` | 依次探测的 IDE MCP 端口 |
269
+ | `injectGuidance` | `boolean` | `true` | IDE 可用时是否向系统提示注入「优先使用 IDEA MCP」引导 |
270
+ | `openInIde` | `boolean \| string` | `"idea"` | 拉起哪个 IDE;`false` 关闭,`"idea"` 映射到 IntelliJ IDEA |
271
+ | `transport` | `"stdio" \| "http"` | `"stdio"` | `stdio` 注册 IDE 自带的 stdio 桥(本地子进程,不会空闲断流);`http` 用远程 Streamable HTTP |
272
+ | `ideExecutable` | `string` | (推断) | IDE 可执行文件绝对路径;默认从 `<App>.app/Contents/MacOS/` 推断 |
273
+ | `launchCooldownMs` | `number` | `120000` | 启动保护冷却:冷却期内不重复 spawn |
274
+ | `launchMaxAttempts` | `number` | `5` | 单次会话内最多 spawn 次数,超出后停止重试 |
275
+ | `mcpProbeTimeoutMs` | `number` | `1000` | 单个 MCP 端口的短探测超时 |
276
+ | `executionTimeoutMs` | `number` | `600000` | 单次 `idea_*` 工具调用的硬上限(毫秒);防止桥挂起后无限等待(OpenCode 默认 12 小时) |
277
+ | `heartbeatIntervalMs` | `number` | `5000` | 后台 IDE 端口探活间隔(毫秒);仅"已标记 + 桥已注册"的项目有效 |
278
+ | `heartbeatFailures` | `number` | `2` | 连续探活失败几次后注销 `idea` MCP(杀僵尸桥 + 移除工具),下一条消息自动重建 |
279
+ | `feedback` | `"message" \| false` | `"message"` | 结果反馈:`message` 会话通知(用户可见,AI 只回"收到");`false` 静默 |
230
280
 
231
281
  ## 行为
232
282
 
233
- - **被标记的项目**:启动读回标记后挂上 shell 环境钩子 (环境值待接入后填充)、注册 `idea-run-config` skill;
234
- `/open-in-idea` 接入 IDE MCP + 启用引导。
283
+ - **被标记的项目**:启动读回标记后挂上 shell 环境钩子 (环境值待接入后填充)、注册 `idea-run-config` skill;IDE MCP 由
284
+ `/open-in-idea` 注册。
235
285
  - **未标记的项目**:不改环境、不注册、不注入任何 IDEA 提示 (每请求零开销;setup 时只做一次项目标记读取)。
236
- - **手动接入**:启动 **不探测、不连接 IDE MCP**;注册、**环境重读**、引导注入全部由 `/open-in-idea` 触发。
237
- `context` 钩子按「每次模型调用」触发 (含工具续跑),是**请求的纯函数**:它只看本次请求自带的工具快照 ——
238
- 有 `idea_*` 就剥离隐藏工具并注入引导;没有则只在项目被标记时给恢复提示。**不做任何 I/O(get 只看内存缓存),没有定时器,
239
- 没有事件订阅**。一次失败的调用不会触发探测或注销。
240
- - **项目标记自动重连**:`/open-in-idea` 会持久化一个**项目级**标记(插件名下 KV,key 为 `project/<项目根路径>`,重启 OpenCode 后
241
- 仍在),并在内存缓存。之后每条用户消息经 `prompt` 钩子处理时,先查 OpenCode 里 `idea` MCP server 的状态:不是
242
- `connected`/`pending`(真断了)才同步重跑一遍完整接入流程 (探测 → `open -a` → 等 MCP → 注册 + `tool.reload`),让**这条消息
243
- 本身**就能用上 IDEA 工具;恢复成功后补一条会话通知。`/close-in-idea` 删标记后即不再自愈。失败时沿用启动保护的冷却与次数上限
244
- (`launchCooldownMs` / `launchMaxAttempts`),不会每条消息都重复拉起 IDE。
245
- - **判定只看 MCP 状态,不看工具报错**:是否重连只取决于 `idea` MCP server 的连接状态(`ctx.mcp.list()`)。工具调用的业务报错
246
- (如 `File not found`)只可能发生在 server 仍 `connected` 时,所以**永远不会**被误判成断开;插件也不注册 `tool.execute.after`
247
- 钩子,一次失败的调用不会触发探测或注销。
248
- - **`/open-in-idea`**:手动打开当前项目并即时加载能力 (已在 IDE 打开时则只重新加载);MCP 未开启时只发一次提示并
249
- fast-fail,用户手动开启 MCP + Brave Mode 后重试。**`/close-in-idea`**:删项目标记,不改动已建立的连接。
286
+ - **手动接入 + 就绪即返回**:`/open-in-idea` 会 `open -a` 拉起 IDEA 并**等 IDE MCP 就绪**(冷启动数十秒,上限 60s);就绪后
287
+ **先注销旧的 `idea`(杀掉旧 stdio 桥)→ 再以 stdio 重新注册**,等 `idea_*` 工具就绪,最后才通知。**每次执行都重建**,所以断掉的
288
+ 僵尸桥只要重跑一次命令就能恢复。命令返回即可用;`/close-in-idea` 注销 `idea` + 删标记 + 清环境。
289
+ - **`context` 钩子是请求的纯函数**:按「每次模型调用」触发 (含工具续跑),只看本次请求自带的工具快照 —— 有 `idea_*` 就剥离隐藏
290
+ 工具并注入引导;没有则只在项目被标记时给恢复提示。该钩子本身**不做任何 I/O**(只看内存缓存),也不订阅任何事件。
291
+ - **后台心跳(仅"已标记 + 桥已注册"的项目)**:每 `heartbeatIntervalMs`(默认 5s)探一次 IDE 端口;连续 `heartbeatFailures`
292
+ (默认 2)次不通就**注销 `idea` MCP** —— 杀掉僵尸桥子进程 + 移除 `idea_*` 工具,于是模型**调不到、也就不会挂起** —— 并锁存
293
+ `needRebuild`。下一条用户消息在 `prompt` 钩子里读到锁存 → 自动重跑接入流程(IDE 不在则 `open -a` 拉起),成功后清锁存。**全程静默**。
294
+ 探活是**每个 Location(项目)各一份**;IDE 只有一个,所以 IDE 一挂、所有项目的桥几乎同时被注销,再由各自的消息触发重建。
295
+ - **失败触发的自动接入(仅被标记项目)**:`tool.execute.after` 钩子监听 `idea_*` 工具报错并**分类**:
296
+ - IDE MCP 不可用(掉了)→ 重跑 `/open-in-idea` 的接入流程;
297
+ - IDE 在、但项目没打开(`Unable to determine the target project` / `doesn't correspond to any open project`)→ 让 IDEA 重新打开该项目;
298
+ - 普通业务错误(`File not found` 等)→ **不动作**。判定只认**传输层专有标记**(`MCP server "idea" is not available` /
299
+ `MCP server is not connected` / `Error POSTing to endpoint` / `SseClientTransport is closed`),**不认**工具输出里出现的通用串
300
+ (例如 SQL 工具报的 `Connection refused`) —— MCP 工具错误的 message 就是工具自己的输出,认通用串会把正常业务失败误判成掉线并重建。
301
+ 触发成功会把该工具错误改写为"已重连 / 已重新打开,请重试",模型在同一回合内重试;失败则保持原错误。非 `idea_*` 工具、未标记
302
+ 项目都不受影响。启动保护 (`launchCooldownMs` / `launchMaxAttempts`) 限制重复拉起 IDE。
303
+ - **`/open-in-idea`**:手动打开当前项目;**等 IDE MCP 就绪 → 先注销旧的 `idea`(杀旧桥)→ 再重新注册 → 等 `idea_*` 工具就绪**才返回。
304
+ **`/close-in-idea`**:注销 IDE MCP + 删项目标记 + 清空注入的环境。
250
305
 
251
306
  ## 限制
252
307
 
@@ -256,21 +311,29 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
256
311
  - skill 定义的字段名**以 schema 为准**,不要照官方文档:`Skill.Info` 需要 `path` (AbsolutePath),文档里写的 `location`
257
312
  不存在 —— 用错会让 `ctx.skill.transform` 抛 `SchemaError`,而**任何 transform 失败都会禁用整个插件**(0.0.6 就是这样丢了
258
313
  `/open-in-idea`)。同理,`Skill.Info` 的完整字段是 `id` / `name` / `description?` / `autoinvoke?` / `path` / `content`。
259
- - 环境变量依赖 `execute_terminal_command` + Brave Mode;拿不到时环境为空。
314
+ - 项目命令走 `idea_execute_terminal_command` + **Brave Mode**;没开 Brave Mode 时每条命令都要用户确认一次。
260
315
  - 一批用不到的 IDEA MCP 工具被硬编码隐藏 (`src/plugin.js` 的 `HIDDEN_IDEA_TOOLS`,共 31 个):VCS (`idea_git_status` /
261
316
  `idea_get_repositories`,版本控制走原生 `git`)、Router 派发工具 (`idea_execute_tool`,未启用 router-only 时冗余)、Debugger
262
317
  (全部 `idea_xdebug_*`)、Dev Kit MCP、Inspection KTS MCP、解释器环境 MCP、以及数据库的建/改数据源 (数据源由用户在
263
- IDEA 里配置,AI 只读和查询)。`idea_execute_terminal_command` 也不暴露给模型 —— 它只由插件内部读取 IDE 环境,而那是插件直接调
264
- MCP,不经过模型工具表。
265
- - 探测只确认「MCP 服务是否在监听」,不校验「当前项目是否已在 IDE 打开」;MCP 关闭时会用一次短探测并立即给出设置提示。
266
- - OpenCode 的 MCP 自动重连**只覆盖 legacy Streamable HTTP 的 session 过期**(`SessionExpiredError`:有 session id + 404 /
267
- "Server not initialized" → `recover()` + 重试该次调用);modern 端点 (IDE 2026.2,protocol 2025-06-18,不带 session id)
268
- 的连接被关只会置 `failed` 并移除工具,**不会自动重连**。**被标记的项目**由插件在 `prompt` 钩子里查 MCP 状态并按需重跑接入
269
- 流程补这个缺口;未标记的项目仍走手动 `/open-in-idea`。
318
+ IDEA 里配置,AI 只读和查询)。**`idea_execute_terminal_command` 不隐藏**:项目命令要在 IDE 环境里跑,模型需要它。
319
+ - 探测只确认「MCP 服务是否在监听」;**「当前项目是否已在 IDE 打开」靠一次项目作用域的 MCP 调用判断**
320
+ (`get_project_modules`,带 `IJ_MCP_SERVER_PROJECT_PATH`):返回模块 = 已打开 → 跳过 `open -a`;报
321
+ `Unable to determine the target project` = 未打开 → `open -a` 打开。MCP 关闭时会用一次短探测并立即给出设置提示。
322
+ 项目刚打开时的短暂"项目未就绪"由工具失败分类兜底(「IDE 在、但项目没打开」→ 重新打开并让模型重试)。
323
+ - IDE **2026.2+** 提供官方 stdio 桥 (`idea stdioMcpServer`),插件默认用它注册。stdio 是子进程管道,不像 Streamable HTTP 那样
324
+ 会空闲断流;但它**启动时要求 IDE MCP 已在监听**,所以 `/open-in-idea` 必须先等就绪再注册(冷启动约数十秒)。`transport: "http"`
325
+ 可切回远程 Streamable HTTP(OpenCode 会因连接关闭把 server 置 failed 并移除工具,不自动重连)。
326
+ - **stdio 的边界(已实测)**:IDE 中途退出后,桥进程会变成"僵尸"——进程还在、调用挂起、**不回任何错误**。两种情况都实测过:**调用之后**IDE 挂、
327
+ 以及**调用进行中**IDE 挂(在 IDE 里跑 `sleep 120` 时 `kill` 掉 IDEA),桥都只在 stderr 打一行错误(`SseClientTransport is closed!` /
328
+ `Request timeout has expired`),**stdout 一个字节都不回**。而且**重开 IDEA 也不会自愈**(实测:重开 IDEA 后连续探测 5 分钟仍全部 TIMEOUT);
329
+ OpenCode 依旧显示 connected,`refresh`/`reload` 只会 `reconcile`,配置没变就跳过,不会重启它。所以:
330
+ - **后台心跳(见上)自动兜底**:5s 探活、连续 2 次不通就注销 `idea`(杀僵尸桥 + 移除工具),下一条消息自动重建 —— 常态下**不需要用户手动做什么**;
331
+ - 手动重建仍可用:重开 IDEA 后再执行一次 `/open-in-idea`(先注销旧 `idea`、再重新注册);
332
+ - 插件给 `idea` server 配 `executionTimeoutMs`(默认 **10 分钟**)作为硬上限:兜住"心跳尚未触发、工具已在途"的那次挂起,不再沿用 OpenCode 默认的 12 小时;
333
+ - "IDE 被关掉 → 工具挂起"这类**不会**走工具失败分类(没有 error);能自动分类处理的是"IDE 在、项目没打开"和"server 掉了(启动即失败)"。
270
334
  - 项目标记存在插件名下 KV (`plugin:opencode-idea:project/<项目根路径>`)。插件实例按 **Location**(项目/打开目录)创建,标记按
271
- **项目根路径**共享;但两个不同 Location(例如项目根与某子目录)各有自己的内存缓存 —— 在其中一个执行 open/close 命令,另一个
272
- 实例要等它下次回源(目前只在 setup 读一次)才会看到。单目录打开项目的常见用法不受影响。
273
- - `/close-in-idea` **只删标记,不断开已建立的连接**:已注册的 IDEA 工具会保留到连接自然中断,期间标记与实际连接可能短暂不一致。
335
+ **项目根路径**共享;但两个不同 Location(例如项目根与某子目录)各有自己的内存缓存 —— 单目录打开项目的常见用法不受影响。
336
+ - `/close-in-idea` 会**注销 IDE MCP** 并删标记 + 清空注入的环境。
274
337
  - 插件**完全不再检查 `.idea` 目录**:是否是 IDEA 项目只由项目标记(`/open-in-idea` 设置、`/close-in-idea` 清除)决定。
275
338
  (`/open-in-idea` 会让 IDEA 打开并导入该项目,`.idea` 由 IDEA 自己生成,插件无需也不应据此判断。)
276
339
  - 打开 IDE 仅实现 macOS (`open -a`);首次打开若弹 Trust 对话框,需确认后重试。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-idea",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "OpenCode plugin that connects to JetBrains IDE MCP servers and prefers IDE tools when the current project is open in the IDE",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -9,9 +9,7 @@
9
9
  "src/index.js",
10
10
  "src/plugin.js",
11
11
  "src/mcp/idea.js",
12
- "src/mcp/ide-env.js",
13
12
  "src/project.js",
14
- "src/env.js",
15
13
  "src/ide-launcher.js",
16
14
  "src/ide-guidance.js",
17
15
  "src/skill/loader.js",
@@ -60,6 +60,17 @@ export function buildIdeGuidance(projectPath) {
60
60
  `- \`idea_get_run_configurations\`:读项目级 \`.run/*.run.xml\`(增删改直接改文件);传 \`filePath\` 可列该文件的运行入口。`,
61
61
  `- \`idea_execute_run_configuration\`:跑已有配置;Shell Script 要 \`EXECUTE_IN_TERMINAL: false\`;覆盖参数(\`programArguments\` / \`workingDirectory\` / \`envs\`)需配置支持 \`supportsDynamicLaunchOverrides\`。`,
62
62
  ``,
63
+ `### 终端`,
64
+ `- \`idea_execute_terminal_command\`:**项目内的一切命令执行都用它**,不要用原生 \`shell\`。`,
65
+ ` 判据是"命令是否依赖项目的工具链 / 运行时 / 依赖环境",不是语言或工具名 —— Java、Python、Node、Go 之外,`,
66
+ ` Maven / Gradle、uv / pip / poetry、cargo、bundler、make / cmake、包管理器与构建、测试、代码生成等,全都算。`,
67
+ ` 例外:纯操作系统级的通用命令(\`ls\` / \`cat\` / \`cp\` / \`mkdir\` / \`git\` 等)用原生 \`shell\` 即可。`,
68
+ ` 记不清就不确定时:只要命令会调用项目的工具链,就用它,不要退回原生 \`shell\`。`,
69
+ ` - 必须 \`executeInShell: true\`:在用户真实 shell 里执行,**天然继承 IDE 里配好的运行时环境**(JDK / Node / Python / Go / Maven / uv 等及各版本管理器);原生 shell 可能用错版本。`,
70
+ ` - 按预期耗时传 \`timeout\`(毫秒),长任务给足;必要时传 \`maxLinesCount\` 控制返回行数。`,
71
+ ` - 连续命令传 \`reuseExistingTerminalWindow: true\`,复用同一个 IDE 终端窗口。`,
72
+ ` - 结果带 \`command_exit_code\` 与 \`command_output\`;\`cd\` 不跨调用保留,多步时在一条命令里用 \`&&\`。`,
73
+ ``,
63
74
  `### 数据库 (数据源由用户在 IDE 里配置:不要自己建/改,不要向用户索取数据库凭据)`,
64
75
  `- \`idea_list_database_connections\`:先看有没有现成数据源;每条给 \`id\` / \`dbms\` / \`jdbcUrl\` / \`readOnly\` / \`isDDL\`。`,
65
76
  `- \`idea_test_database_connection\`(\`id\`):\`hasProblems\` 为 \`NO\` 才算通,另带 DBMS / 驱动版本与 ping。`,
@@ -82,6 +93,16 @@ export const MCP_RECOVERY_GUIDANCE = [
82
93
  'If an idea.* / idea_* tool is missing, MCP is disconnected, the connection is refused, or a tool times out: stop retrying immediately; do not run extra probes or wait in tool calls. Tell the user that IDEA MCP is unavailable and ask them to run /open-in-idea to reconnect.',
83
94
  ].join('\n');
84
95
 
96
+ /**
97
+ * Recovery instruction for a marked project, where the plugin reconnects the
98
+ * IDE MCP on its own. The model must not fall back to "ask the user" when a
99
+ * single connection error is all it saw — the reconnect is already happening.
100
+ */
101
+ export const MCP_RECOVERY_GUIDANCE_MARKED = [
102
+ '### IDEA MCP failure recovery',
103
+ 'This project is IDEA-connected and the plugin reconnects the IDE MCP automatically. When an idea_* call fails with a connection error (for example `MCP server "idea" is not available`), do not run extra probes or wait in tool calls: the reconnect is already under way, so retry the operation once. Only if it still fails, tell the user that IDEA MCP is unavailable and to run /open-in-idea.',
104
+ ].join('\n');
105
+
85
106
  /**
86
107
  * Push the guidance onto a session `system` array. Called for every primary
87
108
  * model request, so it stays idempotent per request.
@@ -97,9 +118,13 @@ export function appendIdeGuidance(system, projectPath) {
97
118
  system.push({ type: 'text', text });
98
119
  }
99
120
 
100
- /** @param {unknown} system */
101
- export function appendIdeRecoveryGuidance(system) {
121
+ /**
122
+ * @param {unknown} system
123
+ * @param {{ marked?: boolean }} [options] `marked` picks the auto-reconnect variant
124
+ */
125
+ export function appendIdeRecoveryGuidance(system, { marked = false } = {}) {
102
126
  if (!Array.isArray(system)) return;
103
- if (system.some((part) => part?.text === MCP_RECOVERY_GUIDANCE)) return;
104
- system.push({ type: 'text', text: MCP_RECOVERY_GUIDANCE });
127
+ const text = marked ? MCP_RECOVERY_GUIDANCE_MARKED : MCP_RECOVERY_GUIDANCE;
128
+ if (system.some((part) => part?.text === text)) return;
129
+ system.push({ type: 'text', text });
105
130
  }
@@ -12,6 +12,8 @@
12
12
  // unavailable; this module only requests opening/activating the project.
13
13
 
14
14
  import { spawn } from 'node:child_process';
15
+ import { readdirSync, statSync } from 'node:fs';
16
+ import path from 'node:path';
15
17
 
16
18
  /** Default macOS app name for IntelliJ IDEA. */
17
19
  export const DEFAULT_IDE_APP = 'IntelliJ IDEA';
@@ -35,6 +37,62 @@ export function resolveIdeApp(value) {
35
37
  return IDE_APPS[value.toLowerCase()] ?? value;
36
38
  }
37
39
 
40
+ /** Preferred launcher binary name per app, used when `Contents/MacOS` has several. */
41
+ const IDE_EXECUTABLES = {
42
+ 'IntelliJ IDEA': 'idea',
43
+ PyCharm: 'pycharm',
44
+ 'PyCharm Community': 'pycharm',
45
+ WebStorm: 'webstorm',
46
+ GoLand: 'goland',
47
+ CLion: 'clion',
48
+ PhpStorm: 'phpstorm',
49
+ RubyMine: 'rubymine',
50
+ Rider: 'rider',
51
+ DataGrip: 'datagrip',
52
+ 'Android Studio': 'studio',
53
+ };
54
+
55
+ /**
56
+ * Absolute path to the IDE launcher binary (`<App>.app/Contents/MacOS/<bin>`).
57
+ * The stdio bridge is started by running this binary with `stdioMcpServer`.
58
+ *
59
+ * Returns undefined when the app bundle cannot be found — the caller then falls
60
+ * back to the remote transport.
61
+ *
62
+ * @param {string | undefined} app macOS app name, e.g. "IntelliJ IDEA"
63
+ * @param {{ dirs?: string[] }} [options] search roots (defaults to the two
64
+ * standard Applications folders); tests pass their own.
65
+ * @returns {string | undefined}
66
+ */
67
+ export function resolveIdeExecutable(app, options = {}) {
68
+ if (typeof app !== 'string' || app.length === 0) return undefined;
69
+ const home = process.env.HOME;
70
+ const dirs = options.dirs ?? [home ? path.join(home, 'Applications') : undefined, '/Applications'].filter(
71
+ (value) => typeof value === 'string',
72
+ );
73
+ const preferred = IDE_EXECUTABLES[app];
74
+ for (const dir of dirs) {
75
+ const macos = path.join(dir, `${app}.app`, 'Contents', 'MacOS');
76
+ let entries;
77
+ try {
78
+ entries = readdirSync(macos);
79
+ } catch {
80
+ continue;
81
+ }
82
+ const executable = (name) => {
83
+ try {
84
+ const stats = statSync(path.join(macos, name));
85
+ return stats.isFile() && (stats.mode & 0o111) !== 0;
86
+ } catch {
87
+ return false;
88
+ }
89
+ };
90
+ const chosen = (preferred && entries.includes(preferred) && preferred) || entries.find(executable);
91
+ if (chosen) return path.join(macos, chosen);
92
+ }
93
+ return undefined;
94
+ }
95
+
38
96
  /**
39
97
  * Open `directory` in the IDE, reusing a running instance when possible.
40
98
  * Never throws; resolves `true` when `open` reported success.