opencode-idea 0.0.14 → 0.0.16
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 +68 -82
- package/package.json +1 -1
- package/src/ide-launcher.js +24 -59
- package/src/mcp/idea.js +34 -58
- package/src/plugin.js +130 -201
package/README.md
CHANGED
|
@@ -12,10 +12,14 @@ OpenCode 插件,面向 JetBrains 项目 (IntelliJ IDEA / PyCharm / WebStorm 等)
|
|
|
12
12
|
> (含 fnm / SDKMAN 这类靠 shell init 切版本的场景)。详见「项目命令执行」。
|
|
13
13
|
|
|
14
14
|
非 JetBrains 项目完全不受影响。 **手动接入**:插件启动不探测、不连接 IDE,只有跑 `/open-in-idea` 才接入。跑过一次后,该**项目**
|
|
15
|
-
被「标记」(持久化在 `ctx.storage`,重启 OpenCode 后仍在)
|
|
16
|
-
|
|
15
|
+
被「标记」(持久化在 `ctx.storage`,重启 OpenCode 后仍在)。之后**没有任何后台轮询**——恢复完全靠请求驱动:插件发现"该有 `idea_*`
|
|
16
|
+
工具却没有"(新实例)或"`idea_*` 调用失败"(掉线)时,**在下一条消息发出前静默接回**(用户无感)。
|
|
17
17
|
`/close-in-idea` 注销 IDE MCP + 删除标记。没跑过 `/open-in-idea` 的项目保持全手动、零自主动作。
|
|
18
18
|
|
|
19
|
+
> **为什么改成请求驱动**:OpenCode 把一个 Location(项目)的全部服务缓存在 `LayerMap` 里,空闲 **60 分钟**
|
|
20
|
+
> (`idleTimeToLive`)后整体驱逐——插件实例连同 `idea` MCP 注册一起消失。这一点两种传输都躲不过,所以与其后台探活
|
|
21
|
+
> (永远晚一步、还刷 IDE 日志),不如在请求前发现并把连接接回来。
|
|
22
|
+
|
|
19
23
|
## 工作原理
|
|
20
24
|
|
|
21
25
|
```text
|
|
@@ -30,9 +34,8 @@ setup(启动, 每项目一次):
|
|
|
30
34
|
- 已就绪 → 再问 IDE「当前项目是否已打开」(`get_project_modules`,带项目路径)
|
|
31
35
|
· 已打开 → 不重复 open;未打开 → open -a 打开
|
|
32
36
|
- 未就绪(冷启动)→ open -a 拉起,再轮询等到就绪(上限 60s)
|
|
33
|
-
4. **先注销旧的 idea MCP(
|
|
34
|
-
|
|
35
|
-
—— 每次执行都重建,因为断掉的桥不会自愈、也不能靠 refresh 复活
|
|
37
|
+
4. **先注销旧的 idea MCP,再重新注册**(`type: "remote"` 指向 `http://127.0.0.1:<port>/stream`,项目路径走请求头)
|
|
38
|
+
—— 每次执行都重建:OpenCode 的 reconcile 会跳过配置未变的 re-`set`,不先拆就换不掉一个已经死掉的注册
|
|
36
39
|
5. 等 `idea_*` 工具进入注册表;读 IDE 终端环境更新 shell 环境;启用引导
|
|
37
40
|
6. 发结果通知(用户看到干净气泡,模型只回"收到")
|
|
38
41
|
|
|
@@ -47,22 +50,23 @@ setup(启动, 每项目一次):
|
|
|
47
50
|
- 普通业务错误(File not found 等)→ 不动作
|
|
48
51
|
2. 触发成功 → 把该错误改写为"已重连 / 已重新打开,请重试",模型同一回合重试;失败 → 保持原错误
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
1.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
3.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
—— 全程静默,用户无感
|
|
59
|
-
※ 这是**每个 Location(项目)各跑一份**探活;IDE 只有一个,所以所有项目的桥几乎同时被注销、再各自重建
|
|
53
|
+
请求驱动的恢复(没有后台轮询;每个 Location(项目)各一份插件实例):
|
|
54
|
+
1. `prompt` 钩子(消息进入时,OpenCode 会 await 它)——「已标记 + 本实例没有任何 `idea` 注册」→
|
|
55
|
+
探测端口,**只注册、绝不 `open -a`**;命中就重建,于是**这条消息本身**就带上了 `idea_*` 工具
|
|
56
|
+
2. `context` 钩子——某条请求"曾经有 `idea_*`、现在没有" → 立刻锁存并在后台重建(不等下一条消息)
|
|
57
|
+
3. `execute.after` 钩子——`idea_*` 调用以"不可用"告终(`MCP server "idea" is not available` /
|
|
58
|
+
`Error POSTing to endpoint`)→ 重跑接入流程,并把错误改写成"已重连,请重试";
|
|
59
|
+
普通业务错误(文件不存在等)不动作
|
|
60
|
+
※ 三者都只在**确实有事发生**时动;没人用这个项目时,插件完全静止
|
|
60
61
|
```
|
|
61
62
|
|
|
62
63
|
## 在 IDE 中打开项目 (`/open-in-idea`)
|
|
63
64
|
|
|
64
65
|
在会话里输入 `/open-in-idea`:
|
|
65
66
|
|
|
67
|
+
> **非 macOS**:第 1、3 步的自动打开被跳过(没有对应的启动命令),请**先手动打开 IDE 和当前项目**,再执行本命令 ——
|
|
68
|
+
> 端口在监听就会正常接入,并同样等到 `idea_*` 工具就绪才返回。
|
|
69
|
+
|
|
66
70
|
1. **让运行中的 IDEA 实例打开/激活当前项目**:`open -a "IntelliJ IDEA" <项目目录>` —— **复用已运行的实例**(不会重复开新实例),
|
|
67
71
|
项目已打开时只把它切到前台。**无论 MCP 是否已就绪都会执行这一步**:端点能应答只说明 IDEA 在运行(可能开的是**别的**项目),
|
|
68
72
|
不代表当前项目已打开;
|
|
@@ -70,7 +74,7 @@ setup(启动, 每项目一次):
|
|
|
70
74
|
3. 未就绪 → 说明 IDEA 是冷启动,继续用 `open -a` 拉起并**轮询等到 MCP 就绪**(冷启动约数十秒,上限 60s);
|
|
71
75
|
4. 等不到(例如 IDE 里 MCP 服务未开启)→ 提示在 IDEA 的 `Settings → MCP Server` 开启 MCP 服务并启用 **Brave Mode**,用户设置好后
|
|
72
76
|
再次执行 `/open-in-idea`;
|
|
73
|
-
5. 就绪后 **先注销旧的 `idea` MCP
|
|
77
|
+
5. 就绪后 **先注销旧的 `idea` MCP → 再重新注册**(每次执行都重建:配置未变的 re-`set` 会被 reconcile 跳过),然后重读
|
|
74
78
|
IDE 环境、启用系统引导,并**等到 `idea_*` 工具进入注册表**才返回。
|
|
75
79
|
|
|
76
80
|
> **通知不触发 AI 干活**:默认 `feedback: "message"` 把结果作为一条 **标注清楚的通知**发到会话里 —— 用户看到干净的通知气泡
|
|
@@ -171,36 +175,22 @@ IDE MCP 只能 **查**(`get_run_configurations`)和 **执行**(`execute_run_conf
|
|
|
171
175
|
- JetBrains IDE 已开启 MCP 服务 (IDE 设置里的 MCP Server / AI Assistant MCP),版本需 **2026.2+**(提供 Streamable HTTP 端点
|
|
172
176
|
`/stream`);若关闭,`/open-in-idea` 会立即提示如何重新开启。
|
|
173
177
|
- 要读 IDE 终端环境,需要在 IDE 里开启 **Brave Mode**(否则 `execute_terminal_command` 会等确认);MCP 未就绪时通知会同时提醒开启它。
|
|
174
|
-
- 插件注册给 OpenCode 的 MCP
|
|
178
|
+
- 插件注册给 OpenCode 的 MCP 配置(**只支持 Streamable HTTP**):
|
|
175
179
|
|
|
176
180
|
```jsonc
|
|
177
181
|
{
|
|
178
|
-
"type": "
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
"
|
|
182
|
-
"environment": {
|
|
183
|
-
"IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>",
|
|
184
|
-
"IJ_MCP_SERVER_PORT": "64342",
|
|
185
|
-
},
|
|
182
|
+
"type": "remote",
|
|
183
|
+
"url": "http://127.0.0.1:64342/stream",
|
|
184
|
+
"headers": { "IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>" },
|
|
185
|
+
"codemode": false,
|
|
186
186
|
}
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
**客户端侧是持久管道,不存在 HTTP 空闲断流**——这正是默认选它的原因。每个项目一个插件实例、各带自己的
|
|
191
|
-
`IJ_MCP_SERVER_PROJECT_PATH`,所以每个项目会起一个独立的 stdio 客户端进程。
|
|
189
|
+
项目通过 `IJ_MCP_SERVER_PROJECT_PATH` 头选择,所以一个 IDE 能同时服务多个项目,而**不需要为每个项目起一个子进程**。
|
|
192
190
|
|
|
193
|
-
>
|
|
194
|
-
>
|
|
195
|
-
>
|
|
196
|
-
>
|
|
197
|
-
> ```jsonc
|
|
198
|
-
> {
|
|
199
|
-
> "type": "remote",
|
|
200
|
-
> "url": "http://127.0.0.1:64342/stream",
|
|
201
|
-
> "headers": { "IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>" },
|
|
202
|
-
> }
|
|
203
|
-
> ```
|
|
191
|
+
> **为什么不用 IDE 的 stdio 桥**(`idea stdioMcpServer`,子进程管道):它的故障是"沉默"的——IDE 连接断掉后子进程只挂起、
|
|
192
|
+
> 不回任何错误,只能靠后台心跳兜;而 Location 空闲 60 分钟被驱逐时,桥进程与注册一起消失——**两种传输都躲不过**。
|
|
193
|
+
> HTTP 至少把"端点没了"变成一次普通的调用错误,插件能分类并自愈。OpenCode 2.0.19+ 也已无 SSE 客户端传输,当初选 stdio 的理由不再成立。
|
|
204
194
|
>
|
|
205
195
|
> **为什么不用 `/sse`**:OpenCode V2 的远程 MCP 客户端**只支持 Streamable HTTP**,没有旧版 SSE 传输。IDE 的 `/sse` 是旧版
|
|
206
196
|
> SSE (POST 返回 405),V2 连不上。插件对 IDE 的**探测**仍走 `/stream`(一次 `initialize`),用来确认 IDE MCP 已就绪并拿到端口。
|
|
@@ -267,14 +257,10 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
267
257
|
"ports": [64342], // IDE MCP 端口;默认 [64342]
|
|
268
258
|
"injectGuidance": true, // IDE 可用时向系统提示注入「优先使用 IDEA MCP」引导
|
|
269
259
|
"openInIde": "idea", // false 关闭;或任意 macOS 应用名(如 "IntelliJ IDEA")
|
|
270
|
-
"transport": "stdio", // "stdio"(默认,本地桥,不会空闲断流)| "http"(远程 Streamable HTTP)
|
|
271
|
-
"ideExecutable": undefined, // 可显式指定 IDE 可执行文件;默认从 .app 里推断
|
|
272
260
|
"launchCooldownMs": 120000, // 启动保护冷却
|
|
273
261
|
"launchMaxAttempts": 5,
|
|
274
262
|
"mcpProbeTimeoutMs": 1000, // 单个 MCP 端口的短探测超时
|
|
275
263
|
"executionTimeoutMs": 600000, // 单次 idea_* 工具调用硬上限;默认 10 分钟(OpenCode 默认 12 小时)
|
|
276
|
-
"heartbeatIntervalMs": 5000, // 后台端口探活间隔
|
|
277
|
-
"heartbeatFailures": 1, // 探活失败几次后注销 idea MCP(默认一次即注销)
|
|
278
264
|
"feedback": "message", // "message"(默认,会话通知,AI 只回"收到")| false(静默)
|
|
279
265
|
},
|
|
280
266
|
},
|
|
@@ -284,20 +270,16 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
284
270
|
|
|
285
271
|
不传 options 时全部使用下表默认值。
|
|
286
272
|
|
|
287
|
-
| 选项
|
|
288
|
-
|
|
|
289
|
-
| `ports`
|
|
290
|
-
| `injectGuidance`
|
|
291
|
-
| `openInIde`
|
|
292
|
-
| `
|
|
293
|
-
| `
|
|
294
|
-
| `
|
|
295
|
-
| `
|
|
296
|
-
| `
|
|
297
|
-
| `executionTimeoutMs` | `number` | `600000` | 单次 `idea_*` 工具调用的硬上限(毫秒);防止桥挂起后无限等待(OpenCode 默认 12 小时) |
|
|
298
|
-
| `heartbeatIntervalMs` | `number` | `5000` | 后台 IDE 端口探活间隔(毫秒);仅"已标记 + 桥已注册"的项目有效 |
|
|
299
|
-
| `heartbeatFailures` | `number` | `1` | 探活失败几次后注销 `idea` MCP(杀僵尸桥 + 移除工具),下一条消息自动重建 |
|
|
300
|
-
| `feedback` | `"message" \| false` | `"message"` | 结果反馈:`message` 会话通知(用户可见,AI 只回"收到");`false` 静默 |
|
|
273
|
+
| 选项 | 类型 | 默认 | 说明 |
|
|
274
|
+
| -------------------- | -------------------- | ----------- | ---------------------------------------------------------------------------------- |
|
|
275
|
+
| `ports` | `number[]` | `[64342]` | 依次探测的 IDE MCP 端口 |
|
|
276
|
+
| `injectGuidance` | `boolean` | `true` | IDE 可用时是否向系统提示注入「优先使用 IDEA MCP」引导 |
|
|
277
|
+
| `openInIde` | `boolean \| string` | `"idea"` | 拉起哪个 IDE;`false` 关闭,`"idea"` 映射到 IntelliJ IDEA |
|
|
278
|
+
| `launchCooldownMs` | `number` | `120000` | 启动保护冷却:冷却期内不重复 spawn |
|
|
279
|
+
| `launchMaxAttempts` | `number` | `5` | 单次会话内最多 spawn 次数,超出后停止重试 |
|
|
280
|
+
| `mcpProbeTimeoutMs` | `number` | `1000` | 单个 MCP 端口的短探测超时 |
|
|
281
|
+
| `executionTimeoutMs` | `number` | `600000` | 单次 `idea_*` 工具调用的硬上限(毫秒);防止端点卡住后无限等待(OpenCode 默认 12 小时) |
|
|
282
|
+
| `feedback` | `"message" \| false` | `"message"` | 结果反馈:`message` 会话通知(用户可见,AI 只回"收到");`false` 静默 |
|
|
301
283
|
|
|
302
284
|
## 行为
|
|
303
285
|
|
|
@@ -305,27 +287,28 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
305
287
|
`/open-in-idea` 注册。
|
|
306
288
|
- **未标记的项目**:不改环境、不注册、不注入任何 IDEA 提示 (每请求零开销;setup 时只做一次项目标记读取)。
|
|
307
289
|
- **手动接入 + 就绪即返回**:`/open-in-idea` 会 `open -a` 拉起 IDEA 并**等 IDE MCP 就绪**(冷启动数十秒,上限 60s);就绪后
|
|
308
|
-
**先注销旧的 `idea`
|
|
309
|
-
|
|
290
|
+
**先注销旧的 `idea` → 再重新注册**(远程 Streamable HTTP),等 `idea_*` 工具就绪,最后才通知。**每次执行都重建**。命令返回即可用;
|
|
291
|
+
`/close-in-idea` 注销 `idea` + 删标记 + 清环境。
|
|
310
292
|
- **`context` 钩子是请求的纯函数**:按「每次模型调用」触发 (含工具续跑),只看本次请求自带的工具快照 —— 有 `idea_*` 就剥离隐藏
|
|
311
293
|
工具并注入引导;没有则只在项目被标记时给恢复提示。该钩子本身**不做任何 I/O**(只看内存缓存),也不订阅任何事件。
|
|
312
|
-
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
294
|
+
- **请求驱动的恢复(没有后台轮询)**:
|
|
295
|
+
- **`prompt` 钩子**:「已标记 + 本实例没有任何 `idea` 注册」时**探测端口并重新注册**(只探测、绝不 `open -a`)。OpenCode 会
|
|
296
|
+
await `prompt` 才构建请求,所以**触发它的那条消息本身**就带上了 `idea_*` 工具,没有空窗。这正是 Location 被空闲驱逐
|
|
297
|
+
(60 分钟 `idleTimeToLive`)或 OpenCode 重启后,新实例自动接回连接的路径。
|
|
298
|
+
- **`context` 钩子**:发现「本会话见过 `idea_*` 工具、本请求却没有」→ 立刻锁存并在后台重建(不等下一条消息)。工具缺失时注入的
|
|
299
|
+
恢复引导会**明确禁止**调用 `idea_*`(此前复用"重试一次"的文案,反而诱导模型去调一个不在工具表里的工具,得到
|
|
300
|
+
`No tool named ... available`)。
|
|
301
|
+
- **`execute.after` 钩子**:见下一条。
|
|
302
|
+
恢复是**每个 Location(项目)各一份**;没有任何定时器,插件在不使用时完全静止。
|
|
320
303
|
- **失败触发的自动接入(仅被标记项目)**:`tool.execute.after` 钩子监听 `idea_*` 工具报错并**分类**:
|
|
321
304
|
- IDE MCP 不可用(掉了)→ 重跑 `/open-in-idea` 的接入流程;
|
|
322
305
|
- IDE 在、但项目没打开(`Unable to determine the target project` / `doesn't correspond to any open project`)→ 让 IDEA 重新打开该项目;
|
|
323
306
|
- 普通业务错误(`File not found` 等)→ **不动作**。判定只认**传输层专有标记**(`MCP server "idea" is not available` /
|
|
324
|
-
`MCP server is not connected` / `Error POSTing to endpoint`
|
|
307
|
+
`MCP server is not connected` / `Error POSTing to endpoint`),**不认**工具输出里出现的通用串
|
|
325
308
|
(例如 SQL 工具报的 `Connection refused`) —— MCP 工具错误的 message 就是工具自己的输出,认通用串会把正常业务失败误判成掉线并重建。
|
|
326
309
|
触发成功会把该工具错误改写为"已重连 / 已重新打开,请重试",模型在同一回合内重试;失败则保持原错误。非 `idea_*` 工具、未标记
|
|
327
310
|
项目都不受影响。启动保护 (`launchCooldownMs` / `launchMaxAttempts`) 限制重复拉起 IDE。
|
|
328
|
-
- **`/open-in-idea`**:手动打开当前项目;**等 IDE MCP 就绪 → 先注销旧的 `idea`
|
|
311
|
+
- **`/open-in-idea`**:手动打开当前项目;**等 IDE MCP 就绪 → 先注销旧的 `idea` → 再重新注册 → 等 `idea_*` 工具就绪**才返回。
|
|
329
312
|
**`/close-in-idea`**:注销 IDE MCP + 删项目标记 + 清空注入的环境。
|
|
330
313
|
|
|
331
314
|
## 限制
|
|
@@ -345,24 +328,27 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
345
328
|
(`get_project_modules`,带 `IJ_MCP_SERVER_PROJECT_PATH`):返回模块 = 已打开 → 跳过 `open -a`;报
|
|
346
329
|
`Unable to determine the target project` = 未打开 → `open -a` 打开。MCP 关闭时会用一次短探测并立即给出设置提示。
|
|
347
330
|
项目刚打开时的短暂"项目未就绪"由工具失败分类兜底(「IDE 在、但项目没打开」→ 重新打开并让模型重试)。
|
|
348
|
-
- IDE
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
-
|
|
358
|
-
- 插件给 `idea` server 配 `executionTimeoutMs`(默认 **10 分钟**)
|
|
359
|
-
- "IDE 被关掉
|
|
331
|
+
- **项目路径按 IDE 的写法归一化**:IDE 收的是系统无关形式(正斜杠),其自带配置示例在 Windows 上也是
|
|
332
|
+
`"E:/gitea/secondev-demo"`。OpenCode 的 `location` 在 Windows 上是原生反斜杠,所以 `src/mcp/idea.js` 的
|
|
333
|
+
`ideProjectPath()` 把它转成正斜杠,**注册与探测走同一个函数**,两者必须一致,否则会用一种写法注册、又用另一种
|
|
334
|
+
写法去问,永远匹配不上。macOS 上是原样透传。
|
|
335
|
+
- IDE **2026.2+** 提供 Streamable HTTP 端点 `/stream`,这是插件**唯一**使用的传输(`type: "remote"`)。注册本身不启动任何子进程,
|
|
336
|
+
所以没有"桥进程被 IDE 拖死后变成僵尸"这一类问题。`/open-in-idea` 仍要求 IDE MCP 已在监听(冷启动约数十秒),因此会先等就绪再注册。
|
|
337
|
+
- **空闲驱逐(已实测)**:OpenCode 把一个 Location 的服务缓存在 `LayerMap` 里,`idleTimeToLive` = **60 分钟**;空闲到期即整体驱逐,
|
|
338
|
+
插件实例连同 `idea` MCP 注册一起消失。**两种传输都躲不过**,所以对策不是换传输,而是请求驱动的恢复(见「行为」):
|
|
339
|
+
- 新实例上第一条用户消息的 `prompt` 钩子会把连接接回来 —— 常态下**不需要用户手动做什么**;
|
|
340
|
+
- 手动重建仍可用:执行 `/open-in-idea`(先注销旧 `idea`、再重新注册);
|
|
341
|
+
- 插件给 `idea` server 配 `executionTimeoutMs`(默认 **10 分钟**)作为硬上限:兜住端点卡住时的单次调用,不再沿用 OpenCode 默认的 12 小时;
|
|
342
|
+
- "IDE 被关掉"这一类会被下一次 `idea_*` 调用暴露(HTTP 直接报错),由 `execute.after` 钩子分类后自愈;不产生 error 的场景(例如
|
|
343
|
+
IDE 进程还在但项目没打开)由「IDE 在、项目没打开」分支处理。
|
|
360
344
|
- 项目标记存在插件名下 KV (`plugin:opencode-idea:project/<项目根路径>`)。插件实例按 **Location**(项目/打开目录)创建,标记按
|
|
361
345
|
**项目根路径**共享;但两个不同 Location(例如项目根与某子目录)各有自己的内存缓存 —— 单目录打开项目的常见用法不受影响。
|
|
362
346
|
- `/close-in-idea` 会**注销 IDE MCP** 并删标记 + 清空注入的环境。
|
|
363
347
|
- 插件**完全不再检查 `.idea` 目录**:是否是 IDEA 项目只由项目标记(`/open-in-idea` 设置、`/close-in-idea` 清除)决定。
|
|
364
348
|
(`/open-in-idea` 会让 IDEA 打开并导入该项目,`.idea` 由 IDEA 自己生成,插件无需也不应据此判断。)
|
|
365
|
-
-
|
|
349
|
+
- **自动拉起 IDE 仅实现 macOS** (`open -a`)。其他平台(包括 Windows)没有对应的启动命令,**请手动打开 IDE 和项目**,再执行
|
|
350
|
+
`/open-in-idea` —— 端口在监听就会正常接入,其余能力(注册、探测、自愈、工具暴露)与 macOS 完全一致。
|
|
351
|
+
IDE 里打开 MCP 服务(2026.2+)并启用 Brave Mode;首次打开若弹 Trust 对话框,需确认后重试。
|
|
366
352
|
- IDE MCP 没有「打开项目」工具,因此打开动作走 OS/CLI,而非 MCP。
|
|
367
353
|
- 插件热重载会断开 IDE 连接,需重跑 `/open-in-idea`。
|
|
368
354
|
- 插件不修改任何工具的定义、描述或可用性;IDE MCP 工具以 `idea_*` 直接暴露给模型 (`codemode: false`),不用 `execute` 包裹。
|
package/package.json
CHANGED
package/src/ide-launcher.js
CHANGED
|
@@ -12,12 +12,27 @@
|
|
|
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';
|
|
17
15
|
|
|
18
16
|
/** Default macOS app name for IntelliJ IDEA. */
|
|
19
17
|
export const DEFAULT_IDE_APP = 'IntelliJ IDEA';
|
|
20
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Can this platform launch the IDE at all?
|
|
21
|
+
*
|
|
22
|
+
* Launching goes through macOS `open -a`, which is the only launcher this
|
|
23
|
+
* plugin implements. On Windows there is no `open` command, so a spawn there
|
|
24
|
+
* fails with ENOENT — silently, as a rejected promise that resolves `false`.
|
|
25
|
+
* Rather than pretend, other platforms resolve no app at all and the caller
|
|
26
|
+
* asks the user to open the IDE by hand.
|
|
27
|
+
*
|
|
28
|
+
* A function, not a constant: the answer is read when a command runs, so tests
|
|
29
|
+
* can simulate another platform by replacing it. A constant would be bound at
|
|
30
|
+
* import time and never re-read.
|
|
31
|
+
*/
|
|
32
|
+
export function autoLaunchSupported() {
|
|
33
|
+
return process.platform === 'darwin';
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
/** Aliases accepted by the `openInIde` option. */
|
|
22
37
|
export const IDE_APPS = {
|
|
23
38
|
idea: 'IntelliJ IDEA',
|
|
@@ -28,71 +43,21 @@ export const IDE_APPS = {
|
|
|
28
43
|
/**
|
|
29
44
|
* Resolve the `openInIde` option to a macOS app name, or `undefined` to disable.
|
|
30
45
|
*
|
|
46
|
+
* `undefined` covers two distinct reasons, and the caller tells them apart via
|
|
47
|
+
* `autoLaunchSupported()`: the user turned launching off, or this platform has
|
|
48
|
+
* no launcher. They deserve different user-facing wording.
|
|
49
|
+
*
|
|
31
50
|
* @param {boolean | string | undefined} value
|
|
51
|
+
* @param {{ platform?: string }} [options]
|
|
32
52
|
* @returns {string | undefined}
|
|
33
53
|
*/
|
|
34
|
-
export function resolveIdeApp(value) {
|
|
54
|
+
export function resolveIdeApp(value, { platform = process.platform } = {}) {
|
|
55
|
+
if (platform !== 'darwin') return undefined;
|
|
35
56
|
if (value === false) return undefined;
|
|
36
57
|
if (typeof value !== 'string') return DEFAULT_IDE_APP;
|
|
37
58
|
return IDE_APPS[value.toLowerCase()] ?? value;
|
|
38
59
|
}
|
|
39
60
|
|
|
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
|
-
|
|
96
61
|
/**
|
|
97
62
|
* Open `directory` in the IDE, reusing a running instance when possible.
|
|
98
63
|
* Never throws; resolves `true` when `open` reported success.
|
package/src/mcp/idea.js
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
// HOW THE IDE MCP SERVER WORKS (verified against a real IntelliJ IDEA 2026.2
|
|
4
4
|
// server):
|
|
5
5
|
//
|
|
6
|
-
// * The IDE exposes a local MCP server
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
6
|
+
// * The IDE exposes a local MCP server at the Streamable-HTTP endpoint
|
|
7
|
+
// `http://127.0.0.1:<port>/stream` (IntelliJ 2026.2+). That is the only
|
|
8
|
+
// transport this plugin supports. The IDE's `stdioMcpServer` bridge was
|
|
9
|
+
// dropped: both transports die with the project's Location services, and the
|
|
10
|
+
// HTTP one at least reports a dead endpoint as an ordinary tool error
|
|
11
|
+
// (which the plugin then heals) instead of hanging silently.
|
|
12
|
+
// * The project a call targets is selected by the project path, carried in the
|
|
13
|
+
// HTTP header `IJ_MCP_SERVER_PROJECT_PATH` (see `serverConfig`).
|
|
13
14
|
// * Availability probe: a single `initialize` POST to `/stream` succeeds when
|
|
14
15
|
// the MCP server is up. We do NOT check whether a specific project is open —
|
|
15
16
|
// the user triggers `/open-in-idea` for the project they want, so "server is
|
|
@@ -17,14 +18,24 @@
|
|
|
17
18
|
|
|
18
19
|
export const PROJECT_HEADER = 'IJ_MCP_SERVER_PROJECT_PATH';
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The project path in the form the IDE matches against.
|
|
23
|
+
*
|
|
24
|
+
* The IDE takes the project selector in system-independent form (forward
|
|
25
|
+
* slashes) — its own MCP client configuration ships
|
|
26
|
+
* `IJ_MCP_SERVER_PROJECT_PATH: "E:/gitea/secondev-demo"`, even on Windows.
|
|
27
|
+
* OpenCode's `location` is the platform-native path, so on Windows it carries
|
|
28
|
+
* backslashes (`E:\gitea\secondev-demo`) that the IDE does not necessarily
|
|
29
|
+
* match. Normalizing here keeps one spelling on the wire for BOTH senders
|
|
30
|
+
* below; they must agree, or the plugin asks about the project with one path
|
|
31
|
+
* and registers it with another and never matches. On macOS this is a no-op.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} projectPath
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function ideProjectPath(projectPath) {
|
|
37
|
+
return projectPath.replace(/\\/g, '/');
|
|
38
|
+
}
|
|
28
39
|
|
|
29
40
|
/** OpenCode MCP server name registered for the IDE. */
|
|
30
41
|
export const IDEA_SERVER_NAME = 'idea';
|
|
@@ -59,41 +70,7 @@ export function serverConfig(port, projectPath, options = {}) {
|
|
|
59
70
|
const config = {
|
|
60
71
|
type: 'remote',
|
|
61
72
|
url: `http://127.0.0.1:${port}${MCP_STREAM_PATH}`,
|
|
62
|
-
headers: { [PROJECT_HEADER]: projectPath },
|
|
63
|
-
codemode: false,
|
|
64
|
-
};
|
|
65
|
-
return withExecutionTimeout(config, options);
|
|
66
|
-
}
|
|
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
|
-
},
|
|
73
|
+
headers: { [PROJECT_HEADER]: ideProjectPath(projectPath) },
|
|
97
74
|
codemode: false,
|
|
98
75
|
};
|
|
99
76
|
return withExecutionTimeout(config, options);
|
|
@@ -102,11 +79,10 @@ export function stdioServerConfig(executable, port, projectPath, options = {}) {
|
|
|
102
79
|
/**
|
|
103
80
|
* Attach OpenCode's per-server execution timeout to an MCP config.
|
|
104
81
|
*
|
|
105
|
-
* OpenCode defaults this to **12 hours**.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* an ordinary timeout error.
|
|
82
|
+
* OpenCode defaults this to **12 hours**. A wedged IDE MCP server can accept the
|
|
83
|
+
* POST and never answer it, so without a cap a call made after the IDE died
|
|
84
|
+
* would sit unresolved for the whole default. A finite cap turns that hang into
|
|
85
|
+
* an ordinary timeout error the plugin can classify and heal from.
|
|
110
86
|
*
|
|
111
87
|
* @param {Record<string, unknown>} config
|
|
112
88
|
* @param {{ executionTimeoutMs?: number }} options
|
|
@@ -146,7 +122,7 @@ export async function probePort(port, options = {}) {
|
|
|
146
122
|
params: {
|
|
147
123
|
protocolVersion: PROTOCOL_VERSION,
|
|
148
124
|
capabilities: {},
|
|
149
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
125
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.16' },
|
|
150
126
|
},
|
|
151
127
|
}),
|
|
152
128
|
signal: controller.signal,
|
|
@@ -203,7 +179,7 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
|
|
|
203
179
|
headers: {
|
|
204
180
|
'Content-Type': 'application/json',
|
|
205
181
|
Accept: 'application/json, text/event-stream',
|
|
206
|
-
[PROJECT_HEADER]: projectPath,
|
|
182
|
+
[PROJECT_HEADER]: ideProjectPath(projectPath),
|
|
207
183
|
...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}),
|
|
208
184
|
},
|
|
209
185
|
body: JSON.stringify(payload),
|
|
@@ -217,7 +193,7 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
|
|
|
217
193
|
params: {
|
|
218
194
|
protocolVersion: PROTOCOL_VERSION,
|
|
219
195
|
capabilities: {},
|
|
220
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
196
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.16' },
|
|
221
197
|
},
|
|
222
198
|
});
|
|
223
199
|
// The IDE's Streamable-HTTP endpoint binds follow-up requests to the session
|
package/src/plugin.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// opencode-idea — OpenCode plugin entry.
|
|
2
2
|
//
|
|
3
3
|
// For a project the user opted into IDEA this plugin:
|
|
4
|
-
// 1. registers the IDE MCP server with OpenCode
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// 1. registers the IDE MCP server with OpenCode as the IDE's Streamable-HTTP
|
|
5
|
+
// endpoint, scoped to the project through the `IJ_MCP_SERVER_PROJECT_PATH`
|
|
6
|
+
// header; injects direct-call guidance for IDEA MCP tools; and updates only
|
|
7
|
+
// IDEA MCP tool descriptions;
|
|
8
8
|
// 2. registers the `/open-in-idea` command, which opens the current project
|
|
9
9
|
// in IntelliJ IDEA (reusing a running instance), loads the IDE
|
|
10
10
|
// capabilities immediately, and marks the project as an IDEA project;
|
|
@@ -18,20 +18,27 @@
|
|
|
18
18
|
// IDE, and loads the IDE MCP + guidance on demand.
|
|
19
19
|
//
|
|
20
20
|
// `/open-in-idea` opens the project in the IDE, waits until its MCP endpoint is
|
|
21
|
-
// ready, registers `idea` (
|
|
21
|
+
// ready, registers `idea` (remote, project-scoped) and marks the project in
|
|
22
22
|
// `ctx.storage`; `/close-in-idea` unregisters the server and clears the mark.
|
|
23
23
|
// A live endpoint alone does NOT mean our project is open there (the running IDE
|
|
24
24
|
// may have a different project open), so the open flow asks the IDE directly —
|
|
25
25
|
// one project-scoped MCP call — and runs `open -a` only when the project is not
|
|
26
26
|
// open yet.
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
27
|
+
//
|
|
28
|
+
// Recovery is request-driven: there is NO background polling. The mark gates it,
|
|
29
|
+
// and it fires from three places, each observing a real fact:
|
|
30
|
+
// * the `prompt` hook, when the project is marked but this plugin instance has
|
|
31
|
+
// no `idea` server at all. That is exactly what a fresh instance looks like
|
|
32
|
+
// after OpenCode evicted the project's Location services (the Location's
|
|
33
|
+
// LayerMap uses `idleTimeToLive: "60 minutes"`) or after the process
|
|
34
|
+
// restarted — setup deliberately does not connect, so without this the
|
|
35
|
+
// session would stay tool-less until the user ran `/open-in-idea` by hand.
|
|
36
|
+
// `prompt` is awaited before the request is built, so the message that
|
|
37
|
+
// triggers it already carries the tools;
|
|
38
|
+
// * the `context` hook, when a request that HAD `idea_*` tools no longer does;
|
|
39
|
+
// * the `execute.after` hook, when an `idea_*` call fails in a way that means
|
|
40
|
+
// the IDE MCP is not usable (the server dropped, or the project is not open
|
|
41
|
+
// in the IDE). A plain business error is classified apart and left alone.
|
|
35
42
|
//
|
|
36
43
|
// `Plugin.define` from `@opencode/plugin` is an identity function, so a plain
|
|
37
44
|
// `{ id, setup }` object is the whole plugin contract. Exporting it directly
|
|
@@ -43,15 +50,13 @@ import {
|
|
|
43
50
|
callTool,
|
|
44
51
|
findIdePort,
|
|
45
52
|
serverConfig,
|
|
46
|
-
stdioServerConfig,
|
|
47
53
|
} from './mcp/idea.js';
|
|
48
54
|
import { currentProjectPath } from './project.js';
|
|
49
55
|
import {
|
|
56
|
+
autoLaunchSupported,
|
|
50
57
|
createLaunchGuard,
|
|
51
|
-
DEFAULT_IDE_APP,
|
|
52
58
|
openInIde,
|
|
53
59
|
resolveIdeApp,
|
|
54
|
-
resolveIdeExecutable,
|
|
55
60
|
} from './ide-launcher.js';
|
|
56
61
|
import {
|
|
57
62
|
appendIdeGuidance,
|
|
@@ -69,25 +74,15 @@ const DEFAULT_MCP_PROBE_TIMEOUT_MS = 1000;
|
|
|
69
74
|
const DEFAULT_MCP_START_TIMEOUT_MS = 60000;
|
|
70
75
|
const MCP_START_POLL_MS = 1000;
|
|
71
76
|
const DEFAULT_TOOL_REFRESH_TIMEOUT_MS = 5000;
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
//
|
|
77
|
+
// 等工具目录落地时的轮询间隔。这是一次**有界等待**(见 waitForIdeaTools),不是
|
|
78
|
+
// 状态轮询:100ms 足够快,又不会在 5s 上限内空转几十次。
|
|
79
|
+
const TOOL_REFRESH_POLL_MS = 100;
|
|
80
|
+
// Hard cap on ONE IDE MCP tool call. OpenCode's own default is 12 hours, and a
|
|
81
|
+
// wedged IDE MCP server can accept a POST and never answer it — so a call made
|
|
76
82
|
// after the IDE died would otherwise sit unresolved for 12h. 10 minutes is far
|
|
77
|
-
// beyond any normal IDE tool and turns that
|
|
83
|
+
// beyond any normal IDE tool and turns that hang into a normal error the failure
|
|
84
|
+
// classifier can act on.
|
|
78
85
|
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
|
-
// ONE miss is already conclusive: a healthy localhost endpoint answers in
|
|
82
|
-
// milliseconds, and a single probe already tolerates up to `mcpProbeTimeoutMs`
|
|
83
|
-
// (1s). Requiring consecutive misses would only widen the window in which a
|
|
84
|
-
// call already made against the zombie bridge keeps hanging. So on a miss the
|
|
85
|
-
// plugin deactivates the `idea` server — killing the zombie bridge child AND
|
|
86
|
-
// removing the `idea_*` tools, so the model can never call into a dead pipe —
|
|
87
|
-
// and latches a rebuild for the next user message (the bridge never heals on
|
|
88
|
-
// its own).
|
|
89
|
-
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
|
|
90
|
-
const DEFAULT_HEARTBEAT_FAILURES = 1;
|
|
91
86
|
// Every IDE tool comes from the MCP server the plugin registers as `idea`, and
|
|
92
87
|
// OpenCode namespaces a server's tools with its name — so `idea_*` IS the whole
|
|
93
88
|
// set, the same predicate the request hooks use. "Ready" means at least one of
|
|
@@ -207,7 +202,10 @@ function toolIds(tools) {
|
|
|
207
202
|
* - `project`: the IDE is up but the requested project is not open in it, e.g.
|
|
208
203
|
* "`projectPath`=... doesn't correspond to any open project." Re-opening the
|
|
209
204
|
* project fixes it.
|
|
210
|
-
* - `mcp`: the IDE MCP server itself is gone
|
|
205
|
+
* - `mcp`: the IDE MCP server itself is gone — the endpoint stopped answering,
|
|
206
|
+
* the transport closed, or the connection was refused. See the marker list in
|
|
207
|
+
* the body; it includes the connect failure OpenCode's own MCP client emits
|
|
208
|
+
* (`Unable to connect…`, and the structural `code: "ConnectionRefused"`).
|
|
211
209
|
*
|
|
212
210
|
* Everything else is `none`.
|
|
213
211
|
*
|
|
@@ -218,11 +216,10 @@ function classifyIdeaFailure(error) {
|
|
|
218
216
|
const message = typeof error?.message === 'string' ? error.message : '';
|
|
219
217
|
// "Project not open in the IDE" comes in two shapes, both meaning the same
|
|
220
218
|
// thing and both fixed by opening the project again:
|
|
221
|
-
// - the
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
// - an explicit `projectPath` was passed but is not open: "`projectPath`=...
|
|
219
|
+
// - the server could not pick a project for the call: "Unable to determine
|
|
220
|
+
// the target project for the current MCP tool call." (the project path in
|
|
221
|
+
// the request header did not resolve — the IDE may still be opening it)
|
|
222
|
+
// - the project was resolved but is not open: "`projectPath`=...
|
|
226
223
|
// doesn't correspond to any open project."
|
|
227
224
|
if (
|
|
228
225
|
message.includes('Unable to determine the target project') ||
|
|
@@ -230,20 +227,35 @@ function classifyIdeaFailure(error) {
|
|
|
230
227
|
) {
|
|
231
228
|
return 'project';
|
|
232
229
|
}
|
|
233
|
-
// Only transport-level markers, which OpenCode
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
230
|
+
// Only transport-level markers, which OpenCode's own MCP client emits. The
|
|
231
|
+
// message of an MCP *tool error* is the tool's own content (`tool/mcp.ts`
|
|
232
|
+
// turns `result.isError` into `ToolFailure({ message: <text> })`), so a
|
|
233
|
+
// generic string such as "Connection refused" from a SQL or run-config tool
|
|
234
|
+
// would otherwise be misread as a dropped connection and rebuild a healthy
|
|
235
|
+
// server.
|
|
236
|
+
//
|
|
237
|
+
// MEASURED (2026-09-30, OpenCode 2.0.19, IDE process killed): a dead remote
|
|
238
|
+
// MCP endpoint does NOT produce any of the shapes above. OpenCode's client
|
|
239
|
+
// reports its own connect failure instead:
|
|
240
|
+
//
|
|
241
|
+
// level=WARN message="mcp http request failed"
|
|
242
|
+
// errors="[{\"type\":\"TypeError\",
|
|
243
|
+
// \"message\":\"Unable to connect. Is the computer able to access the url?\",
|
|
244
|
+
// \"code\":\"ConnectionRefused\",\"errno\":0}]"
|
|
245
|
+
//
|
|
246
|
+
// so the text is matched on its stable prefix, and the underlying `code` is
|
|
247
|
+
// accepted as a structural signal too (a wording change must not silently
|
|
248
|
+
// disable the heal).
|
|
239
249
|
if (
|
|
240
250
|
message.includes(`MCP server "${IDEA_SERVER_NAME}" is not available`) ||
|
|
241
251
|
message.includes('MCP server is not connected') ||
|
|
242
252
|
message.includes('Error POSTing to endpoint') ||
|
|
243
|
-
message.includes('
|
|
253
|
+
message.includes('Unable to connect')
|
|
244
254
|
) {
|
|
245
255
|
return 'mcp';
|
|
246
256
|
}
|
|
257
|
+
const causeCode = error?.error?.code ?? error?.cause?.code ?? error?.code;
|
|
258
|
+
if (causeCode === 'ConnectionRefused' || causeCode === 'ECONNREFUSED') return 'mcp';
|
|
247
259
|
return 'none';
|
|
248
260
|
}
|
|
249
261
|
|
|
@@ -285,7 +297,17 @@ function rewriteToolError(event, message) {
|
|
|
285
297
|
|
|
286
298
|
/**
|
|
287
299
|
* @typedef {{
|
|
288
|
-
* status:
|
|
300
|
+
* status:
|
|
301
|
+
* | 'connected'
|
|
302
|
+
* | 'tools-loading'
|
|
303
|
+
* | 'mcp-unavailable'
|
|
304
|
+
* | 'launch-failed'
|
|
305
|
+
* | 'disabled'
|
|
306
|
+
* | 'manual-launch'
|
|
307
|
+
* | 'closed',
|
|
308
|
+
* // `manual-launch` = this platform has no launcher at all (the user must
|
|
309
|
+
* // open the IDE by hand); `disabled` = the user turned launching off. Both
|
|
310
|
+
* // mean "no app to open", so only `autoLaunchSupported()` tells them apart.
|
|
289
311
|
* port?: number,
|
|
290
312
|
* opened?: boolean,
|
|
291
313
|
* unavailableNoticeSent?: boolean
|
|
@@ -321,6 +343,8 @@ export function ideFeedback(result) {
|
|
|
321
343
|
return '无法拉起 IntelliJ IDEA(未找到应用或启动失败)。请确认已安装 IDEA,或手动打开后重试 /open-in-idea。';
|
|
322
344
|
case 'disabled':
|
|
323
345
|
return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
|
|
346
|
+
case 'manual-launch':
|
|
347
|
+
return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。';
|
|
324
348
|
case 'closed':
|
|
325
349
|
return '已关闭当前项目的 IDEA 接入:已注销 IDE MCP 并清除项目标记。如需重新接入,请再次执行 /open-in-idea。';
|
|
326
350
|
default:
|
|
@@ -360,10 +384,6 @@ export default {
|
|
|
360
384
|
* mcpStartTimeoutMs?: number,
|
|
361
385
|
* toolRefreshTimeoutMs?: number,
|
|
362
386
|
* executionTimeoutMs?: number,
|
|
363
|
-
* heartbeatIntervalMs?: number,
|
|
364
|
-
* heartbeatFailures?: number,
|
|
365
|
-
* transport?: 'stdio' | 'http',
|
|
366
|
-
* ideExecutable?: string,
|
|
367
387
|
* feedback?: false | 'message',
|
|
368
388
|
* },
|
|
369
389
|
* command?: {
|
|
@@ -423,28 +443,12 @@ export default {
|
|
|
423
443
|
const ports = asArray(options.ports, DEFAULT_PORTS);
|
|
424
444
|
const injectGuidance = options.injectGuidance !== false;
|
|
425
445
|
const ideApp = resolveIdeApp(options.openInIde);
|
|
426
|
-
// Transport for the IDE MCP server. `stdio` (default) registers the IDE's
|
|
427
|
-
// stdio bridge as a local server: the client side is a child-process pipe,
|
|
428
|
-
// which — unlike the remote Streamable-HTTP stream — does not get closed
|
|
429
|
-
// while idle and never reconnected. `http` keeps the older remote config.
|
|
430
|
-
const transport = options.transport === 'http' ? 'http' : 'stdio';
|
|
431
|
-
const ideExecutable = typeof options.ideExecutable === 'string'
|
|
432
|
-
? options.ideExecutable
|
|
433
|
-
// Resolve independently of `openInIde`: that option only controls whether
|
|
434
|
-
// the plugin may LAUNCH the IDE, not which transport the MCP server uses.
|
|
435
|
-
// Falling back to the default app when launching is disabled keeps stdio
|
|
436
|
-
// the default there too (otherwise `openInIde: false` silently registered
|
|
437
|
-
// the remote HTTP transport).
|
|
438
|
-
: resolveIdeExecutable(ideApp ?? DEFAULT_IDE_APP);
|
|
439
|
-
const useStdio = transport === 'stdio' && typeof ideExecutable === 'string';
|
|
440
446
|
const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
|
|
441
447
|
const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
|
|
442
448
|
const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
|
|
443
|
-
// Cap on a single IDE tool call, so a
|
|
444
|
-
//
|
|
449
|
+
// Cap on a single IDE tool call, so a wedged server fails in finite time
|
|
450
|
+
// instead of OpenCode's 12h default. See DEFAULT_EXECUTION_TIMEOUT_MS.
|
|
445
451
|
const executionTimeoutMs = asPositiveNumber(options.executionTimeoutMs, DEFAULT_EXECUTION_TIMEOUT_MS);
|
|
446
|
-
const heartbeatIntervalMs = asPositiveNumber(options.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
447
|
-
const heartbeatFailures = asPositiveNumber(options.heartbeatFailures, DEFAULT_HEARTBEAT_FAILURES);
|
|
448
452
|
// "message" (default) injects a clearly-labelled notification that the
|
|
449
453
|
// model only acknowledges ("收到"); the user sees the clean notice via
|
|
450
454
|
// metadata.displayText. false is silent.
|
|
@@ -469,15 +473,10 @@ export default {
|
|
|
469
473
|
let skillsRegistered = false;
|
|
470
474
|
let disposed = false;
|
|
471
475
|
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
// Latches when the IDE was seen down and the server was deactivated. It is
|
|
477
|
-
// cleared ONLY by a successful rebuild — never by the endpoint coming back —
|
|
478
|
-
// because the bridge does not heal on its own. It is set from TWO places:
|
|
479
|
-
// the heartbeat (the IDE port stopped answering) and the `context` hook (a
|
|
480
|
-
// request arrived without the `idea_*` tools).
|
|
476
|
+
// Latches when a request showed the `idea_*` tools are gone. It is cleared
|
|
477
|
+
// only by a successful rebuild. It is set from the `context` hook (a request
|
|
478
|
+
// that HAD the tools no longer does); the `prompt` hook also heals without
|
|
479
|
+
// this latch when the instance has no registration at all.
|
|
481
480
|
let needsRebuild = false;
|
|
482
481
|
// Set once a request in this session actually carried `idea_*` tools, so a
|
|
483
482
|
// later request without them is a DROP of this session's own connection —
|
|
@@ -565,27 +564,6 @@ export default {
|
|
|
565
564
|
return false;
|
|
566
565
|
};
|
|
567
566
|
|
|
568
|
-
/**
|
|
569
|
-
* Does the local registry still list any of this server's tools?
|
|
570
|
-
*
|
|
571
|
-
* The IDE endpoint answering says nothing about the bridge OpenCode actually
|
|
572
|
-
* calls: when that child process dies, OpenCode drops every `idea_*` tool
|
|
573
|
-
* while the IDE keeps serving. The port probe reports "fine" and the model
|
|
574
|
-
* gets a tool list without the IDE — so this near end needs its own check,
|
|
575
|
-
* and it is a purely local read.
|
|
576
|
-
*
|
|
577
|
-
* Unavailable or failed reads report `true`: inventing a miss would tear down
|
|
578
|
-
* a working bridge, which is worse than skipping one tick.
|
|
579
|
-
*/
|
|
580
|
-
const ideaToolsPresent = async () => {
|
|
581
|
-
if (typeof ctx.tool?.list !== 'function') return true;
|
|
582
|
-
try {
|
|
583
|
-
return toolIds(await ctx.tool.list()).some((id) => id.startsWith(IDEA_TOOL_PREFIX));
|
|
584
|
-
} catch {
|
|
585
|
-
return true;
|
|
586
|
-
}
|
|
587
|
-
};
|
|
588
|
-
|
|
589
567
|
const refreshIdeTools = async () => {
|
|
590
568
|
if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
|
|
591
569
|
// Reconcile the MCP servers above, then replay the tool registry so the
|
|
@@ -597,9 +575,7 @@ export default {
|
|
|
597
575
|
};
|
|
598
576
|
|
|
599
577
|
const activateIde = async (port) => {
|
|
600
|
-
const config =
|
|
601
|
-
? stdioServerConfig(ideExecutable, port, projectPath, { executionTimeoutMs })
|
|
602
|
-
: serverConfig(port, projectPath, { executionTimeoutMs });
|
|
578
|
+
const config = serverConfig(port, projectPath, { executionTimeoutMs });
|
|
603
579
|
const mcpRegistration = await ctx.mcp.transform((editor) => {
|
|
604
580
|
editor.set(IDEA_SERVER_NAME, config);
|
|
605
581
|
});
|
|
@@ -619,15 +595,11 @@ export default {
|
|
|
619
595
|
return false;
|
|
620
596
|
}
|
|
621
597
|
|
|
622
|
-
// Always tear the server down and register it again.
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
// the
|
|
627
|
-
// reconciled config, which is what actually kills the child process — so a
|
|
628
|
-
// full rebuild is the only reliable way back to a working bridge.
|
|
629
|
-
// `/open-in-idea` is manual and rare; rebuilding an already-healthy bridge
|
|
630
|
-
// costs one restart, which is the right trade for never leaving a zombie.
|
|
598
|
+
// Always tear the server down and register it again. OpenCode's reconcile
|
|
599
|
+
// SKIPS a re-`set` whose config is unchanged, so re-registering without a
|
|
600
|
+
// teardown would leave a dropped server in place forever. `/open-in-idea`
|
|
601
|
+
// and the request hooks are rare; rebuilding costs one restart, which is
|
|
602
|
+
// the right trade for never leaving a dead registration behind.
|
|
631
603
|
await deactivateIde();
|
|
632
604
|
if (port === undefined) return false;
|
|
633
605
|
return activateIde(port);
|
|
@@ -636,9 +608,7 @@ export default {
|
|
|
636
608
|
// Serialize reconciles so overlapping callers can never register/activate
|
|
637
609
|
// the IDE concurrently (which would leak registrations).
|
|
638
610
|
let reconcileChain = Promise.resolve();
|
|
639
|
-
// Non-zero while a reconcile is queued or running.
|
|
640
|
-
// registration down before it registers again, so the tools are briefly
|
|
641
|
-
// absent on purpose — the heartbeat must not read that as a loss.
|
|
611
|
+
// Non-zero while a reconcile is queued or running.
|
|
642
612
|
let reconcileBusy = 0;
|
|
643
613
|
const enqueue = (task) => {
|
|
644
614
|
reconcileBusy += 1;
|
|
@@ -735,7 +705,11 @@ export default {
|
|
|
735
705
|
}
|
|
736
706
|
return { status: 'connected', port: serving, unavailableNoticeSent: false };
|
|
737
707
|
}
|
|
738
|
-
|
|
708
|
+
// No app to open. Either the user turned launching off, or this platform
|
|
709
|
+
// has no launcher — say which, so the notice is actionable.
|
|
710
|
+
if (!ideApp) {
|
|
711
|
+
return { status: autoLaunchSupported() ? 'disabled' : 'manual-launch' };
|
|
712
|
+
}
|
|
739
713
|
if (guard.exhausted()) {
|
|
740
714
|
await reportUnavailable(false);
|
|
741
715
|
return { status: 'mcp-unavailable', opened: false, unavailableNoticeSent: launchNoticeSent };
|
|
@@ -824,7 +798,6 @@ export default {
|
|
|
824
798
|
}
|
|
825
799
|
if (result.port !== undefined) {
|
|
826
800
|
needsRebuild = false;
|
|
827
|
-
heartbeatMisses = 0;
|
|
828
801
|
}
|
|
829
802
|
} catch {
|
|
830
803
|
// best effort: the latch stays set, and the prompt hook retries
|
|
@@ -840,56 +813,6 @@ export default {
|
|
|
840
813
|
// Setup never probes or connects the IDE — everything is on `/open-in-idea`.
|
|
841
814
|
await reconcile({ probeIde: false });
|
|
842
815
|
|
|
843
|
-
/**
|
|
844
|
-
* One heartbeat tick. Only marked projects are watched, and it only acts
|
|
845
|
-
* when an `idea` server is actually registered: the point is to protect an
|
|
846
|
-
* ESTABLISHED connection. A project that never connected has no zombie to
|
|
847
|
-
* kill, and must not have IDEA launched on its behalf. The deactivation
|
|
848
|
-
* runs under the reconcile chain, so a tick cannot race `/open-in-idea`.
|
|
849
|
-
*/
|
|
850
|
-
let heartbeatBusy = false;
|
|
851
|
-
const heartbeatTick = async () => {
|
|
852
|
-
if (disposed || heartbeatBusy) return;
|
|
853
|
-
if (markCache !== true || registrations.length === 0) {
|
|
854
|
-
heartbeatMisses = 0;
|
|
855
|
-
return;
|
|
856
|
-
}
|
|
857
|
-
heartbeatBusy = true;
|
|
858
|
-
try {
|
|
859
|
-
const port = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
|
|
860
|
-
// Two independent failures to catch. The endpoint can stop answering (the
|
|
861
|
-
// IDE is gone), or it can keep answering while the bridge OpenCode calls
|
|
862
|
-
// has died — the second one is invisible to the probe AND to the model,
|
|
863
|
-
// which is why the local registry gets checked too.
|
|
864
|
-
if (port !== undefined && (reconcileBusy > 0 || (await ideaToolsPresent()))) {
|
|
865
|
-
heartbeatMisses = 0;
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
heartbeatMisses += 1;
|
|
869
|
-
if (heartbeatMisses < heartbeatFailures) return;
|
|
870
|
-
// Either end is down. Deactivating kills the zombie bridge child and
|
|
871
|
-
// removes the `idea_*` tools, so the model cannot call into a dead pipe.
|
|
872
|
-
const kicked = await enqueue(async () => {
|
|
873
|
-
if (disposed || registrations.length === 0) return false;
|
|
874
|
-
await deactivateIde();
|
|
875
|
-
return true;
|
|
876
|
-
});
|
|
877
|
-
if (kicked) needsRebuild = true;
|
|
878
|
-
// The IDE is still reachable, so reconnecting needs no launch — reconnect
|
|
879
|
-
// now instead of leaving the session tool-less until the next message. A
|
|
880
|
-
// dead endpoint still waits for one: that path may `open -a`, and the
|
|
881
|
-
// heartbeat must never start the IDE on its own.
|
|
882
|
-
if (needsRebuild && port !== undefined) await reviveIde();
|
|
883
|
-
} finally {
|
|
884
|
-
heartbeatBusy = false;
|
|
885
|
-
}
|
|
886
|
-
};
|
|
887
|
-
heartbeatTimer = setInterval(() => {
|
|
888
|
-
heartbeatTick().catch(() => {});
|
|
889
|
-
}, heartbeatIntervalMs);
|
|
890
|
-
// Never keep the host process alive just to probe (also keeps tests from hanging).
|
|
891
|
-
if (typeof heartbeatTimer?.unref === 'function') heartbeatTimer.unref();
|
|
892
|
-
|
|
893
816
|
const sendFeedback = async (input, result) => {
|
|
894
817
|
if (feedback !== 'message' || typeof ctx.session?.prompt !== 'function') return false;
|
|
895
818
|
try {
|
|
@@ -979,10 +902,10 @@ export default {
|
|
|
979
902
|
description: '取消当前项目的 IDEA 接入:注销 IDE MCP 并清除项目标记',
|
|
980
903
|
execute: async (input) => {
|
|
981
904
|
await setMark(false);
|
|
982
|
-
// Unregister the IDE MCP server
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
//
|
|
905
|
+
// Unregister the IDE MCP server. Reuses the serialized reconcile, so
|
|
906
|
+
// a concurrent rebuild (an in-flight `/open-in-idea`, or a
|
|
907
|
+
// request-time heal) cannot re-register the server after the mark is
|
|
908
|
+
// gone.
|
|
986
909
|
await reconcile({ probeIde: false });
|
|
987
910
|
await sendFeedback(input, { status: 'closed' });
|
|
988
911
|
},
|
|
@@ -1003,21 +926,17 @@ export default {
|
|
|
1003
926
|
if (typeof ctx.session?.hook === 'function') {
|
|
1004
927
|
sessionRegistration = await ctx.session.hook('context', (event) => {
|
|
1005
928
|
const tools = event?.tools;
|
|
1006
|
-
const serving = tools !== undefined && Object.keys(tools).some((name) => name.startsWith(
|
|
929
|
+
const serving = tools !== undefined && Object.keys(tools).some((name) => name.startsWith(IDEA_TOOL_PREFIX));
|
|
1007
930
|
if (serving) {
|
|
1008
931
|
wasServing = true;
|
|
1009
932
|
removeIdeaToolDefinitions(tools, HIDDEN_IDEA_TOOLS);
|
|
1010
933
|
} else if (wasServing && tools !== undefined && markCache === true) {
|
|
1011
|
-
// This session HAD the tools and this request does not.
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
// a
|
|
1017
|
-
// message would send the model a tool list without `idea_*` and
|
|
1018
|
-
// recover nothing. Latch it here instead, where "is the IDE serving
|
|
1019
|
-
// THIS request?" is a fact. `wasServing` keeps this from firing for a
|
|
1020
|
-
// project that never connected in this session — setup deliberately
|
|
934
|
+
// This session HAD the tools and this request does not. Latch a
|
|
935
|
+
// rebuild here, where "is the IDE serving THIS request?" is a fact: a
|
|
936
|
+
// failed reconnect can tear the registration down without anything else
|
|
937
|
+
// noticing. `wasServing` keeps this from firing for a project that
|
|
938
|
+
// never connected in this session — the `prompt` hook covers that case
|
|
939
|
+
// (a fresh instance has no registration at all) and setup deliberately
|
|
1021
940
|
// does not connect on its own.
|
|
1022
941
|
needsRebuild = true;
|
|
1023
942
|
wasServing = false;
|
|
@@ -1037,14 +956,23 @@ export default {
|
|
|
1037
956
|
appendIdeGuidance(event?.system, projectPath);
|
|
1038
957
|
});
|
|
1039
958
|
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
959
|
+
// 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
|
|
960
|
+
// **本次请求**里就带上 `idea_*` 工具,没有"先回一句正在重连、下一条才行"
|
|
961
|
+
// 的空窗。
|
|
962
|
+
//
|
|
963
|
+
// 两种"需要重连"的形状都从这里进:
|
|
964
|
+
// * `needsRebuild` — 之前的请求已经证明工具没了;
|
|
965
|
+
// * `registrations.length === 0` — 本插件实例压根没有 `idea` 服务器,这
|
|
966
|
+
// 正是 OpenCode 回收 Location 服务(LayerMap idleTimeToLive = 60 分钟)
|
|
967
|
+
// 或进程重启后新实例的样子。setup 故意不连接,没有这一步会话就会永久
|
|
968
|
+
// 没有工具,只能靠用户手敲 `/open-in-idea`。
|
|
969
|
+
//
|
|
970
|
+
// 只探测、不启动 IDE:走 `reconcile({ probeIde: true })`,而不是会 `open -a`
|
|
971
|
+
// 的 `reviveIde()` —— 一条用户消息不该把 IDEA 拉起来。
|
|
1046
972
|
promptRegistration = await ctx.session.hook('prompt', async (event) => {
|
|
1047
|
-
if (disposed ||
|
|
973
|
+
if (disposed || markCache !== true) return;
|
|
974
|
+
const fresh = registrations.length === 0;
|
|
975
|
+
if (!needsRebuild && !fresh) return;
|
|
1048
976
|
// Never react to the plugin's own notices, or recovery would feed on
|
|
1049
977
|
// itself. Match the notice TEXT, never `metadata.displayText`: OpenCode
|
|
1050
978
|
// also sets `displayText` on ordinary user messages (it is the message
|
|
@@ -1052,15 +980,12 @@ export default {
|
|
|
1052
980
|
// — verified live, and it made this rebuild never run.
|
|
1053
981
|
const text = event?.prompt?.text;
|
|
1054
982
|
if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
const joined = reviveInFlight !== undefined;
|
|
1062
|
-
await reviveIde();
|
|
1063
|
-
if (joined && needsRebuild) await reviveIde();
|
|
983
|
+
try {
|
|
984
|
+
await reconcile({ probeIde: true });
|
|
985
|
+
} catch {
|
|
986
|
+
// best effort: the latch stays set and the next message retries
|
|
987
|
+
}
|
|
988
|
+
if (registrations.length > 0) needsRebuild = false;
|
|
1064
989
|
});
|
|
1065
990
|
|
|
1066
991
|
// The only self-initiated action: when an `idea_*` call fails in a way
|
|
@@ -1075,9 +1000,14 @@ export default {
|
|
|
1075
1000
|
if (!(await readMark())) return;
|
|
1076
1001
|
const kind = classifyIdeaFailure(event.error);
|
|
1077
1002
|
if (kind === 'none') return;
|
|
1078
|
-
if (kind === 'project'
|
|
1079
|
-
// The IDE is up; the
|
|
1080
|
-
//
|
|
1003
|
+
if (kind === 'project') {
|
|
1004
|
+
// The IDE is up and answering; only the PROJECT is missing. That is
|
|
1005
|
+
// never a connection problem, so this must never fall through to
|
|
1006
|
+
// the reconnect below — on a platform with no launcher there is
|
|
1007
|
+
// nothing to call, and falling through would report "reconnected"
|
|
1008
|
+
// and send the model round after round after a call that can only
|
|
1009
|
+
// fail. Leave the IDE's own message, which names the real cause.
|
|
1010
|
+
if (!ideApp) return;
|
|
1081
1011
|
await openInIde(projectPath, { app: ideApp });
|
|
1082
1012
|
rewriteToolError(event, '该项目已在 IDEA 中重新打开,请重试刚才的操作。');
|
|
1083
1013
|
return;
|
|
@@ -1091,7 +1021,6 @@ export default {
|
|
|
1091
1021
|
|
|
1092
1022
|
return async () => {
|
|
1093
1023
|
disposed = true;
|
|
1094
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
1095
1024
|
if (sessionRegistration) {
|
|
1096
1025
|
try {
|
|
1097
1026
|
await sessionRegistration.dispose();
|