opencode-idea 0.0.9 → 0.0.11
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 +138 -55
- package/package.json +1 -1
- package/src/ide-guidance.js +18 -4
- package/src/ide-launcher.js +58 -0
- package/src/mcp/idea.js +74 -9
- package/src/plugin.js +430 -67
- package/src/project.js +0 -15
package/README.md
CHANGED
|
@@ -8,39 +8,62 @@ OpenCode 插件,面向 JetBrains 项目 (IntelliJ IDEA / PyCharm / WebStorm 等)
|
|
|
8
8
|
(正常回退,无需声明)。
|
|
9
9
|
3. **注入 IDE 环境变量** —— 从 IDE 集成终端读取它实际运行的环境,注入每个 OpenCode shell。
|
|
10
10
|
|
|
11
|
-
非 JetBrains 项目完全不受影响。
|
|
12
|
-
|
|
11
|
+
非 JetBrains 项目完全不受影响。 **手动接入**:插件启动不探测、不连接 IDE,只有跑 `/open-in-idea` 才接入。跑过一次后,该**项目**
|
|
12
|
+
被「标记」(持久化在 `ctx.storage`,重启 OpenCode 后仍在):之后插件在后台**每 5s 探活**该 IDE——**掉线就注销 `idea` MCP**(杀掉不会自愈的
|
|
13
|
+
stdio 僵尸桥 + 移除 `idea_*` 工具,让模型调不到、**不会挂起**),并锁存"待重建";**下一条用户消息**读到锁存后**静默重建**(用户无感)。
|
|
14
|
+
`/close-in-idea` 注销 IDE MCP + 删除标记。没跑过 `/open-in-idea` 的项目保持全手动、零自主动作。
|
|
13
15
|
|
|
14
16
|
## 工作原理
|
|
15
17
|
|
|
16
18
|
```text
|
|
17
|
-
setup(
|
|
18
|
-
a.
|
|
19
|
-
b.
|
|
19
|
+
setup(启动, 每项目一次):
|
|
20
|
+
a. 读项目标记(ctx.storage: project/<项目路径>; OpenCode 重启后仍生效)
|
|
21
|
+
b. 读 IDE 集成终端环境 → 通过 shell create.before 钩子注入每个 shell(null: 未连 IDE 时为空)
|
|
22
|
+
c. 注册 /open-in-idea 与 /close-in-idea 命令 + session context 钩子 + tool execute.after 钩子
|
|
20
23
|
不探测、不注册 MCP
|
|
21
24
|
|
|
22
|
-
/open-in-idea(
|
|
23
|
-
1.
|
|
24
|
-
2.
|
|
25
|
-
3. 未就绪 → open -a "IntelliJ IDEA" <项目目录>(复用已运行实例)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
/open-in-idea(手动触发, 阻塞到就绪):
|
|
26
|
+
1. 写项目标记 = true + 同步内存缓存;注册 `idea-run-config` skill
|
|
27
|
+
2. 探测本地 JetBrains IDE MCP 服务(/stream)
|
|
28
|
+
3. 未就绪 → open -a "IntelliJ IDEA" <项目目录>(复用已运行实例),然后轮询等到 MCP 就绪(冷启动约数十秒,上限 60s)
|
|
29
|
+
4. **先注销旧的 idea MCP(杀掉旧的 stdio 桥子进程),再重新注册**
|
|
30
|
+
(`idea stdioMcpServer`,env 带项目路径 + 端口;每项目一个子进程)
|
|
31
|
+
—— 每次执行都重建,因为断掉的桥不会自愈、也不能靠 refresh 复活
|
|
32
|
+
5. 等 `idea_*` 工具进入注册表;读 IDE 终端环境更新 shell 环境;启用引导
|
|
33
|
+
6. 发结果通知(用户看到干净气泡,模型只回"收到")
|
|
34
|
+
|
|
35
|
+
/close-in-idea(手动触发):
|
|
36
|
+
1. 删项目标记 + 同步内存缓存 + 注销 IDE MCP + 清空注入的环境
|
|
37
|
+
2. 发结果通知
|
|
38
|
+
|
|
39
|
+
工具失败触发(仅在被标记的项目里;需先 /open-in-idea 过一次):
|
|
40
|
+
1. `idea_*` 工具调用失败 → tool execute.after 钩子**分类**:
|
|
41
|
+
- IDE MCP 不可用(掉了)→ 重跑接入流程
|
|
42
|
+
- IDE 在、但项目没打开("Unable to determine the target project" / "doesn't correspond to any open project")→ 让 IDEA 重新打开该项目
|
|
43
|
+
- 普通业务错误(File not found 等)→ 不动作
|
|
44
|
+
2. 触发成功 → 把该错误改写为"已重连 / 已重新打开,请重试",模型同一回合重试;失败 → 保持原错误
|
|
45
|
+
|
|
46
|
+
后台探活(心跳;仅"已标记 + 桥已注册"的项目):
|
|
47
|
+
1. 每 5s 探测 IDE 端口(/stream);连续 2 次不通 →
|
|
48
|
+
2. **注销 `idea` MCP**:杀掉僵尸桥子进程 + 从注册表移除 `idea_*` 工具
|
|
49
|
+
(→ 模型调不到,也就不会挂起;这一步是"从根上避免挂起"的关键)
|
|
50
|
+
3. 锁存 `needRebuild = true`(不会因端口恢复而清,因为桥不会自愈)
|
|
51
|
+
4. 下一条用户消息:纯内存读到锁存 → 自动重跑接入流程(IDE 不在则 `open -a` 拉起),成功后清锁存
|
|
52
|
+
—— 全程静默,用户无感
|
|
53
|
+
※ 这是**每个 Location(项目)各跑一份**探活;IDE 只有一个,所以所有项目的桥几乎同时被注销、再各自重建
|
|
33
54
|
```
|
|
34
55
|
|
|
35
56
|
## 在 IDE 中打开项目 (`/open-in-idea`)
|
|
36
57
|
|
|
37
58
|
在会话里输入 `/open-in-idea`:
|
|
38
59
|
|
|
39
|
-
1. 先探测 `/stream`;已就绪 →
|
|
40
|
-
2. 未就绪 → 用 `open -a "IntelliJ IDEA" <项目目录>` 拉起或激活 IDEA —— **复用已运行的实例**(不会重复开新实例)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
1. 先探测 `/stream`;已就绪 → 直接进入下一步;
|
|
61
|
+
2. 未就绪 → 用 `open -a "IntelliJ IDEA" <项目目录>` 拉起或激活 IDEA —— **复用已运行的实例**(不会重复开新实例),然后
|
|
62
|
+
**轮询等到 MCP 就绪**(冷启动约数十秒,上限 60s);
|
|
63
|
+
3. 等不到(例如 IDE 里 MCP 服务未开启)→ 提示在 IDEA 的 `Settings → MCP Server` 开启 MCP 服务并启用 **Brave Mode**,用户设置好后
|
|
64
|
+
再次执行 `/open-in-idea`;
|
|
65
|
+
4. 就绪后 **先注销旧的 `idea` MCP(杀掉旧的 stdio 桥子进程)→ 再重新注册**(每次执行都重建,因为断掉的桥不会自愈),然后重读
|
|
66
|
+
IDE 环境、启用系统引导,并**等到 `idea_*` 工具进入注册表**才返回。
|
|
44
67
|
|
|
45
68
|
> **通知不触发 AI 干活**:默认 `feedback: "message"` 把结果作为一条 **标注清楚的通知**发到会话里 —— 用户看到干净的通知气泡
|
|
46
69
|
> (经 `metadata.displayText`),模型只看到"这是通知,请只回复『收到』"的指令,因此不会执行任何操作、不会调用工具。
|
|
@@ -53,6 +76,11 @@ Brave Mode 后再次执行 `/open-in-idea` 即可接上。
|
|
|
53
76
|
**热更新会断开**:插件源码变更会触发 V2 热重载,注册随旧实例一起释放,`activePort` 归零。这是手动模式的预期行为 ——
|
|
54
77
|
**改完插件后重跑一次 `/open-in-idea`** 即可恢复。
|
|
55
78
|
|
|
79
|
+
## 关闭接入 (`/close-in-idea`)
|
|
80
|
+
|
|
81
|
+
输入 `/close-in-idea`:**注销当前项目的 IDE MCP** + 删除项目标记(持久化 + 内存缓存一并清),并清空注入的环境变量。
|
|
82
|
+
之后 `idea_*` 工具不再可用,也不会再因失败自动接入。想重新接入,再次执行 `/open-in-idea`。
|
|
83
|
+
|
|
56
84
|
## IDE 工具优先 (直接调用引导)
|
|
57
85
|
|
|
58
86
|
IDE MCP 可用时,插件通过 **系统提示注入**提高模型主动使用 IDEA MCP 的概率;不修改任何工具 (原生或 IDEA)
|
|
@@ -78,7 +106,7 @@ IDE MCP 可用时,插件通过 **系统提示注入**提高模型主动使用 ID
|
|
|
78
106
|
## Run Configuration 管理 (skill)
|
|
79
107
|
|
|
80
108
|
IDE MCP 只能 **查**(`get_run_configurations`)和 **执行**(`execute_run_configuration`)run configuration, **没有增删改**
|
|
81
|
-
|
|
109
|
+
。插件在**被标记的项目**里注册一个 `idea-run-config` skill 来补上这块:
|
|
82
110
|
|
|
83
111
|
- 只操作 **项目级** `.run/<name>.run.xml`(可提交 git、团队共享), **不碰** `.idea/workspace.xml`;
|
|
84
112
|
- 不内置任何按类型的模板 —— 先找项目里的 **真实样本**(`**/*.run.xml`,或让用户在 IDEA 里勾 "Store as project file"
|
|
@@ -86,9 +114,9 @@ IDE MCP 只能 **查**(`get_run_configurations`)和 **执行**(`execute_run_conf
|
|
|
86
114
|
- 写完必须用 `idea_get_run_configurations` **校验它能被列出**、并用 `idea_execute_run_configuration` **实跑确认**;
|
|
87
115
|
- 改配置用 `idea_apply_patch` **定向改 option**,不整文件重写。
|
|
88
116
|
|
|
89
|
-
skill
|
|
90
|
-
`skills/<id>/SKILL.md`(每个 skill 一个目录,`name` /
|
|
91
|
-
**加新 skill 只需加一个目录,不用改代码**。
|
|
117
|
+
**skill 的生命周期跟随项目标记**:`/open-in-idea` 打标记时注册、`/close-in-idea` 清标记时注销;曾被标记过的项目在
|
|
118
|
+
`setup` 时(读回标记)就会注册。未标记的项目不受影响。文档放在 `skills/<id>/SKILL.md`(每个 skill 一个目录,`name` /
|
|
119
|
+
`description` 走 YAML frontmatter),由 `src/skill/loader.js` 扫描加载 —— **加新 skill 只需加一个目录,不用改代码**。
|
|
92
120
|
|
|
93
121
|
## 环境变量注入
|
|
94
122
|
|
|
@@ -110,19 +138,39 @@ skill 由 `ctx.skill.transform` 在插件 `setup` 时注册, **与 `/open-in-ide
|
|
|
110
138
|
- JetBrains IDE 已开启 MCP 服务 (IDE 设置里的 MCP Server / AI Assistant MCP),版本需 **2026.2+**(提供 Streamable HTTP 端点
|
|
111
139
|
`/stream`);若关闭,`/open-in-idea` 会立即提示如何重新开启。
|
|
112
140
|
- 要读 IDE 终端环境,需要在 IDE 里开启 **Brave Mode**(否则 `execute_terminal_command` 会等确认);MCP 未就绪时通知会同时提醒开启它。
|
|
113
|
-
- 插件注册给 OpenCode 的 MCP
|
|
141
|
+
- 插件注册给 OpenCode 的 MCP 配置。默认用 **stdio 桥**:
|
|
114
142
|
|
|
115
143
|
```jsonc
|
|
116
144
|
{
|
|
117
|
-
"type": "
|
|
118
|
-
|
|
119
|
-
|
|
145
|
+
"type": "local",
|
|
146
|
+
// OpenCode 的 `LocalConfig.command` 是【字符串数组】:可执行文件 + 参数
|
|
147
|
+
// (源码 `client.ts` 里 `const [command, ...args] = config.command`)。没有单独的 `args` 字段。
|
|
148
|
+
"command": ["/Applications/IntelliJ IDEA.app/Contents/MacOS/idea", "stdioMcpServer"],
|
|
149
|
+
"environment": {
|
|
150
|
+
"IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>",
|
|
151
|
+
"IJ_MCP_SERVER_PORT": "64342",
|
|
152
|
+
},
|
|
120
153
|
}
|
|
121
154
|
```
|
|
122
155
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
156
|
+
`idea stdioMcpServer` 是 IDE 自带的桥:OpenCode 通过 **stdin/stdout 子进程管道**跟它通信,它在后端连 IDE 的 MCP。
|
|
157
|
+
**客户端侧是持久管道,不存在 HTTP 空闲断流**——这正是默认选它的原因。每个项目一个插件实例、各带自己的
|
|
158
|
+
`IJ_MCP_SERVER_PROJECT_PATH`,所以每个项目会起一个独立的 stdio 客户端进程。
|
|
159
|
+
|
|
160
|
+
> **为什么不用远程 `/stream`**:Streamable HTTP 的客户端会挂一条用于 server→client 通知的 SSE 流;这条流**空闲一段时间会
|
|
161
|
+
> 被关**,而**远程传输在客户端侧不重连**(OpenCode 只为 legacy session 过期重连)。于是"闲置一会儿"就会掉。stdio 桥没有这个
|
|
162
|
+
> 问题。`transport: "http"` 可切回远程模式:
|
|
163
|
+
>
|
|
164
|
+
> ```jsonc
|
|
165
|
+
> {
|
|
166
|
+
> "type": "remote",
|
|
167
|
+
> "url": "http://127.0.0.1:64342/stream",
|
|
168
|
+
> "headers": { "IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>" },
|
|
169
|
+
> }
|
|
170
|
+
> ```
|
|
171
|
+
>
|
|
172
|
+
> **为什么不用 `/sse`**:OpenCode V2 的远程 MCP 客户端**只支持 Streamable HTTP**,没有旧版 SSE 传输。IDE 的 `/sse` 是旧版
|
|
173
|
+
> SSE (POST 返回 405),V2 连不上。插件对 IDE 的**探测**仍走 `/stream`(一次 `initialize`),用来确认 IDE MCP 已就绪并拿到端口。
|
|
126
174
|
|
|
127
175
|
插件按 `ports` 顺序选择第一个可用端口,但会并发探测以避免多个端口的超时叠加;默认 `[64342]`。
|
|
128
176
|
|
|
@@ -187,9 +235,14 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
187
235
|
"injectEnv": true,
|
|
188
236
|
"injectGuidance": true, // IDE 可用时向系统提示注入「优先使用 IDEA MCP」引导
|
|
189
237
|
"openInIde": "idea", // false 关闭;或任意 macOS 应用名(如 "IntelliJ IDEA")
|
|
238
|
+
"transport": "stdio", // "stdio"(默认,本地桥,不会空闲断流)| "http"(远程 Streamable HTTP)
|
|
239
|
+
"ideExecutable": undefined, // 可显式指定 IDE 可执行文件;默认从 .app 里推断
|
|
190
240
|
"launchCooldownMs": 120000, // 启动保护冷却
|
|
191
241
|
"launchMaxAttempts": 5,
|
|
192
242
|
"mcpProbeTimeoutMs": 1000, // 单个 MCP 端口的短探测超时
|
|
243
|
+
"executionTimeoutMs": 600000, // 单次 idea_* 工具调用硬上限;默认 10 分钟(OpenCode 默认 12 小时)
|
|
244
|
+
"heartbeatIntervalMs": 5000, // 后台端口探活间隔
|
|
245
|
+
"heartbeatFailures": 2, // 连续失败几次后注销 idea MCP
|
|
193
246
|
"feedback": "message", // "message"(默认,会话通知,AI 只回"收到")| false(静默)
|
|
194
247
|
},
|
|
195
248
|
},
|
|
@@ -199,29 +252,46 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
199
252
|
|
|
200
253
|
不传 options 时全部使用下表默认值。
|
|
201
254
|
|
|
202
|
-
| 选项
|
|
203
|
-
|
|
|
204
|
-
| `ports`
|
|
205
|
-
| `injectEnv`
|
|
206
|
-
| `injectGuidance`
|
|
207
|
-
| `openInIde`
|
|
208
|
-
| `
|
|
209
|
-
| `
|
|
210
|
-
| `
|
|
211
|
-
| `
|
|
255
|
+
| 选项 | 类型 | 默认 | 说明 |
|
|
256
|
+
| --------------------- | -------------------- | ----------- | --------------------------------------------------------------------------------------- |
|
|
257
|
+
| `ports` | `number[]` | `[64342]` | 依次探测的 IDE MCP 端口 |
|
|
258
|
+
| `injectEnv` | `boolean` | `true` | 是否注入 IDE 终端环境变量 |
|
|
259
|
+
| `injectGuidance` | `boolean` | `true` | IDE 可用时是否向系统提示注入「优先使用 IDEA MCP」引导 |
|
|
260
|
+
| `openInIde` | `boolean \| string` | `"idea"` | 拉起哪个 IDE;`false` 关闭,`"idea"` 映射到 IntelliJ IDEA |
|
|
261
|
+
| `transport` | `"stdio" \| "http"` | `"stdio"` | `stdio` 注册 IDE 自带的 stdio 桥(本地子进程,不会空闲断流);`http` 用远程 Streamable HTTP |
|
|
262
|
+
| `ideExecutable` | `string` | (推断) | IDE 可执行文件绝对路径;默认从 `<App>.app/Contents/MacOS/` 推断 |
|
|
263
|
+
| `launchCooldownMs` | `number` | `120000` | 启动保护冷却:冷却期内不重复 spawn |
|
|
264
|
+
| `launchMaxAttempts` | `number` | `5` | 单次会话内最多 spawn 次数,超出后停止重试 |
|
|
265
|
+
| `mcpProbeTimeoutMs` | `number` | `1000` | 单个 MCP 端口的短探测超时 |
|
|
266
|
+
| `executionTimeoutMs` | `number` | `600000` | 单次 `idea_*` 工具调用的硬上限(毫秒);防止桥挂起后无限等待(OpenCode 默认 12 小时) |
|
|
267
|
+
| `heartbeatIntervalMs` | `number` | `5000` | 后台 IDE 端口探活间隔(毫秒);仅"已标记 + 桥已注册"的项目有效 |
|
|
268
|
+
| `heartbeatFailures` | `number` | `2` | 连续探活失败几次后注销 `idea` MCP(杀僵尸桥 + 移除工具),下一条消息自动重建 |
|
|
269
|
+
| `feedback` | `"message" \| false` | `"message"` | 结果反馈:`message` 会话通知(用户可见,AI 只回"收到");`false` 静默 |
|
|
212
270
|
|
|
213
271
|
## 行为
|
|
214
272
|
|
|
215
|
-
-
|
|
216
|
-
-
|
|
217
|
-
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
-
|
|
222
|
-
|
|
223
|
-
-
|
|
224
|
-
|
|
273
|
+
- **被标记的项目**:启动读回标记后挂上 shell 环境钩子 (环境值待接入后填充)、注册 `idea-run-config` skill;IDE MCP 由
|
|
274
|
+
`/open-in-idea` 注册。
|
|
275
|
+
- **未标记的项目**:不改环境、不注册、不注入任何 IDEA 提示 (每请求零开销;setup 时只做一次项目标记读取)。
|
|
276
|
+
- **手动接入 + 就绪即返回**:`/open-in-idea` 会 `open -a` 拉起 IDEA 并**等 IDE MCP 就绪**(冷启动数十秒,上限 60s);就绪后
|
|
277
|
+
**先注销旧的 `idea`(杀掉旧 stdio 桥)→ 再以 stdio 重新注册**,等 `idea_*` 工具就绪,最后才通知。**每次执行都重建**,所以断掉的
|
|
278
|
+
僵尸桥只要重跑一次命令就能恢复。命令返回即可用;`/close-in-idea` 注销 `idea` + 删标记 + 清环境。
|
|
279
|
+
- **`context` 钩子是请求的纯函数**:按「每次模型调用」触发 (含工具续跑),只看本次请求自带的工具快照 —— 有 `idea_*` 就剥离隐藏
|
|
280
|
+
工具并注入引导;没有则只在项目被标记时给恢复提示。该钩子本身**不做任何 I/O**(只看内存缓存),也不订阅任何事件。
|
|
281
|
+
- **后台心跳(仅"已标记 + 桥已注册"的项目)**:每 `heartbeatIntervalMs`(默认 5s)探一次 IDE 端口;连续 `heartbeatFailures`
|
|
282
|
+
(默认 2)次不通就**注销 `idea` MCP** —— 杀掉僵尸桥子进程 + 移除 `idea_*` 工具,于是模型**调不到、也就不会挂起** —— 并锁存
|
|
283
|
+
`needRebuild`。下一条用户消息在 `prompt` 钩子里读到锁存 → 自动重跑接入流程(IDE 不在则 `open -a` 拉起),成功后清锁存。**全程静默**。
|
|
284
|
+
探活是**每个 Location(项目)各一份**;IDE 只有一个,所以 IDE 一挂、所有项目的桥几乎同时被注销,再由各自的消息触发重建。
|
|
285
|
+
- **失败触发的自动接入(仅被标记项目)**:`tool.execute.after` 钩子监听 `idea_*` 工具报错并**分类**:
|
|
286
|
+
- IDE MCP 不可用(掉了)→ 重跑 `/open-in-idea` 的接入流程;
|
|
287
|
+
- IDE 在、但项目没打开(`Unable to determine the target project` / `doesn't correspond to any open project`)→ 让 IDEA 重新打开该项目;
|
|
288
|
+
- 普通业务错误(`File not found` 等)→ **不动作**。判定只认**传输层专有标记**(`MCP server "idea" is not available` /
|
|
289
|
+
`MCP server is not connected` / `Error POSTing to endpoint` / `SseClientTransport is closed`),**不认**工具输出里出现的通用串
|
|
290
|
+
(例如 SQL 工具报的 `Connection refused`) —— MCP 工具错误的 message 就是工具自己的输出,认通用串会把正常业务失败误判成掉线并重建。
|
|
291
|
+
触发成功会把该工具错误改写为"已重连 / 已重新打开,请重试",模型在同一回合内重试;失败则保持原错误。非 `idea_*` 工具、未标记
|
|
292
|
+
项目都不受影响。启动保护 (`launchCooldownMs` / `launchMaxAttempts`) 限制重复拉起 IDE。
|
|
293
|
+
- **`/open-in-idea`**:手动打开当前项目;**等 IDE MCP 就绪 → 先注销旧的 `idea`(杀旧桥)→ 再重新注册 → 等 `idea_*` 工具就绪**才返回。
|
|
294
|
+
**`/close-in-idea`**:注销 IDE MCP + 删项目标记 + 清空注入的环境。
|
|
225
295
|
|
|
226
296
|
## 限制
|
|
227
297
|
|
|
@@ -238,9 +308,22 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
238
308
|
IDEA 里配置,AI 只读和查询)。`idea_execute_terminal_command` 也不暴露给模型 —— 它只由插件内部读取 IDE 环境,而那是插件直接调
|
|
239
309
|
MCP,不经过模型工具表。
|
|
240
310
|
- 探测只确认「MCP 服务是否在监听」,不校验「当前项目是否已在 IDE 打开」;MCP 关闭时会用一次短探测并立即给出设置提示。
|
|
241
|
-
-
|
|
242
|
-
|
|
243
|
-
|
|
311
|
+
- IDE **2026.2+** 提供官方 stdio 桥 (`idea stdioMcpServer`),插件默认用它注册。stdio 是子进程管道,不像 Streamable HTTP 那样
|
|
312
|
+
会空闲断流;但它**启动时要求 IDE MCP 已在监听**,所以 `/open-in-idea` 必须先等就绪再注册(冷启动约数十秒)。`transport: "http"`
|
|
313
|
+
可切回远程 Streamable HTTP(OpenCode 会因连接关闭把 server 置 failed 并移除工具,不自动重连)。
|
|
314
|
+
- **stdio 的边界(已实测)**:IDE 中途退出后,桥进程会变成"僵尸"——进程还在、调用挂起、**不回任何错误**。两种情况都实测过:**调用之后**IDE 挂、
|
|
315
|
+
以及**调用进行中**IDE 挂(在 IDE 里跑 `sleep 120` 时 `kill` 掉 IDEA),桥都只在 stderr 打一行错误(`SseClientTransport is closed!` /
|
|
316
|
+
`Request timeout has expired`),**stdout 一个字节都不回**。而且**重开 IDEA 也不会自愈**(实测:重开 IDEA 后连续探测 5 分钟仍全部 TIMEOUT);
|
|
317
|
+
OpenCode 依旧显示 connected,`refresh`/`reload` 只会 `reconcile`,配置没变就跳过,不会重启它。所以:
|
|
318
|
+
- **后台心跳(见上)自动兜底**:5s 探活、连续 2 次不通就注销 `idea`(杀僵尸桥 + 移除工具),下一条消息自动重建 —— 常态下**不需要用户手动做什么**;
|
|
319
|
+
- 手动重建仍可用:重开 IDEA 后再执行一次 `/open-in-idea`(先注销旧 `idea`、再重新注册);
|
|
320
|
+
- 插件给 `idea` server 配 `executionTimeoutMs`(默认 **10 分钟**)作为硬上限:兜住"心跳尚未触发、工具已在途"的那次挂起,不再沿用 OpenCode 默认的 12 小时;
|
|
321
|
+
- "IDE 被关掉 → 工具挂起"这类**不会**走工具失败分类(没有 error);能自动分类处理的是"IDE 在、项目没打开"和"server 掉了(启动即失败)"。
|
|
322
|
+
- 项目标记存在插件名下 KV (`plugin:opencode-idea:project/<项目根路径>`)。插件实例按 **Location**(项目/打开目录)创建,标记按
|
|
323
|
+
**项目根路径**共享;但两个不同 Location(例如项目根与某子目录)各有自己的内存缓存 —— 单目录打开项目的常见用法不受影响。
|
|
324
|
+
- `/close-in-idea` 会**注销 IDE MCP** 并删标记 + 清空注入的环境。
|
|
325
|
+
- 插件**完全不再检查 `.idea` 目录**:是否是 IDEA 项目只由项目标记(`/open-in-idea` 设置、`/close-in-idea` 清除)决定。
|
|
326
|
+
(`/open-in-idea` 会让 IDEA 打开并导入该项目,`.idea` 由 IDEA 自己生成,插件无需也不应据此判断。)
|
|
244
327
|
- 打开 IDE 仅实现 macOS (`open -a`);首次打开若弹 Trust 对话框,需确认后重试。
|
|
245
328
|
- IDE MCP 没有「打开项目」工具,因此打开动作走 OS/CLI,而非 MCP。
|
|
246
329
|
- 插件热重载会断开 IDE 连接,需重跑 `/open-in-idea`。
|
package/package.json
CHANGED
package/src/ide-guidance.js
CHANGED
|
@@ -82,6 +82,16 @@ export const MCP_RECOVERY_GUIDANCE = [
|
|
|
82
82
|
'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
83
|
].join('\n');
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Recovery instruction for a marked project, where the plugin reconnects the
|
|
87
|
+
* IDE MCP on its own. The model must not fall back to "ask the user" when a
|
|
88
|
+
* single connection error is all it saw — the reconnect is already happening.
|
|
89
|
+
*/
|
|
90
|
+
export const MCP_RECOVERY_GUIDANCE_MARKED = [
|
|
91
|
+
'### IDEA MCP failure recovery',
|
|
92
|
+
'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.',
|
|
93
|
+
].join('\n');
|
|
94
|
+
|
|
85
95
|
/**
|
|
86
96
|
* Push the guidance onto a session `system` array. Called for every primary
|
|
87
97
|
* model request, so it stays idempotent per request.
|
|
@@ -97,9 +107,13 @@ export function appendIdeGuidance(system, projectPath) {
|
|
|
97
107
|
system.push({ type: 'text', text });
|
|
98
108
|
}
|
|
99
109
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
110
|
+
/**
|
|
111
|
+
* @param {unknown} system
|
|
112
|
+
* @param {{ marked?: boolean }} [options] `marked` picks the auto-reconnect variant
|
|
113
|
+
*/
|
|
114
|
+
export function appendIdeRecoveryGuidance(system, { marked = false } = {}) {
|
|
102
115
|
if (!Array.isArray(system)) return;
|
|
103
|
-
|
|
104
|
-
system.
|
|
116
|
+
const text = marked ? MCP_RECOVERY_GUIDANCE_MARKED : MCP_RECOVERY_GUIDANCE;
|
|
117
|
+
if (system.some((part) => part?.text === text)) return;
|
|
118
|
+
system.push({ type: 'text', text });
|
|
105
119
|
}
|
package/src/ide-launcher.js
CHANGED
|
@@ -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.
|
package/src/mcp/idea.js
CHANGED
|
@@ -6,11 +6,10 @@
|
|
|
6
6
|
// * The IDE exposes a local MCP server. IntelliJ 2026.2+ exposes the modern
|
|
7
7
|
// Streamable-HTTP endpoint at `http://127.0.0.1:<port>/stream`; older builds
|
|
8
8
|
// only had the legacy SSE endpoint at `/sse`.
|
|
9
|
-
// *
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// `IJ_MCP_SERVER_PROJECT_PATH`.
|
|
9
|
+
// * The project a call targets is selected by the project path: the HTTP
|
|
10
|
+
// header `IJ_MCP_SERVER_PROJECT_PATH` for the remote transport, or the
|
|
11
|
+
// `IJ_MCP_SERVER_PROJECT_PATH` environment variable for the stdio bridge
|
|
12
|
+
// (see `serverConfig` / `stdioServerConfig`).
|
|
14
13
|
// * Availability probe: a single `initialize` POST to `/stream` succeeds when
|
|
15
14
|
// the MCP server is up. We do NOT check whether a specific project is open —
|
|
16
15
|
// the user triggers `/open-in-idea` for the project they want, so "server is
|
|
@@ -18,6 +17,15 @@
|
|
|
18
17
|
|
|
19
18
|
export const PROJECT_HEADER = 'IJ_MCP_SERVER_PROJECT_PATH';
|
|
20
19
|
|
|
20
|
+
/** Environment variable the IDE's stdio bridge uses to select the project. */
|
|
21
|
+
export const PROJECT_ENV = 'IJ_MCP_SERVER_PROJECT_PATH';
|
|
22
|
+
|
|
23
|
+
/** Environment variable the IDE's stdio bridge uses to reach the running server. */
|
|
24
|
+
export const PORT_ENV = 'IJ_MCP_SERVER_PORT';
|
|
25
|
+
|
|
26
|
+
/** Argument that puts the IDE launcher into its stdio MCP bridge mode. */
|
|
27
|
+
export const STDIO_ARG = 'stdioMcpServer';
|
|
28
|
+
|
|
21
29
|
/** OpenCode MCP server name registered for the IDE. */
|
|
22
30
|
export const IDEA_SERVER_NAME = 'idea';
|
|
23
31
|
|
|
@@ -44,16 +52,73 @@ const CALL_TIMEOUT_MS = 15000;
|
|
|
44
52
|
*
|
|
45
53
|
* @param {number} port
|
|
46
54
|
* @param {string} projectPath
|
|
55
|
+
* @param {{ executionTimeoutMs?: number }} [options]
|
|
47
56
|
*/
|
|
48
|
-
export function serverConfig(port, projectPath) {
|
|
49
|
-
|
|
57
|
+
export function serverConfig(port, projectPath, options = {}) {
|
|
58
|
+
/** @type {Record<string, unknown>} */
|
|
59
|
+
const config = {
|
|
50
60
|
type: 'remote',
|
|
51
61
|
url: `http://127.0.0.1:${port}${MCP_STREAM_PATH}`,
|
|
52
62
|
headers: { [PROJECT_HEADER]: projectPath },
|
|
53
63
|
codemode: false,
|
|
54
64
|
};
|
|
65
|
+
return withExecutionTimeout(config, options);
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Build the OpenCode MCP server config for the IDE's **stdio bridge**.
|
|
70
|
+
*
|
|
71
|
+
* The IDE launcher (`<App>.app/Contents/MacOS/<binary>`) with the
|
|
72
|
+
* `stdioMcpServer` argument runs a small proxy that speaks MCP over
|
|
73
|
+
* stdin/stdout and connects to the running IDE MCP server selected by the
|
|
74
|
+
* environment. That makes the client side a child-process pipe instead of a
|
|
75
|
+
* Streamable-HTTP stream, so it does not suffer the idle-close that the remote
|
|
76
|
+
* transport does. The project and port are carried in the environment, matching
|
|
77
|
+
* what the remote config carries in a header and URL.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} executable absolute path to the IDE launcher
|
|
80
|
+
* @param {number} port IDE MCP port to bridge to
|
|
81
|
+
* @param {string} projectPath project root the bridge should target
|
|
82
|
+
* @param {{ executionTimeoutMs?: number }} [options]
|
|
83
|
+
*/
|
|
84
|
+
export function stdioServerConfig(executable, port, projectPath, options = {}) {
|
|
85
|
+
/** @type {Record<string, unknown>} */
|
|
86
|
+
const config = {
|
|
87
|
+
type: 'local',
|
|
88
|
+
// OpenCode's `LocalConfig.command` is a STRING ARRAY holding the executable
|
|
89
|
+
// followed by its arguments (`client.ts` does `const [command, ...args] =
|
|
90
|
+
// config.command`). There is no separate `args` field — passing one, or a
|
|
91
|
+
// bare string command, fails schema validation and the server never spawns.
|
|
92
|
+
command: [executable, STDIO_ARG],
|
|
93
|
+
environment: {
|
|
94
|
+
[PROJECT_ENV]: projectPath,
|
|
95
|
+
[PORT_ENV]: String(port),
|
|
96
|
+
},
|
|
97
|
+
codemode: false,
|
|
98
|
+
};
|
|
99
|
+
return withExecutionTimeout(config, options);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Attach OpenCode's per-server execution timeout to an MCP config.
|
|
104
|
+
*
|
|
105
|
+
* OpenCode defaults this to **12 hours**. The IDE's stdio bridge never answers a
|
|
106
|
+
* call whose upstream IDE connection has dropped (it logs on stderr but sends no
|
|
107
|
+
* JSON-RPC response), so without a cap a call made after the IDE died would sit
|
|
108
|
+
* unresolved for the whole default. A finite cap turns that permanent hang into
|
|
109
|
+
* an ordinary timeout error.
|
|
110
|
+
*
|
|
111
|
+
* @param {Record<string, unknown>} config
|
|
112
|
+
* @param {{ executionTimeoutMs?: number }} options
|
|
113
|
+
*/
|
|
114
|
+
function withExecutionTimeout(config, options) {
|
|
115
|
+
if (typeof options.executionTimeoutMs === 'number') {
|
|
116
|
+
return { ...config, timeout: { execution: options.executionTimeoutMs } };
|
|
117
|
+
}
|
|
118
|
+
return config;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
57
122
|
/**
|
|
58
123
|
* Is an IDE MCP server listening on `port`? One Streamable-HTTP `initialize`
|
|
59
124
|
* POST decides it: any 2xx JSON reply means the server is up.
|
|
@@ -81,7 +146,7 @@ export async function probePort(port, options = {}) {
|
|
|
81
146
|
params: {
|
|
82
147
|
protocolVersion: PROTOCOL_VERSION,
|
|
83
148
|
capabilities: {},
|
|
84
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
149
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.11' },
|
|
85
150
|
},
|
|
86
151
|
}),
|
|
87
152
|
signal: controller.signal,
|
|
@@ -151,7 +216,7 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
|
|
|
151
216
|
params: {
|
|
152
217
|
protocolVersion: PROTOCOL_VERSION,
|
|
153
218
|
capabilities: {},
|
|
154
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
219
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.11' },
|
|
155
220
|
},
|
|
156
221
|
});
|
|
157
222
|
const response = await post({
|
package/src/plugin.js
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
// opencode-idea — OpenCode plugin entry.
|
|
2
2
|
//
|
|
3
|
-
// For a
|
|
3
|
+
// For a project the user opted into IDEA this plugin:
|
|
4
4
|
// 1. injects the IDE integrated-terminal environment (JAVA_HOME / GOROOT /
|
|
5
5
|
// Node / Maven / ...) into every OpenCode shell;
|
|
6
|
-
// 2. registers the IDE MCP server with OpenCode
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// 2. registers the IDE MCP server with OpenCode — by default as the IDE's
|
|
7
|
+
// stdio bridge (`idea stdioMcpServer`), scoped to the project through
|
|
8
|
+
// `IJ_MCP_SERVER_PROJECT_PATH` — injects direct-call guidance for IDEA MCP
|
|
9
|
+
// tools, and updates only IDEA MCP tool descriptions;
|
|
9
10
|
// 3. registers the `/open-in-idea` command, which opens the current project
|
|
10
|
-
// in IntelliJ IDEA (reusing a running instance)
|
|
11
|
-
// capabilities immediately;
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// in IntelliJ IDEA (reusing a running instance), loads the IDE
|
|
12
|
+
// capabilities immediately, and marks the project as an IDEA project;
|
|
13
|
+
// `/close-in-idea` clears that mark;
|
|
14
|
+
// 4. registers the `idea-run-config` skill (marked projects only), which
|
|
15
|
+
// teaches the model how to manage `.run/*.run.xml` run configurations — a
|
|
16
|
+
// capability the IDE MCP server does not expose.
|
|
15
17
|
//
|
|
16
|
-
//
|
|
17
|
-
// NOT probe or connect the IDE (only the IDE-terminal env is read
|
|
18
|
-
// is requested). `/open-in-idea` probes, opens the IDE, and loads
|
|
19
|
-
// guidance on demand.
|
|
18
|
+
// A project that was never marked is left untouched. Everything is manual:
|
|
19
|
+
// setup does NOT probe or connect the IDE (only the IDE-terminal env is read
|
|
20
|
+
// when a probe is requested). `/open-in-idea` probes, opens the IDE, and loads
|
|
21
|
+
// the IDE MCP + guidance on demand.
|
|
22
|
+
//
|
|
23
|
+
// `/open-in-idea` opens the project in the IDE, waits until its MCP endpoint is
|
|
24
|
+
// ready, registers `idea` (stdio, project-scoped) and marks the project in
|
|
25
|
+
// `ctx.storage`; `/close-in-idea` unregisters the server and clears the mark.
|
|
26
|
+
// The mark gates the IDEA skills and the failure-driven recovery. After a manual
|
|
27
|
+
// open, an `idea_*` call that fails because the IDE MCP is not usable (the server
|
|
28
|
+
// dropped, or the project is not open in the IDE) re-runs the open flow; a plain
|
|
29
|
+
// business error is classified apart and left alone. Because a stdio bridge whose
|
|
30
|
+
// IDE connection drops never reports it (it hangs), a background heartbeat probes
|
|
31
|
+
// the IDE endpoint and, once it is clearly gone, deactivates the server — killing
|
|
32
|
+
// the zombie bridge and removing the tools so a call cannot hang — and the next
|
|
33
|
+
// user message rebuilds it.
|
|
20
34
|
//
|
|
21
35
|
// `Plugin.define` from `@opencode/plugin` is an identity function, so a plain
|
|
22
36
|
// `{ id, setup }` object is the whole plugin contract. Exporting it directly
|
|
@@ -27,14 +41,17 @@ import {
|
|
|
27
41
|
IDEA_SERVER_NAME,
|
|
28
42
|
findIdePort,
|
|
29
43
|
serverConfig,
|
|
44
|
+
stdioServerConfig,
|
|
30
45
|
} from './mcp/idea.js';
|
|
31
46
|
import { readIdeTerminalEnv } from './mcp/ide-env.js';
|
|
32
|
-
import { currentProjectPath
|
|
47
|
+
import { currentProjectPath } from './project.js';
|
|
33
48
|
import { applyEnv, mergeEnv } from './env.js';
|
|
34
49
|
import {
|
|
35
50
|
createLaunchGuard,
|
|
51
|
+
DEFAULT_IDE_APP,
|
|
36
52
|
openInIde,
|
|
37
53
|
resolveIdeApp,
|
|
54
|
+
resolveIdeExecutable,
|
|
38
55
|
} from './ide-launcher.js';
|
|
39
56
|
import {
|
|
40
57
|
appendIdeGuidance,
|
|
@@ -53,6 +70,20 @@ const DEFAULT_MCP_START_TIMEOUT_MS = 60000;
|
|
|
53
70
|
const MCP_START_POLL_MS = 1000;
|
|
54
71
|
const DEFAULT_TOOL_REFRESH_TIMEOUT_MS = 5000;
|
|
55
72
|
const TOOL_REFRESH_POLL_MS = 25;
|
|
73
|
+
// Hard cap on ONE IDE MCP tool call. OpenCode's own default is 12 hours, and the
|
|
74
|
+
// IDE's stdio bridge NEVER answers a call once its upstream IDE connection has
|
|
75
|
+
// dropped (it logs on stderr but sends no JSON-RPC response) — so a call made
|
|
76
|
+
// after the IDE died would otherwise sit unresolved for 12h. 10 minutes is far
|
|
77
|
+
// beyond any normal IDE tool and turns that permanent hang into a normal error.
|
|
78
|
+
const DEFAULT_EXECUTION_TIMEOUT_MS = 600000;
|
|
79
|
+
// Background IDE liveness probe. The stdio bridge NEVER reports a dropped IDE
|
|
80
|
+
// connection (it just hangs), so the plugin watches the IDE endpoint itself.
|
|
81
|
+
// After HEARTBEAT_FAILURES consecutive misses it deactivates the `idea` server
|
|
82
|
+
// — which kills the zombie bridge child AND removes the `idea_*` tools, so the
|
|
83
|
+
// model can never call into a dead pipe — and latches a rebuild for the next
|
|
84
|
+
// user message (the bridge never heals on its own).
|
|
85
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
|
|
86
|
+
const DEFAULT_HEARTBEAT_FAILURES = 2;
|
|
56
87
|
const IDEA_READY_TOOL_IDS = new Set(['idea_read_file', 'idea_apply_patch']);
|
|
57
88
|
|
|
58
89
|
/**
|
|
@@ -147,9 +178,94 @@ function toolIds(tools) {
|
|
|
147
178
|
return (tools ?? []).map((tool) => tool.id).filter((id) => typeof id === 'string').sort();
|
|
148
179
|
}
|
|
149
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Classify an `idea_*` tool failure for the reconnect decision.
|
|
183
|
+
*
|
|
184
|
+
* It must never mistake a plain business error (a bad path, a missing file) for
|
|
185
|
+
* a broken connection, so only two shapes trigger action:
|
|
186
|
+
*
|
|
187
|
+
* - `project`: the IDE is up but the requested project is not open in it, e.g.
|
|
188
|
+
* "`projectPath`=... doesn't correspond to any open project." Re-opening the
|
|
189
|
+
* project fixes it.
|
|
190
|
+
* - `mcp`: the IDE MCP server itself is gone (dropped / transport closed).
|
|
191
|
+
*
|
|
192
|
+
* Everything else is `none`.
|
|
193
|
+
*
|
|
194
|
+
* @param {{ message?: unknown } | undefined} error
|
|
195
|
+
* @returns {'project' | 'mcp' | 'none'}
|
|
196
|
+
*/
|
|
197
|
+
function classifyIdeaFailure(error) {
|
|
198
|
+
const message = typeof error?.message === 'string' ? error.message : '';
|
|
199
|
+
// "Project not open in the IDE" comes in two shapes, both meaning the same
|
|
200
|
+
// thing and both fixed by opening the project again:
|
|
201
|
+
// - the called tool carried no `projectPath`, so the server could not pick
|
|
202
|
+
// one: "Unable to determine the target project for the current MCP tool
|
|
203
|
+
// call." (this is what the plugin's stdio registration produces, since it
|
|
204
|
+
// sets the project through env, not through the call)
|
|
205
|
+
// - an explicit `projectPath` was passed but is not open: "`projectPath`=...
|
|
206
|
+
// doesn't correspond to any open project."
|
|
207
|
+
if (
|
|
208
|
+
message.includes('Unable to determine the target project') ||
|
|
209
|
+
message.includes('correspond to any open project')
|
|
210
|
+
) {
|
|
211
|
+
return 'project';
|
|
212
|
+
}
|
|
213
|
+
// Only transport-level markers, which OpenCode / the IDE bridge emit
|
|
214
|
+
// themselves. The message of an MCP *tool error* is the tool's own content
|
|
215
|
+
// (`tool/mcp.ts` turns `result.isError` into `ToolFailure({ message: <text> })`),
|
|
216
|
+
// so a generic string such as "Connection refused" from a SQL or run-config
|
|
217
|
+
// tool would otherwise be misread as a dropped connection and rebuild a
|
|
218
|
+
// healthy bridge.
|
|
219
|
+
if (
|
|
220
|
+
message.includes(`MCP server "${IDEA_SERVER_NAME}" is not available`) ||
|
|
221
|
+
message.includes('MCP server is not connected') ||
|
|
222
|
+
message.includes('Error POSTing to endpoint') ||
|
|
223
|
+
message.includes('SseClientTransport is closed')
|
|
224
|
+
) {
|
|
225
|
+
return 'mcp';
|
|
226
|
+
}
|
|
227
|
+
return 'none';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Replace a tool error's message, so the model sees the replacement. OpenCode
|
|
232
|
+
* returns `event.error` after the `execute.after` hook resolves, so rewriting it
|
|
233
|
+
* is how the plugin steers the model after an automatic reconnect.
|
|
234
|
+
*
|
|
235
|
+
* The error is a `Tool.Error` instance (`ToolFailure` extends it). It is rebuilt
|
|
236
|
+
* through its own constructor — no runtime dependency on `@opencode/ai` — with a
|
|
237
|
+
* direct field mutation as a fallback.
|
|
238
|
+
*
|
|
239
|
+
* @param {{ error?: unknown }} event
|
|
240
|
+
* @param {string} message
|
|
241
|
+
* @returns {boolean}
|
|
242
|
+
*/
|
|
243
|
+
function rewriteToolError(event, message) {
|
|
244
|
+
const current = event?.error;
|
|
245
|
+
if (!current || typeof current !== 'object') return false;
|
|
246
|
+
try {
|
|
247
|
+
const Ctor = current.constructor;
|
|
248
|
+
if (typeof Ctor === 'function') {
|
|
249
|
+
const next = { message };
|
|
250
|
+
if (current.error !== undefined) next.error = current.error;
|
|
251
|
+
if (current.metadata !== undefined) next.metadata = current.metadata;
|
|
252
|
+
event.error = new Ctor(next);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// fall through to mutation
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
current.message = message;
|
|
260
|
+
return true;
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
150
266
|
/**
|
|
151
267
|
* @typedef {{
|
|
152
|
-
* status: 'connected' | 'tools-loading' | 'mcp-unavailable' | 'launch-failed' | 'disabled',
|
|
268
|
+
* status: 'connected' | 'tools-loading' | 'mcp-unavailable' | 'launch-failed' | 'disabled' | 'closed',
|
|
153
269
|
* port?: number,
|
|
154
270
|
* opened?: boolean,
|
|
155
271
|
* unavailableNoticeSent?: boolean
|
|
@@ -185,6 +301,8 @@ export function ideFeedback(result) {
|
|
|
185
301
|
return '无法拉起 IntelliJ IDEA(未找到应用或启动失败)。请确认已安装 IDEA,或手动打开后重试 /open-in-idea。';
|
|
186
302
|
case 'disabled':
|
|
187
303
|
return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
|
|
304
|
+
case 'closed':
|
|
305
|
+
return '已关闭当前项目的 IDEA 接入:已注销 IDE MCP 并清除项目标记。如需重新接入,请再次执行 /open-in-idea。';
|
|
188
306
|
default:
|
|
189
307
|
return '已处理 /open-in-idea。';
|
|
190
308
|
}
|
|
@@ -195,8 +313,11 @@ export function ideFeedback(result) {
|
|
|
195
313
|
* notification, not a task: the model must only acknowledge it. The user sees
|
|
196
314
|
* the clean notice instead, via `metadata.displayText`.
|
|
197
315
|
*/
|
|
316
|
+
/** Marks the plugin's own session notices, so recovery never reacts to them. */
|
|
317
|
+
export const NOTICE_PREFIX = '【插件通知】';
|
|
318
|
+
|
|
198
319
|
export function idePromptText(result) {
|
|
199
|
-
return [
|
|
320
|
+
return [`${NOTICE_PREFIX}${ideFeedback(result)}`, '这是发给用户的通知,你无需处理。请只回复"收到"。'].join('\n\n');
|
|
200
321
|
}
|
|
201
322
|
|
|
202
323
|
/** `metadata` so the UI shows the clean notice while the model sees the instruction. */
|
|
@@ -219,6 +340,11 @@ export default {
|
|
|
219
340
|
* mcpProbeTimeoutMs?: number,
|
|
220
341
|
* mcpStartTimeoutMs?: number,
|
|
221
342
|
* toolRefreshTimeoutMs?: number,
|
|
343
|
+
* executionTimeoutMs?: number,
|
|
344
|
+
* heartbeatIntervalMs?: number,
|
|
345
|
+
* heartbeatFailures?: number,
|
|
346
|
+
* transport?: 'stdio' | 'http',
|
|
347
|
+
* ideExecutable?: string,
|
|
222
348
|
* feedback?: false | 'message',
|
|
223
349
|
* },
|
|
224
350
|
* command?: {
|
|
@@ -250,31 +376,57 @@ export default {
|
|
|
250
376
|
* tool?: {
|
|
251
377
|
* list?: () => Promise<ReadonlyArray<{ id?: string }>>,
|
|
252
378
|
* reload?: () => Promise<void>,
|
|
379
|
+
* hook?: (name: string, cb: (event: {
|
|
380
|
+
* tool?: string,
|
|
381
|
+
* sessionID?: string,
|
|
382
|
+
* status?: string,
|
|
383
|
+
* error?: unknown,
|
|
384
|
+
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
253
385
|
* },
|
|
254
386
|
* mcp: {
|
|
255
387
|
* transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
256
388
|
* reload?: () => Promise<void>,
|
|
389
|
+
* list?: () => Promise<{ data?: ReadonlyArray<{ name?: string, status?: { status?: string } }> }>,
|
|
257
390
|
* },
|
|
258
391
|
* shell: { hook: (name: string, cb: (input: { env: Record<string, string | undefined> }) => void) => Promise<{ dispose: () => Promise<void> | void }> },
|
|
392
|
+
* storage?: {
|
|
393
|
+
* get: (key: string) => Promise<unknown>,
|
|
394
|
+
* set: (key: string, value: unknown) => Promise<void>,
|
|
395
|
+
* remove: (key: string) => Promise<void>,
|
|
396
|
+
* },
|
|
259
397
|
* }} ctx
|
|
260
398
|
*/
|
|
261
399
|
async setup(ctx) {
|
|
262
400
|
const projectPath = currentProjectPath(ctx.location);
|
|
263
401
|
if (!projectPath) return;
|
|
264
|
-
// Where IDEA tools can exist at all. Computed once: the recovery guidance is
|
|
265
|
-
// injected per model request, so it must not cost a filesystem check each
|
|
266
|
-
// time, and unrelated projects must not receive IDEA prompts they can never
|
|
267
|
-
// use.
|
|
268
|
-
const isIdeaProject = hasIdeaDirectory(projectPath);
|
|
269
402
|
|
|
270
403
|
const options = ctx.options || {};
|
|
271
404
|
const ports = asArray(options.ports, DEFAULT_PORTS);
|
|
272
405
|
const injectEnv = options.injectEnv !== false;
|
|
273
406
|
const injectGuidance = options.injectGuidance !== false;
|
|
274
407
|
const ideApp = resolveIdeApp(options.openInIde);
|
|
408
|
+
// Transport for the IDE MCP server. `stdio` (default) registers the IDE's
|
|
409
|
+
// stdio bridge as a local server: the client side is a child-process pipe,
|
|
410
|
+
// which — unlike the remote Streamable-HTTP stream — does not get closed
|
|
411
|
+
// while idle and never reconnected. `http` keeps the older remote config.
|
|
412
|
+
const transport = options.transport === 'http' ? 'http' : 'stdio';
|
|
413
|
+
const ideExecutable = typeof options.ideExecutable === 'string'
|
|
414
|
+
? options.ideExecutable
|
|
415
|
+
// Resolve independently of `openInIde`: that option only controls whether
|
|
416
|
+
// the plugin may LAUNCH the IDE, not which transport the MCP server uses.
|
|
417
|
+
// Falling back to the default app when launching is disabled keeps stdio
|
|
418
|
+
// the default there too (otherwise `openInIde: false` silently registered
|
|
419
|
+
// the remote HTTP transport).
|
|
420
|
+
: resolveIdeExecutable(ideApp ?? DEFAULT_IDE_APP);
|
|
421
|
+
const useStdio = transport === 'stdio' && typeof ideExecutable === 'string';
|
|
275
422
|
const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
|
|
276
423
|
const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
|
|
277
424
|
const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
|
|
425
|
+
// Cap on a single IDE tool call, so a bridge that hangs (IDE died) fails in
|
|
426
|
+
// finite time instead of OpenCode's 12h default. See DEFAULT_EXECUTION_TIMEOUT_MS.
|
|
427
|
+
const executionTimeoutMs = asPositiveNumber(options.executionTimeoutMs, DEFAULT_EXECUTION_TIMEOUT_MS);
|
|
428
|
+
const heartbeatIntervalMs = asPositiveNumber(options.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
429
|
+
const heartbeatFailures = asPositiveNumber(options.heartbeatFailures, DEFAULT_HEARTBEAT_FAILURES);
|
|
278
430
|
// "message" (default) injects a clearly-labelled notification that the
|
|
279
431
|
// model only acknowledges ("收到"); the user sees the clean notice via
|
|
280
432
|
// metadata.displayText. false is silent.
|
|
@@ -292,11 +444,78 @@ export default {
|
|
|
292
444
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
293
445
|
let sessionRegistration;
|
|
294
446
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
447
|
+
let toolRegistration;
|
|
448
|
+
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
449
|
+
let promptRegistration;
|
|
450
|
+
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
295
451
|
let commandRegistration;
|
|
296
452
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
297
453
|
let skillRegistration;
|
|
454
|
+
let skillsRegistered = false;
|
|
298
455
|
let disposed = false;
|
|
299
456
|
|
|
457
|
+
// Background heartbeat state (see DEFAULT_HEARTBEAT_INTERVAL_MS).
|
|
458
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
459
|
+
let heartbeatTimer;
|
|
460
|
+
let heartbeatMisses = 0;
|
|
461
|
+
// Latches when the IDE was seen down and the server was deactivated. It is
|
|
462
|
+
// cleared ONLY by a successful rebuild — never by the endpoint coming back —
|
|
463
|
+
// because the bridge does not heal on its own.
|
|
464
|
+
let needsRebuild = false;
|
|
465
|
+
|
|
466
|
+
// Project-level mark: has the user opted this project into IDEA via
|
|
467
|
+
// `/open-in-idea`? One plugin instance serves one project (OpenCode
|
|
468
|
+
// activates plugins per Location), so a single boolean is the whole cache.
|
|
469
|
+
// The durable copy lives in `ctx.storage` under `project/<projectPath>`.
|
|
470
|
+
// The commands are the only writers: they persist first, then invalidate
|
|
471
|
+
// this cache so the next read comes back from storage.
|
|
472
|
+
const storage = ctx.storage;
|
|
473
|
+
const markKey = `project/${projectPath}`;
|
|
474
|
+
/** @type {boolean | undefined} undefined = a storage read is due */
|
|
475
|
+
let markCache;
|
|
476
|
+
|
|
477
|
+
/** Read the durable mark into the cache; at most one storage read per invalidation. */
|
|
478
|
+
const readMark = async () => {
|
|
479
|
+
if (markCache !== undefined) return markCache;
|
|
480
|
+
if (!storage || typeof storage.get !== 'function') {
|
|
481
|
+
markCache = false;
|
|
482
|
+
return markCache;
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
markCache = (await storage.get(markKey)) === true;
|
|
486
|
+
} catch {
|
|
487
|
+
// storage is best effort; fall back to unmarked
|
|
488
|
+
markCache = false;
|
|
489
|
+
}
|
|
490
|
+
return markCache;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Persist the mark, then invalidate the cache so the next reader re-reads
|
|
495
|
+
* storage. Best effort: an in-memory invalidation still applies if the write
|
|
496
|
+
* fails.
|
|
497
|
+
* @param {boolean} marked
|
|
498
|
+
*/
|
|
499
|
+
const setMark = async (marked) => {
|
|
500
|
+
try {
|
|
501
|
+
if (marked) await storage?.set?.(markKey, true);
|
|
502
|
+
else await storage?.remove?.(markKey);
|
|
503
|
+
} catch {
|
|
504
|
+
// best effort
|
|
505
|
+
}
|
|
506
|
+
// Keep the in-memory cache in sync with the write (write-through), so any
|
|
507
|
+
// later synchronous reader sees the new value immediately.
|
|
508
|
+
markCache = marked;
|
|
509
|
+
// The skill lifecycle follows the mark: marking the project registers the
|
|
510
|
+
// IDEA skills, clearing the mark removes them again. Same semantics as the
|
|
511
|
+
// reconnect behavior, so the two can never disagree.
|
|
512
|
+
if (marked) await ensureSkills();
|
|
513
|
+
else await removeSkills();
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// Resolve the mark once, so the request hooks can read it synchronously.
|
|
517
|
+
await readMark();
|
|
518
|
+
|
|
300
519
|
const deactivateIde = async () => {
|
|
301
520
|
const current = registrations.slice();
|
|
302
521
|
registrations.length = 0;
|
|
@@ -336,36 +555,23 @@ export default {
|
|
|
336
555
|
};
|
|
337
556
|
|
|
338
557
|
const activateIde = async (port) => {
|
|
558
|
+
const config = useStdio
|
|
559
|
+
? stdioServerConfig(ideExecutable, port, projectPath, { executionTimeoutMs })
|
|
560
|
+
: serverConfig(port, projectPath, { executionTimeoutMs });
|
|
339
561
|
const mcpRegistration = await ctx.mcp.transform((editor) => {
|
|
340
|
-
editor.set(IDEA_SERVER_NAME,
|
|
562
|
+
editor.set(IDEA_SERVER_NAME, config);
|
|
341
563
|
});
|
|
342
564
|
registrations = [mcpRegistration];
|
|
343
565
|
return refreshIdeTools();
|
|
344
566
|
};
|
|
345
567
|
|
|
346
|
-
/**
|
|
347
|
-
* Is the IDE MCP server contributing tools right now? OpenCode's registry is
|
|
348
|
-
* the source of truth: it adds the tools when a server connects and drops
|
|
349
|
-
* them when the connection stops (`stopServer` publishes `mcp.tools.changed`
|
|
350
|
-
* and the registry reloads). No probe of our own is involved, and only
|
|
351
|
-
* `/open-in-idea` asks, so the read is user-initiated.
|
|
352
|
-
*/
|
|
353
|
-
const ideaToolsRegistered = async () => {
|
|
354
|
-
if (typeof ctx.tool?.list !== 'function') return false;
|
|
355
|
-
try {
|
|
356
|
-
return toolIds(await ctx.tool.list()).some((id) => id.startsWith('idea_'));
|
|
357
|
-
} catch {
|
|
358
|
-
return false;
|
|
359
|
-
}
|
|
360
|
-
};
|
|
361
|
-
|
|
362
568
|
const reconcileOnce = async ({ probeIde = true } = {}) => {
|
|
363
569
|
if (disposed) return false;
|
|
364
570
|
|
|
365
571
|
// Manual: only `/open-in-idea` probes the IDE. Setup calls this with
|
|
366
572
|
// `probeIde: false` so it never connects until the command asks.
|
|
367
573
|
const port = probeIde ? await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }) : undefined;
|
|
368
|
-
const isJetBrains =
|
|
574
|
+
const isJetBrains = markCache === true || port !== undefined;
|
|
369
575
|
if (!isJetBrains) {
|
|
370
576
|
currentEnv = { env: {}, prependPath: [] };
|
|
371
577
|
await deactivateIde();
|
|
@@ -383,10 +589,15 @@ export default {
|
|
|
383
589
|
shellRegistration = await ctx.shell.hook('create.before', (input) => applyEnv(input, currentEnv));
|
|
384
590
|
}
|
|
385
591
|
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
592
|
+
// Always tear the server down and register it again. A stdio bridge whose
|
|
593
|
+
// IDE connection dropped becomes a permanent zombie: the child process is
|
|
594
|
+
// still alive, so OpenCode keeps reporting it as connected, never restarts
|
|
595
|
+
// it, and SKIPS a re-`set` whose config is unchanged (`reconcile()` compares
|
|
596
|
+
// the config). Disposing the registration removes the server from the
|
|
597
|
+
// reconciled config, which is what actually kills the child process — so a
|
|
598
|
+
// full rebuild is the only reliable way back to a working bridge.
|
|
599
|
+
// `/open-in-idea` is manual and rare; rebuilding an already-healthy bridge
|
|
600
|
+
// costs one restart, which is the right trade for never leaving a zombie.
|
|
390
601
|
await deactivateIde();
|
|
391
602
|
if (port === undefined) return false;
|
|
392
603
|
return activateIde(port);
|
|
@@ -485,9 +696,75 @@ export default {
|
|
|
485
696
|
return run;
|
|
486
697
|
};
|
|
487
698
|
|
|
699
|
+
/**
|
|
700
|
+
* The whole `/open-in-idea` flow, shared by the command and the auto-recovery
|
|
701
|
+
* path: ensure an IDE MCP endpoint, then register it and load capabilities.
|
|
702
|
+
*
|
|
703
|
+
* @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
|
|
704
|
+
* @returns {Promise<IdeCommandResult>}
|
|
705
|
+
*/
|
|
706
|
+
const runOpenInIdea = async ({ onUnavailable } = {}) => {
|
|
707
|
+
const result = await ensureIde({ onUnavailable });
|
|
708
|
+
const { port } = result;
|
|
709
|
+
let toolsReady = false;
|
|
710
|
+
try {
|
|
711
|
+
// Do not repeat the expensive probe when the preflight already found no
|
|
712
|
+
// MCP endpoint. The command will retry after the user enables MCP +
|
|
713
|
+
// Brave Mode.
|
|
714
|
+
toolsReady = (await reconcile({ probeIde: port !== undefined })) === true;
|
|
715
|
+
} catch {
|
|
716
|
+
// capability load is best effort; feedback still matters
|
|
717
|
+
}
|
|
718
|
+
return result.status === 'connected' && !toolsReady
|
|
719
|
+
? { ...result, status: 'tools-loading' }
|
|
720
|
+
: result;
|
|
721
|
+
};
|
|
722
|
+
|
|
488
723
|
// Setup never probes or connects the IDE — everything is on `/open-in-idea`.
|
|
489
724
|
await reconcile({ probeIde: false });
|
|
490
725
|
|
|
726
|
+
/**
|
|
727
|
+
* One heartbeat tick. Only marked projects are watched, and it only acts
|
|
728
|
+
* when an `idea` server is actually registered: the point is to protect an
|
|
729
|
+
* ESTABLISHED connection. A project that never connected has no zombie to
|
|
730
|
+
* kill, and must not have IDEA launched on its behalf. The deactivation
|
|
731
|
+
* runs under the reconcile chain, so a tick cannot race `/open-in-idea`.
|
|
732
|
+
*/
|
|
733
|
+
let heartbeatBusy = false;
|
|
734
|
+
const heartbeatTick = async () => {
|
|
735
|
+
if (disposed || heartbeatBusy) return;
|
|
736
|
+
if (markCache !== true || registrations.length === 0) {
|
|
737
|
+
heartbeatMisses = 0;
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
heartbeatBusy = true;
|
|
741
|
+
try {
|
|
742
|
+
const port = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
|
|
743
|
+
if (port !== undefined) {
|
|
744
|
+
heartbeatMisses = 0;
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
heartbeatMisses += 1;
|
|
748
|
+
if (heartbeatMisses < heartbeatFailures) return;
|
|
749
|
+
// The IDE is gone. Deactivating kills the zombie bridge child and
|
|
750
|
+
// removes the `idea_*` tools, so the model cannot call into a dead
|
|
751
|
+
// pipe. Latch a rebuild for the next user message.
|
|
752
|
+
const kicked = await enqueue(async () => {
|
|
753
|
+
if (disposed || registrations.length === 0) return false;
|
|
754
|
+
await deactivateIde();
|
|
755
|
+
return true;
|
|
756
|
+
});
|
|
757
|
+
if (kicked) needsRebuild = true;
|
|
758
|
+
} finally {
|
|
759
|
+
heartbeatBusy = false;
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
heartbeatTimer = setInterval(() => {
|
|
763
|
+
heartbeatTick().catch(() => {});
|
|
764
|
+
}, heartbeatIntervalMs);
|
|
765
|
+
// Never keep the host process alive just to probe (also keeps tests from hanging).
|
|
766
|
+
if (typeof heartbeatTimer?.unref === 'function') heartbeatTimer.unref();
|
|
767
|
+
|
|
491
768
|
const sendFeedback = async (input, result) => {
|
|
492
769
|
if (feedback !== 'message' || typeof ctx.session?.prompt !== 'function') return false;
|
|
493
770
|
try {
|
|
@@ -507,65 +784,91 @@ export default {
|
|
|
507
784
|
|
|
508
785
|
// The shipped skills are static knowledge (`skills/<id>/SKILL.md`), e.g. the
|
|
509
786
|
// run-configuration workflow: where `.run/*.run.xml` live, how to bootstrap a
|
|
510
|
-
// schema from a real example, and how to verify a write.
|
|
511
|
-
//
|
|
787
|
+
// schema from a real example, and how to verify a write. Registered only for
|
|
788
|
+
// a project that has been marked as an IDEA project, so unrelated projects
|
|
789
|
+
// never see an IDEA-specific skill.
|
|
512
790
|
//
|
|
513
791
|
// Deliberately NOT wrapped in a catch: these are core features of the plugin,
|
|
514
792
|
// so a rejected definition must fail loudly. Swallowing it would ship a
|
|
515
793
|
// silently incomplete plugin — which is exactly how 0.0.6 lost
|
|
516
794
|
// `/open-in-idea` without anyone noticing until the command was gone.
|
|
517
|
-
|
|
795
|
+
const ensureSkills = async () => {
|
|
796
|
+
if (skillsRegistered || typeof ctx.skill?.transform !== 'function') return;
|
|
797
|
+
skillsRegistered = true;
|
|
518
798
|
// Read the documents before the transform: callbacks must stay synchronous.
|
|
519
799
|
const skills = readSkills();
|
|
520
800
|
skillRegistration = await ctx.skill.transform((editor) => {
|
|
521
801
|
for (const skill of skills) editor.add(skill);
|
|
522
802
|
});
|
|
523
|
-
}
|
|
803
|
+
};
|
|
804
|
+
/** Remove the IDEA skills again when the project is unmarked. */
|
|
805
|
+
const removeSkills = async () => {
|
|
806
|
+
if (!skillsRegistered || !skillRegistration) return;
|
|
807
|
+
skillsRegistered = false;
|
|
808
|
+
const registration = skillRegistration;
|
|
809
|
+
skillRegistration = undefined;
|
|
810
|
+
try {
|
|
811
|
+
await registration.dispose();
|
|
812
|
+
} catch {
|
|
813
|
+
// best effort
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
// A project marked in an earlier run gets its skills at setup; a project
|
|
817
|
+
// marked for the first time gets them when `/open-in-idea` runs (both paths
|
|
818
|
+
// go through `setMark` / this setup hook).
|
|
819
|
+
if (markCache === true) await ensureSkills();
|
|
524
820
|
|
|
525
821
|
// `/open-in-idea`: manual, re-entrant trigger that also loads the IDE
|
|
526
|
-
// capabilities immediately
|
|
822
|
+
// capabilities immediately, and marks the project so later messages keep the
|
|
823
|
+
// IDE alive. `/close-in-idea` removes that mark. Both persist through
|
|
824
|
+
// `ctx.storage` and invalidate the in-memory cache.
|
|
527
825
|
if (typeof ctx.command?.transform === 'function') {
|
|
528
826
|
commandRegistration = await ctx.command.transform((editor) => {
|
|
529
827
|
editor.add({
|
|
530
828
|
name: 'open-in-idea',
|
|
531
829
|
description: '用 IntelliJ IDEA 打开当前项目,并即时加载 IDE MCP 与项目 SDK 环境',
|
|
532
830
|
execute: async (input) => {
|
|
831
|
+
// Mark the project before running: the mark means "this is an IDEA
|
|
832
|
+
// project", so it is persisted as soon as the user asks, whether or
|
|
833
|
+
// not the IDE happens to be reachable right now. `setMark` also
|
|
834
|
+
// registers the IDEA skills.
|
|
835
|
+
await setMark(true);
|
|
533
836
|
/** @type {boolean} */
|
|
534
837
|
let unavailableNoticeSent = false;
|
|
535
|
-
const result = await
|
|
838
|
+
const result = await runOpenInIdea({
|
|
536
839
|
onUnavailable: async (notice) => {
|
|
537
840
|
unavailableNoticeSent = await sendFeedback(input, notice);
|
|
538
841
|
return unavailableNoticeSent;
|
|
539
842
|
},
|
|
540
843
|
});
|
|
541
|
-
const { port } = result;
|
|
542
|
-
let toolsReady = false;
|
|
543
|
-
try {
|
|
544
|
-
// Do not repeat the expensive probe when the preflight already
|
|
545
|
-
// found no MCP endpoint. The command will retry after the user
|
|
546
|
-
// enables MCP + Brave Mode.
|
|
547
|
-
toolsReady = (await reconcile({ probeIde: port !== undefined })) === true;
|
|
548
|
-
} catch {
|
|
549
|
-
// capability load is best effort; feedback still matters
|
|
550
|
-
}
|
|
551
|
-
const finalResult = result.status === 'connected' && !toolsReady
|
|
552
|
-
? { ...result, status: 'tools-loading' }
|
|
553
|
-
: result;
|
|
554
844
|
// The unavailable notice is the final result for this command. If
|
|
555
845
|
// it was already delivered, do not show the same warning twice.
|
|
556
846
|
const unavailableAlreadySent = unavailableNoticeSent || result.unavailableNoticeSent;
|
|
557
|
-
if (!unavailableAlreadySent ||
|
|
558
|
-
await sendFeedback(input,
|
|
847
|
+
if (!unavailableAlreadySent || result.status !== 'mcp-unavailable') {
|
|
848
|
+
await sendFeedback(input, result);
|
|
559
849
|
}
|
|
560
850
|
},
|
|
561
851
|
});
|
|
852
|
+
editor.add({
|
|
853
|
+
name: 'close-in-idea',
|
|
854
|
+
description: '取消当前项目的 IDEA 接入:注销 IDE MCP 并清除项目标记',
|
|
855
|
+
execute: async (input) => {
|
|
856
|
+
await setMark(false);
|
|
857
|
+
// Unregister the IDE MCP server and clear the injected env. Reuses
|
|
858
|
+
// the serialized reconcile, so a concurrent rebuild (an in-flight
|
|
859
|
+
// `/open-in-idea`, or a heartbeat tick) cannot re-register the
|
|
860
|
+
// server after the mark is gone.
|
|
861
|
+
await reconcile({ probeIde: false });
|
|
862
|
+
await sendFeedback(input, { status: 'closed' });
|
|
863
|
+
},
|
|
864
|
+
});
|
|
562
865
|
});
|
|
563
866
|
}
|
|
564
867
|
|
|
565
868
|
// Hide the tools the model must not see, and point it at the rest. This hook
|
|
566
869
|
// runs for every agent-loop step (including tool-driven continuations), so
|
|
567
|
-
// it
|
|
568
|
-
//
|
|
870
|
+
// it stays a cheap, I/O-free function of the request: it checks the cached
|
|
871
|
+
// project mark and this request's own tool snapshot, and takes no action.
|
|
569
872
|
//
|
|
570
873
|
// `event.tools` is this request's tool snapshot, and OpenCode's own registry
|
|
571
874
|
// adds IDEA tools when the server connects and drops them when it stops
|
|
@@ -577,17 +880,63 @@ export default {
|
|
|
577
880
|
const tools = event?.tools;
|
|
578
881
|
const serving = tools !== undefined && Object.keys(tools).some((name) => name.startsWith('idea_'));
|
|
579
882
|
if (serving) removeIdeaToolDefinitions(tools, HIDDEN_IDEA_TOOLS);
|
|
580
|
-
if (
|
|
883
|
+
if (markCache === true || serving) appendIdeRecoveryGuidance(event?.system, { marked: markCache === true });
|
|
581
884
|
if (!injectGuidance) return;
|
|
582
885
|
// Guidance promises tools the model can call, so it only goes out when
|
|
583
886
|
// they are actually in this request.
|
|
584
887
|
if (!serving) return;
|
|
585
888
|
appendIdeGuidance(event?.system, projectPath);
|
|
586
889
|
});
|
|
890
|
+
|
|
891
|
+
// Rebuild after a heartbeat-detected drop. It reads only the in-memory
|
|
892
|
+
// `needsRebuild` latch, so it is a cheap no-op on every other message.
|
|
893
|
+
// OpenCode awaits `prompt` before the request is built, so when the IDE is
|
|
894
|
+
// back, the message that triggers this already carries working tools.
|
|
895
|
+
promptRegistration = await ctx.session.hook('prompt', async (event) => {
|
|
896
|
+
if (disposed || !needsRebuild || markCache !== true) return;
|
|
897
|
+
// Never react to the plugin's own notices, or recovery would feed on
|
|
898
|
+
// itself. Match the notice TEXT, never `metadata.displayText`: OpenCode
|
|
899
|
+
// also sets `displayText` on ordinary user messages (it is the message
|
|
900
|
+
// text itself), so a displayText guard silently skips every real message
|
|
901
|
+
// — verified live, and it made this rebuild never run.
|
|
902
|
+
const text = event?.prompt?.text;
|
|
903
|
+
if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
|
|
904
|
+
const result = await runOpenInIdea();
|
|
905
|
+
if (result.port !== undefined) {
|
|
906
|
+
needsRebuild = false;
|
|
907
|
+
heartbeatMisses = 0;
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// The only self-initiated action: when an `idea_*` call fails in a way
|
|
912
|
+
// that means the IDE MCP is not usable, run the open flow again. The
|
|
913
|
+
// failure is classified — a plain business error (a bad path, a missing
|
|
914
|
+
// file) never triggers a reconnect, only "the MCP is not available" or
|
|
915
|
+
// "the project is not open in the IDE" do.
|
|
916
|
+
if (typeof ctx.tool?.hook === 'function') {
|
|
917
|
+
toolRegistration = await ctx.tool.hook('execute.after', async (event) => {
|
|
918
|
+
if (event?.status !== 'error') return;
|
|
919
|
+
if (typeof event?.tool !== 'string' || !event.tool.startsWith('idea_')) return;
|
|
920
|
+
if (!(await readMark())) return;
|
|
921
|
+
const kind = classifyIdeaFailure(event.error);
|
|
922
|
+
if (kind === 'none') return;
|
|
923
|
+
if (kind === 'project' && ideApp) {
|
|
924
|
+
// The IDE is up; the project simply is not open in it. Ask the IDE
|
|
925
|
+
// to open the project again, and tell the model to retry.
|
|
926
|
+
await openInIde(projectPath, { app: ideApp });
|
|
927
|
+
rewriteToolError(event, '该项目已在 IDEA 中重新打开,请重试刚才的操作。');
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const result = await runOpenInIdea();
|
|
931
|
+
if (result.port === undefined) return;
|
|
932
|
+
rewriteToolError(event, 'IDE MCP 已重新连接,请重试刚才的操作。');
|
|
933
|
+
});
|
|
934
|
+
}
|
|
587
935
|
}
|
|
588
936
|
|
|
589
937
|
return async () => {
|
|
590
938
|
disposed = true;
|
|
939
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
591
940
|
if (shellRegistration) {
|
|
592
941
|
try {
|
|
593
942
|
await shellRegistration.dispose();
|
|
@@ -602,6 +951,20 @@ export default {
|
|
|
602
951
|
// best effort
|
|
603
952
|
}
|
|
604
953
|
}
|
|
954
|
+
if (toolRegistration) {
|
|
955
|
+
try {
|
|
956
|
+
await toolRegistration.dispose();
|
|
957
|
+
} catch {
|
|
958
|
+
// best effort
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (promptRegistration) {
|
|
962
|
+
try {
|
|
963
|
+
await promptRegistration.dispose();
|
|
964
|
+
} catch {
|
|
965
|
+
// best effort
|
|
966
|
+
}
|
|
967
|
+
}
|
|
605
968
|
if (commandRegistration) {
|
|
606
969
|
try {
|
|
607
970
|
await commandRegistration.dispose();
|
package/src/project.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
// Pure helpers for locating the current OpenCode project.
|
|
2
2
|
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
3
|
/**
|
|
7
4
|
* Resolve the absolute project root for the current plugin location.
|
|
8
5
|
* OpenCode exposes a `Location.Info` on the plugin context.
|
|
@@ -15,15 +12,3 @@ export function currentProjectPath(location) {
|
|
|
15
12
|
const projectDirectory = location.project && location.project.directory;
|
|
16
13
|
return projectDirectory || location.directory || undefined;
|
|
17
14
|
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* True when the directory looks like a JetBrains project (has a `.idea` folder).
|
|
21
|
-
* This is a convenience signal only; the plugin does not require it, because an
|
|
22
|
-
* IDE may have created the project entry without writing `.idea` yet.
|
|
23
|
-
*
|
|
24
|
-
* @param {string | undefined} directory
|
|
25
|
-
* @returns {boolean}
|
|
26
|
-
*/
|
|
27
|
-
export function hasIdeaDirectory(directory) {
|
|
28
|
-
return typeof directory === 'string' && existsSync(path.join(directory, '.idea'));
|
|
29
|
-
}
|