opencode-idea 0.0.13 → 0.0.15
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 +82 -79
- package/package.json +1 -1
- package/skills/idea-standalone/SKILL.md +93 -0
- package/src/ide-guidance.js +28 -10
- package/src/ide-launcher.js +0 -58
- package/src/mcp/idea.js +14 -57
- package/src/plugin.js +181 -143
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,14 +50,14 @@ setup(启动, 每项目一次):
|
|
|
47
50
|
- 普通业务错误(File not found 等)→ 不动作
|
|
48
51
|
2. 触发成功 → 把该错误改写为"已重连 / 已重新打开,请重试",模型同一回合重试;失败 → 保持原错误
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
1.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
3.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
※
|
|
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
|
+
※ 三者都只在**确实有事发生**时动;没人用这个项目时,插件完全静止
|
|
58
61
|
```
|
|
59
62
|
|
|
60
63
|
## 在 IDE 中打开项目 (`/open-in-idea`)
|
|
@@ -68,7 +71,7 @@ setup(启动, 每项目一次):
|
|
|
68
71
|
3. 未就绪 → 说明 IDEA 是冷启动,继续用 `open -a` 拉起并**轮询等到 MCP 就绪**(冷启动约数十秒,上限 60s);
|
|
69
72
|
4. 等不到(例如 IDE 里 MCP 服务未开启)→ 提示在 IDEA 的 `Settings → MCP Server` 开启 MCP 服务并启用 **Brave Mode**,用户设置好后
|
|
70
73
|
再次执行 `/open-in-idea`;
|
|
71
|
-
5. 就绪后 **先注销旧的 `idea` MCP
|
|
74
|
+
5. 就绪后 **先注销旧的 `idea` MCP → 再重新注册**(每次执行都重建:配置未变的 re-`set` 会被 reconcile 跳过),然后重读
|
|
72
75
|
IDE 环境、启用系统引导,并**等到 `idea_*` 工具进入注册表**才返回。
|
|
73
76
|
|
|
74
77
|
> **通知不触发 AI 干活**:默认 `feedback: "message"` 把结果作为一条 **标注清楚的通知**发到会话里 —— 用户看到干净的通知气泡
|
|
@@ -124,14 +127,33 @@ IDE MCP 只能 **查**(`get_run_configurations`)和 **执行**(`execute_run_conf
|
|
|
124
127
|
`setup` 时(读回标记)就会注册。未标记的项目不受影响。文档放在 `skills/<id>/SKILL.md`(每个 skill 一个目录,`name` /
|
|
125
128
|
`description` 走 YAML frontmatter),由 `src/skill/loader.js` 扫描加载 —— **加新 skill 只需加一个目录,不用改代码**。
|
|
126
129
|
|
|
130
|
+
## Standalone 模式纪律 (skill)
|
|
131
|
+
|
|
132
|
+
本机直跑应用的**项目级纪律**由 `idea-standalone` skill 固化:它为项目生成/维护一份 rule,落在项目根
|
|
133
|
+
**`.opencode/rules/idea-standalone.md`**,四段结构固定 ——
|
|
134
|
+
|
|
135
|
+
1. **什么是 standalone 模式**:**standalone = 通过 IDEA 启动**(每个应用的入口是 IDEA run configuration),应用在本机进程
|
|
136
|
+
直跑(不容器化、不部署),只启动其中一部分是常态;启停一律由用户在 IDEA 里手动完成,AI 不得代为启停(含经
|
|
137
|
+
`idea_execute_run_configuration`),判断需要重启时先取得用户明确授权;
|
|
138
|
+
2. **启动哪些应用**:一行一个应用,启动路径**只能是 IDEA run configuration**(配置名必须能在 `idea_get_run_configurations`
|
|
139
|
+
里查到;缺配置先走 `idea-run-config` skill 建 `.run/*.run.xml`),端口与被依赖的先后顺序一并写明;
|
|
140
|
+
3. **热重载矩阵**:逐应用标"是 / 否" —— 带 HMR 的 dev server 改动后**不用**提示重启;`go run .` 这类无热重载的进程改动后
|
|
141
|
+
**必须**显式点名「需要重启 <应用>」,不得在未提示的情况下宣称"改完可验证";
|
|
142
|
+
4. **项目补充条款**:文件写入位置、测试纪律、访问归属、dev 模式特有限制等,按用户给的条款逐条续写、保留原意。
|
|
143
|
+
|
|
144
|
+
事实一律先查后写:配置名来自 `idea_get_run_configurations`,热重载列来自运行方式,端口读不到就问用户 —— 不猜。
|
|
145
|
+
|
|
127
146
|
## 项目命令执行 (IDE 终端)
|
|
128
147
|
|
|
129
|
-
跑项目相关的命令时,模型使用 **`idea_execute_terminal_command`**,而不是 OpenCode 原生 shell
|
|
130
|
-
|
|
148
|
+
跑项目相关的命令时,模型使用 **`idea_execute_terminal_command`**,而不是 OpenCode 原生 shell。判据是**白名单**:命令用于本项目
|
|
149
|
+
自身的构建 / 测试 / 运行,且首个可执行文件命中下表,才走 IDE 终端;其余一律走原生 shell。
|
|
131
150
|
|
|
132
|
-
-
|
|
133
|
-
|
|
134
|
-
-
|
|
151
|
+
- **Node / 前端**(`package.json`):`node` / `npm` / `npx` / `pnpm` / `yarn` / `vite` / `webpack` / `esbuild` / `tsc` /
|
|
152
|
+
`jest` / `vitest`。
|
|
153
|
+
- **Java**(`pom.xml` / `build.gradle`):`java` / `javac` / `mvn` / `mvnw` / `gradle` / `gradlew`。
|
|
154
|
+
- **Python**(`pyproject.toml` / `requirements.txt`):`python` / `python3` / `pip` / `pip3` / `uv` / `poetry` / `pytest`。
|
|
155
|
+
- **Go**(`go.mod`):`go` / `gofmt` / `golangci-lint`。
|
|
156
|
+
- 白名单未覆盖的语言暂走原生 shell,遇到后再补进上表。
|
|
135
157
|
|
|
136
158
|
原因:
|
|
137
159
|
|
|
@@ -150,36 +172,22 @@ IDE MCP 只能 **查**(`get_run_configurations`)和 **执行**(`execute_run_conf
|
|
|
150
172
|
- JetBrains IDE 已开启 MCP 服务 (IDE 设置里的 MCP Server / AI Assistant MCP),版本需 **2026.2+**(提供 Streamable HTTP 端点
|
|
151
173
|
`/stream`);若关闭,`/open-in-idea` 会立即提示如何重新开启。
|
|
152
174
|
- 要读 IDE 终端环境,需要在 IDE 里开启 **Brave Mode**(否则 `execute_terminal_command` 会等确认);MCP 未就绪时通知会同时提醒开启它。
|
|
153
|
-
- 插件注册给 OpenCode 的 MCP
|
|
175
|
+
- 插件注册给 OpenCode 的 MCP 配置(**只支持 Streamable HTTP**):
|
|
154
176
|
|
|
155
177
|
```jsonc
|
|
156
178
|
{
|
|
157
|
-
"type": "
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
"
|
|
161
|
-
"environment": {
|
|
162
|
-
"IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>",
|
|
163
|
-
"IJ_MCP_SERVER_PORT": "64342",
|
|
164
|
-
},
|
|
179
|
+
"type": "remote",
|
|
180
|
+
"url": "http://127.0.0.1:64342/stream",
|
|
181
|
+
"headers": { "IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>" },
|
|
182
|
+
"codemode": false,
|
|
165
183
|
}
|
|
166
184
|
```
|
|
167
185
|
|
|
168
|
-
|
|
169
|
-
**客户端侧是持久管道,不存在 HTTP 空闲断流**——这正是默认选它的原因。每个项目一个插件实例、各带自己的
|
|
170
|
-
`IJ_MCP_SERVER_PROJECT_PATH`,所以每个项目会起一个独立的 stdio 客户端进程。
|
|
186
|
+
项目通过 `IJ_MCP_SERVER_PROJECT_PATH` 头选择,所以一个 IDE 能同时服务多个项目,而**不需要为每个项目起一个子进程**。
|
|
171
187
|
|
|
172
|
-
>
|
|
173
|
-
>
|
|
174
|
-
>
|
|
175
|
-
>
|
|
176
|
-
> ```jsonc
|
|
177
|
-
> {
|
|
178
|
-
> "type": "remote",
|
|
179
|
-
> "url": "http://127.0.0.1:64342/stream",
|
|
180
|
-
> "headers": { "IJ_MCP_SERVER_PROJECT_PATH": "<项目路径>" },
|
|
181
|
-
> }
|
|
182
|
-
> ```
|
|
188
|
+
> **为什么不用 IDE 的 stdio 桥**(`idea stdioMcpServer`,子进程管道):它的故障是"沉默"的——IDE 连接断掉后子进程只挂起、
|
|
189
|
+
> 不回任何错误,只能靠后台心跳兜;而 Location 空闲 60 分钟被驱逐时,桥进程与注册一起消失——**两种传输都躲不过**。
|
|
190
|
+
> HTTP 至少把"端点没了"变成一次普通的调用错误,插件能分类并自愈。OpenCode 2.0.19+ 也已无 SSE 客户端传输,当初选 stdio 的理由不再成立。
|
|
183
191
|
>
|
|
184
192
|
> **为什么不用 `/sse`**:OpenCode V2 的远程 MCP 客户端**只支持 Streamable HTTP**,没有旧版 SSE 传输。IDE 的 `/sse` 是旧版
|
|
185
193
|
> SSE (POST 返回 405),V2 连不上。插件对 IDE 的**探测**仍走 `/stream`(一次 `initialize`),用来确认 IDE MCP 已就绪并拿到端口。
|
|
@@ -246,14 +254,10 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
246
254
|
"ports": [64342], // IDE MCP 端口;默认 [64342]
|
|
247
255
|
"injectGuidance": true, // IDE 可用时向系统提示注入「优先使用 IDEA MCP」引导
|
|
248
256
|
"openInIde": "idea", // false 关闭;或任意 macOS 应用名(如 "IntelliJ IDEA")
|
|
249
|
-
"transport": "stdio", // "stdio"(默认,本地桥,不会空闲断流)| "http"(远程 Streamable HTTP)
|
|
250
|
-
"ideExecutable": undefined, // 可显式指定 IDE 可执行文件;默认从 .app 里推断
|
|
251
257
|
"launchCooldownMs": 120000, // 启动保护冷却
|
|
252
258
|
"launchMaxAttempts": 5,
|
|
253
259
|
"mcpProbeTimeoutMs": 1000, // 单个 MCP 端口的短探测超时
|
|
254
260
|
"executionTimeoutMs": 600000, // 单次 idea_* 工具调用硬上限;默认 10 分钟(OpenCode 默认 12 小时)
|
|
255
|
-
"heartbeatIntervalMs": 5000, // 后台端口探活间隔
|
|
256
|
-
"heartbeatFailures": 2, // 连续失败几次后注销 idea MCP
|
|
257
261
|
"feedback": "message", // "message"(默认,会话通知,AI 只回"收到")| false(静默)
|
|
258
262
|
},
|
|
259
263
|
},
|
|
@@ -263,20 +267,16 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
263
267
|
|
|
264
268
|
不传 options 时全部使用下表默认值。
|
|
265
269
|
|
|
266
|
-
| 选项
|
|
267
|
-
|
|
|
268
|
-
| `ports`
|
|
269
|
-
| `injectGuidance`
|
|
270
|
-
| `openInIde`
|
|
271
|
-
| `
|
|
272
|
-
| `
|
|
273
|
-
| `
|
|
274
|
-
| `
|
|
275
|
-
| `
|
|
276
|
-
| `executionTimeoutMs` | `number` | `600000` | 单次 `idea_*` 工具调用的硬上限(毫秒);防止桥挂起后无限等待(OpenCode 默认 12 小时) |
|
|
277
|
-
| `heartbeatIntervalMs` | `number` | `5000` | 后台 IDE 端口探活间隔(毫秒);仅"已标记 + 桥已注册"的项目有效 |
|
|
278
|
-
| `heartbeatFailures` | `number` | `2` | 连续探活失败几次后注销 `idea` MCP(杀僵尸桥 + 移除工具),下一条消息自动重建 |
|
|
279
|
-
| `feedback` | `"message" \| false` | `"message"` | 结果反馈:`message` 会话通知(用户可见,AI 只回"收到");`false` 静默 |
|
|
270
|
+
| 选项 | 类型 | 默认 | 说明 |
|
|
271
|
+
| -------------------- | -------------------- | ----------- | ---------------------------------------------------------------------------------- |
|
|
272
|
+
| `ports` | `number[]` | `[64342]` | 依次探测的 IDE MCP 端口 |
|
|
273
|
+
| `injectGuidance` | `boolean` | `true` | IDE 可用时是否向系统提示注入「优先使用 IDEA MCP」引导 |
|
|
274
|
+
| `openInIde` | `boolean \| string` | `"idea"` | 拉起哪个 IDE;`false` 关闭,`"idea"` 映射到 IntelliJ IDEA |
|
|
275
|
+
| `launchCooldownMs` | `number` | `120000` | 启动保护冷却:冷却期内不重复 spawn |
|
|
276
|
+
| `launchMaxAttempts` | `number` | `5` | 单次会话内最多 spawn 次数,超出后停止重试 |
|
|
277
|
+
| `mcpProbeTimeoutMs` | `number` | `1000` | 单个 MCP 端口的短探测超时 |
|
|
278
|
+
| `executionTimeoutMs` | `number` | `600000` | 单次 `idea_*` 工具调用的硬上限(毫秒);防止端点卡住后无限等待(OpenCode 默认 12 小时) |
|
|
279
|
+
| `feedback` | `"message" \| false` | `"message"` | 结果反馈:`message` 会话通知(用户可见,AI 只回"收到");`false` 静默 |
|
|
280
280
|
|
|
281
281
|
## 行为
|
|
282
282
|
|
|
@@ -284,23 +284,28 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
284
284
|
`/open-in-idea` 注册。
|
|
285
285
|
- **未标记的项目**:不改环境、不注册、不注入任何 IDEA 提示 (每请求零开销;setup 时只做一次项目标记读取)。
|
|
286
286
|
- **手动接入 + 就绪即返回**:`/open-in-idea` 会 `open -a` 拉起 IDEA 并**等 IDE MCP 就绪**(冷启动数十秒,上限 60s);就绪后
|
|
287
|
-
**先注销旧的 `idea`
|
|
288
|
-
|
|
287
|
+
**先注销旧的 `idea` → 再重新注册**(远程 Streamable HTTP),等 `idea_*` 工具就绪,最后才通知。**每次执行都重建**。命令返回即可用;
|
|
288
|
+
`/close-in-idea` 注销 `idea` + 删标记 + 清环境。
|
|
289
289
|
- **`context` 钩子是请求的纯函数**:按「每次模型调用」触发 (含工具续跑),只看本次请求自带的工具快照 —— 有 `idea_*` 就剥离隐藏
|
|
290
290
|
工具并注入引导;没有则只在项目被标记时给恢复提示。该钩子本身**不做任何 I/O**(只看内存缓存),也不订阅任何事件。
|
|
291
|
-
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
291
|
+
- **请求驱动的恢复(没有后台轮询)**:
|
|
292
|
+
- **`prompt` 钩子**:「已标记 + 本实例没有任何 `idea` 注册」时**探测端口并重新注册**(只探测、绝不 `open -a`)。OpenCode 会
|
|
293
|
+
await `prompt` 才构建请求,所以**触发它的那条消息本身**就带上了 `idea_*` 工具,没有空窗。这正是 Location 被空闲驱逐
|
|
294
|
+
(60 分钟 `idleTimeToLive`)或 OpenCode 重启后,新实例自动接回连接的路径。
|
|
295
|
+
- **`context` 钩子**:发现「本会话见过 `idea_*` 工具、本请求却没有」→ 立刻锁存并在后台重建(不等下一条消息)。工具缺失时注入的
|
|
296
|
+
恢复引导会**明确禁止**调用 `idea_*`(此前复用"重试一次"的文案,反而诱导模型去调一个不在工具表里的工具,得到
|
|
297
|
+
`No tool named ... available`)。
|
|
298
|
+
- **`execute.after` 钩子**:见下一条。
|
|
299
|
+
恢复是**每个 Location(项目)各一份**;没有任何定时器,插件在不使用时完全静止。
|
|
295
300
|
- **失败触发的自动接入(仅被标记项目)**:`tool.execute.after` 钩子监听 `idea_*` 工具报错并**分类**:
|
|
296
301
|
- IDE MCP 不可用(掉了)→ 重跑 `/open-in-idea` 的接入流程;
|
|
297
302
|
- IDE 在、但项目没打开(`Unable to determine the target project` / `doesn't correspond to any open project`)→ 让 IDEA 重新打开该项目;
|
|
298
303
|
- 普通业务错误(`File not found` 等)→ **不动作**。判定只认**传输层专有标记**(`MCP server "idea" is not available` /
|
|
299
|
-
`MCP server is not connected` / `Error POSTing to endpoint`
|
|
304
|
+
`MCP server is not connected` / `Error POSTing to endpoint`),**不认**工具输出里出现的通用串
|
|
300
305
|
(例如 SQL 工具报的 `Connection refused`) —— MCP 工具错误的 message 就是工具自己的输出,认通用串会把正常业务失败误判成掉线并重建。
|
|
301
306
|
触发成功会把该工具错误改写为"已重连 / 已重新打开,请重试",模型在同一回合内重试;失败则保持原错误。非 `idea_*` 工具、未标记
|
|
302
307
|
项目都不受影响。启动保护 (`launchCooldownMs` / `launchMaxAttempts`) 限制重复拉起 IDE。
|
|
303
|
-
- **`/open-in-idea`**:手动打开当前项目;**等 IDE MCP 就绪 → 先注销旧的 `idea`
|
|
308
|
+
- **`/open-in-idea`**:手动打开当前项目;**等 IDE MCP 就绪 → 先注销旧的 `idea` → 再重新注册 → 等 `idea_*` 工具就绪**才返回。
|
|
304
309
|
**`/close-in-idea`**:注销 IDE MCP + 删项目标记 + 清空注入的环境。
|
|
305
310
|
|
|
306
311
|
## 限制
|
|
@@ -320,17 +325,15 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
320
325
|
(`get_project_modules`,带 `IJ_MCP_SERVER_PROJECT_PATH`):返回模块 = 已打开 → 跳过 `open -a`;报
|
|
321
326
|
`Unable to determine the target project` = 未打开 → `open -a` 打开。MCP 关闭时会用一次短探测并立即给出设置提示。
|
|
322
327
|
项目刚打开时的短暂"项目未就绪"由工具失败分类兜底(「IDE 在、但项目没打开」→ 重新打开并让模型重试)。
|
|
323
|
-
- IDE **2026.2+**
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
-
|
|
331
|
-
|
|
332
|
-
- 插件给 `idea` server 配 `executionTimeoutMs`(默认 **10 分钟**)作为硬上限:兜住"心跳尚未触发、工具已在途"的那次挂起,不再沿用 OpenCode 默认的 12 小时;
|
|
333
|
-
- "IDE 被关掉 → 工具挂起"这类**不会**走工具失败分类(没有 error);能自动分类处理的是"IDE 在、项目没打开"和"server 掉了(启动即失败)"。
|
|
328
|
+
- IDE **2026.2+** 提供 Streamable HTTP 端点 `/stream`,这是插件**唯一**使用的传输(`type: "remote"`)。注册本身不启动任何子进程,
|
|
329
|
+
所以没有"桥进程被 IDE 拖死后变成僵尸"这一类问题。`/open-in-idea` 仍要求 IDE MCP 已在监听(冷启动约数十秒),因此会先等就绪再注册。
|
|
330
|
+
- **空闲驱逐(已实测)**:OpenCode 把一个 Location 的服务缓存在 `LayerMap` 里,`idleTimeToLive` = **60 分钟**;空闲到期即整体驱逐,
|
|
331
|
+
插件实例连同 `idea` MCP 注册一起消失。**两种传输都躲不过**,所以对策不是换传输,而是请求驱动的恢复(见「行为」):
|
|
332
|
+
- 新实例上第一条用户消息的 `prompt` 钩子会把连接接回来 —— 常态下**不需要用户手动做什么**;
|
|
333
|
+
- 手动重建仍可用:执行 `/open-in-idea`(先注销旧 `idea`、再重新注册);
|
|
334
|
+
- 插件给 `idea` server 配 `executionTimeoutMs`(默认 **10 分钟**)作为硬上限:兜住端点卡住时的单次调用,不再沿用 OpenCode 默认的 12 小时;
|
|
335
|
+
- "IDE 被关掉"这一类会被下一次 `idea_*` 调用暴露(HTTP 直接报错),由 `execute.after` 钩子分类后自愈;不产生 error 的场景(例如
|
|
336
|
+
IDE 进程还在但项目没打开)由「IDE 在、项目没打开」分支处理。
|
|
334
337
|
- 项目标记存在插件名下 KV (`plugin:opencode-idea:project/<项目根路径>`)。插件实例按 **Location**(项目/打开目录)创建,标记按
|
|
335
338
|
**项目根路径**共享;但两个不同 Location(例如项目根与某子目录)各有自己的内存缓存 —— 单目录打开项目的常见用法不受影响。
|
|
336
339
|
- `/close-in-idea` 会**注销 IDE MCP** 并删标记 + 清空注入的环境。
|
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: idea-standalone
|
|
3
|
+
description: "为项目生成/维护 standalone(经 IDEA 启动、本机进程直跑)模式的纪律 rule,写在项目根 `.opencode/rules/idea-standalone.md`。当用户要做 standalone 规范、要固化「本机跑哪几个应用」「哪些应用热重载、哪些改了必须重启」「AI 能不能启停本机进程」这类项目级纪律时使用。产出必须写清四段:standalone 是什么、standalone 下经 IDEA run configuration 启动哪些应用、逐应用的热重载与重启提示纪律、以及用户补充的其它条款。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Standalone 模式纪律 rule 生成器
|
|
7
|
+
|
|
8
|
+
## 产出物
|
|
9
|
+
|
|
10
|
+
一份 **项目级 rule**:`<项目根>/.opencode/rules/idea-standalone.md`,把这个项目在 **本机 standalone 模式**下怎么跑、AI
|
|
11
|
+
能做什么不能做什么固化下来。
|
|
12
|
+
|
|
13
|
+
- 位置固定:`.opencode/rules/`(项目级规则目录),文件名 `idea-standalone.md`。
|
|
14
|
+
- frontmatter 照抄项目里既有 rule 的格式 (`description` + `alwaysApply: true`)。
|
|
15
|
+
- 只对这个项目生效; **不写**全局规则目录 (`~/.config/opencode/rules/`)。
|
|
16
|
+
|
|
17
|
+
## 四段结构 (缺一不可)
|
|
18
|
+
|
|
19
|
+
### 第一段:什么是 standalone 模式
|
|
20
|
+
|
|
21
|
+
**standalone = 通过 IDEA 启动** —— 应用在本机进程直跑,不容器化、不部署到远端。给"本项目在本机的运行形态"下定义,至少覆盖:
|
|
22
|
+
|
|
23
|
+
- **通过 IDEA 启动**:每个应用的启动入口就是 IDEA 的 run configuration(Run / Debug),不是手搓命令行。
|
|
24
|
+
- **应用在本机进程直跑**:不容器化、不部署到远端。本机 **不跑**什么也要写明 (例如需要 GPU 的服务、或本机直连的远端数据库)。
|
|
25
|
+
- **只启动其中一部分是常态** —— standalone 不要求所有应用都起来,用户可能只开一两个。
|
|
26
|
+
- **启停一律由用户在 IDEA 手动完成**:AI 不得代为启停本机进程 —— 包括 `go run` / `pnpm dev` / `uvicorn` /
|
|
27
|
+
`python -m ...` 这类起进程、`kill` / `pkill` 业务进程,以及经 `idea_execute_run_configuration` 启停。AI 若判断需要重启,
|
|
28
|
+
**先向用户说明并取得明确授权**,绝不自行执行。
|
|
29
|
+
- **与部署链纪律互不构成许可**:standalone 的禁令不适用于部署流程,部署链的步骤也不构成"可以动本机进程"的许可 —— 两阶段纪律互相独立。
|
|
30
|
+
|
|
31
|
+
### 第二段:standalone 下启动哪些应用 (必须经 IDEA run configuration)
|
|
32
|
+
|
|
33
|
+
表格,一行一个应用:
|
|
34
|
+
|
|
35
|
+
| 应用 | run configuration | 目录 | 端口 | 说明 |
|
|
36
|
+
|------|-------------------|------|------|------|
|
|
37
|
+
|
|
38
|
+
要点:
|
|
39
|
+
|
|
40
|
+
- `run configuration` 列 **必须是 `idea_get_run_configurations` 里真实存在的名字**(不凭印象写);缺配置时先用
|
|
41
|
+
`idea-run-config` skill 建项目级 `.run/*.run.xml`,不要手搓启动命令。
|
|
42
|
+
- **启动路径只能是 IDEA run configuration**:不写 `pnpm dev &`、`nohup ... &` 这类后台命令,也不建议用户这么干。
|
|
43
|
+
- 端口写清"固定 / 默认 / 被占用即启动失败"; **哪个应用必须先起来**也要写 (例如前端 dev server 的 proxy 指向本机后端)。
|
|
44
|
+
- 端口读不到 (配置里没有、也没写在项目配置里)就 **问用户**,不要编。
|
|
45
|
+
|
|
46
|
+
### 第三段:热重载矩阵 + 改代码后要不要提醒重启
|
|
47
|
+
|
|
48
|
+
逐应用写清 **是 / 不是**热重载,以及对应的提醒纪律:
|
|
49
|
+
|
|
50
|
+
| 应用 | 热重载 | AI 改代码后 |
|
|
51
|
+
|-------------------------------------------------------|--------|-------------------------------------------------------|
|
|
52
|
+
| 带 HMR 的 dev server(如 Vite) | 是 | 不提醒重启 |
|
|
53
|
+
| 编译型 / 无热重载进程(如 `go run .`、`python -m ...`) | 否 | **必须**在回复里显式点名「需要重启 <应用>」并说明原因 |
|
|
54
|
+
| 只能跑在远端、本机起不来的服务 | 不适用 | 本机无法验证,按部署链部署后再验证 |
|
|
55
|
+
|
|
56
|
+
铁律:
|
|
57
|
+
|
|
58
|
+
- 非热重载的应用改动后, **不得**在没有提示的情况下宣称"改动完成 / 可以去验证了";重启动作由用户手动做。
|
|
59
|
+
- 热重载列必须有依据 (运行方式 → 有没有 HMR),不确定就问用户,不猜。
|
|
60
|
+
- `go run .` 这类 **没有热重载**的写法要写进 rule,别让 AI 以为改了代码就自动生效。
|
|
61
|
+
|
|
62
|
+
### 第四段起:项目补充条款
|
|
63
|
+
|
|
64
|
+
用户针对这个项目给的其它纪律, **按顺序续写在后面**(第四段、第五段……),例如:
|
|
65
|
+
|
|
66
|
+
- 文件写入位置 (禁止写哪些目录、跑测/调试产物必须落在哪儿)
|
|
67
|
+
- 测试纪律 (用例归属、什么时候才允许新增 UT、端到端测试在哪台机器上跑)
|
|
68
|
+
- 访问归属 (数据库 / 第三方接口只经哪个工具,不旁路直连)
|
|
69
|
+
- dev 模式特有限制 (会真实计费的操作、凭据存储方式等)
|
|
70
|
+
|
|
71
|
+
处理方式:用户给的条款 **逐条保留原意**、编号连续, **不合并不改写语义**。
|
|
72
|
+
|
|
73
|
+
## 流程
|
|
74
|
+
|
|
75
|
+
1. **收集事实 (不猜)**:
|
|
76
|
+
- `idea_get_run_configurations` → 真实存在的配置名 (第二段"run configuration"列只能来自这里)。
|
|
77
|
+
- `idea_search_file` 传 `q="*.run.xml"` + `paths: [".run"]` → 每个配置实际干什么 (命令 / 脚本 / npm script)。
|
|
78
|
+
- `package.json` / `pom.xml` / `go.mod` / `pyproject.toml` / `Cargo.toml` → 项目类型 → 决定第三段的热重载列。
|
|
79
|
+
- 端口:从 run configuration 的 option 或项目自身配置里读。
|
|
80
|
+
2. **补齐缺口**:哪些应用进 standalone、哪些配置名对不上、端口与热重载不确定 —— 逐条向用户确认,不编。
|
|
81
|
+
3. **写文件**:首次用 `idea_create_new_file` 写 `.opencode/rules/idea-standalone.md`;后续更新用 `idea_apply_patch`
|
|
82
|
+
**定向改**,不整文件重写。
|
|
83
|
+
4. **校验**:第二段表格里每个配置名都能在 `idea_get_run_configurations` 里查到 —— **查不到就是错的**,不许用
|
|
84
|
+
"应该生效了"结案。
|
|
85
|
+
|
|
86
|
+
## 铁律 (汇总)
|
|
87
|
+
|
|
88
|
+
1. 四段结构不能缺:standalone 定义 → 应用与 run configuration → 热重载与重启提醒 → 用户补充条款。
|
|
89
|
+
2. 第二段的启动方式 **只能是 IDEA run configuration**;不写手搓启动命令。
|
|
90
|
+
3. AI 不启停本机进程 (含 `idea_execute_run_configuration`);判断需要重启时先取得用户明确授权。
|
|
91
|
+
4. 配置名、端口、热重载三项都必须有依据;不确定就问用户。
|
|
92
|
+
5. 用户补充条款保留原意、编号连续,不合并不改写。
|
|
93
|
+
6. rule 只写项目级 `.opencode/rules/idea-standalone.md`,不写全局规则目录。
|
package/src/ide-guidance.js
CHANGED
|
@@ -61,13 +61,12 @@ export function buildIdeGuidance(projectPath) {
|
|
|
61
61
|
`- \`idea_execute_run_configuration\`:跑已有配置;Shell Script 要 \`EXECUTE_IN_TERMINAL: false\`;覆盖参数(\`programArguments\` / \`workingDirectory\` / \`envs\`)需配置支持 \`supportsDynamicLaunchOverrides\`。`,
|
|
62
62
|
``,
|
|
63
63
|
`### 终端`,
|
|
64
|
-
`- \`idea_execute_terminal_command
|
|
65
|
-
|
|
66
|
-
`
|
|
67
|
-
`
|
|
68
|
-
`
|
|
69
|
-
` -
|
|
70
|
-
` 以及把语言当一次性脚本干非开发任务的杂活(如 \`python3 -c\` 改一个文件、\`node -e\` 打印一段内容)。`,
|
|
64
|
+
`- 白名单制:命令用于本项目自身的构建 / 测试 / 运行,且首个可执行文件命中下表,才用 \`idea_execute_terminal_command\`;其余一律走原生 \`shell\`。`,
|
|
65
|
+
`- 白名单(按本项目类型取对应一行) ——`,
|
|
66
|
+
` - Node / 前端(\`package.json\`):\`node\` / \`npm\` / \`npx\` / \`pnpm\` / \`yarn\` / \`vite\` / \`webpack\` / \`esbuild\` / \`tsc\` / \`jest\` / \`vitest\`。`,
|
|
67
|
+
` - Java(\`pom.xml\` / \`build.gradle\`):\`java\` / \`javac\` / \`mvn\` / \`mvnw\` / \`gradle\` / \`gradlew\`。`,
|
|
68
|
+
` - Python(\`pyproject.toml\` / \`requirements.txt\`):\`python\` / \`python3\` / \`pip\` / \`pip3\` / \`uv\` / \`poetry\` / \`pytest\`。`,
|
|
69
|
+
` - Go(\`go.mod\`):\`go\` / \`gofmt\` / \`golangci-lint\`。`,
|
|
71
70
|
` - \`executeInShell: true\`:在用户真实 shell 里执行,继承 IDE 配好的运行时环境;原生 shell 可能用错版本。`,
|
|
72
71
|
` - 按预期耗时传 \`timeout\`(毫秒),长任务给足;必要时传 \`maxLinesCount\` 控制返回行数。`,
|
|
73
72
|
` - 连续命令传 \`reuseExistingTerminalWindow: true\`;结果带 \`command_exit_code\` 与 \`command_output\`,\`cd\` 不跨调用保留,多步用 \`&&\`。`,
|
|
@@ -104,6 +103,19 @@ export const MCP_RECOVERY_GUIDANCE_MARKED = [
|
|
|
104
103
|
'This project is IDEA-connected and the plugin reconnects the IDE MCP automatically. When an idea_* call fails with a connection error (for example `MCP server "idea" is not available`), do not run extra probes or wait in tool calls: the reconnect is already under way, so retry the operation once. Only if it still fails, tell the user that IDEA MCP is unavailable and to run /open-in-idea.',
|
|
105
104
|
].join('\n');
|
|
106
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Recovery instruction for a marked project whose idea_* tools are NOT in this
|
|
108
|
+
* request's tool list. The connection dropped and the plugin is already
|
|
109
|
+
* reconnecting, but the reconnect only lands on a LATER request — so within this
|
|
110
|
+
* one a call can only fail with `No tool named "..." is currently available`.
|
|
111
|
+
* Saying "retry" here (as the MARKED variant does) is what makes the model fire
|
|
112
|
+
* exactly that doomed call.
|
|
113
|
+
*/
|
|
114
|
+
export const MCP_RECOVERY_GUIDANCE_RECONNECTING = [
|
|
115
|
+
'### IDEA MCP failure recovery',
|
|
116
|
+
'No idea_* tool is in this request: the IDE connection dropped and the plugin is reconnecting in the background. Do NOT call any idea_* tool now — it will fail with `No tool named "..." is currently available` — and do not fall back to shell/read/write for in-project work. Continue with what you can, or tell the user the IDE is reconnecting; if idea_* tools are still missing on a later message, ask the user to run /open-in-idea.',
|
|
117
|
+
].join('\n');
|
|
118
|
+
|
|
107
119
|
/**
|
|
108
120
|
* Push the guidance onto a session `system` array. Called for every primary
|
|
109
121
|
* model request, so it stays idempotent per request.
|
|
@@ -121,11 +133,17 @@ export function appendIdeGuidance(system, projectPath) {
|
|
|
121
133
|
|
|
122
134
|
/**
|
|
123
135
|
* @param {unknown} system
|
|
124
|
-
* @param {{ marked?: boolean }} [options] `marked` picks the
|
|
136
|
+
* @param {{ marked?: boolean, serving?: boolean }} [options] `marked` picks the
|
|
137
|
+
* auto-reconnect variant; `serving: false` additionally picks the variant for
|
|
138
|
+
* a request that has no idea_* tools at all, where a call can only fail.
|
|
125
139
|
*/
|
|
126
|
-
export function appendIdeRecoveryGuidance(system, { marked = false } = {}) {
|
|
140
|
+
export function appendIdeRecoveryGuidance(system, { marked = false, serving = true } = {}) {
|
|
127
141
|
if (!Array.isArray(system)) return;
|
|
128
|
-
const text = marked
|
|
142
|
+
const text = marked
|
|
143
|
+
? serving
|
|
144
|
+
? MCP_RECOVERY_GUIDANCE_MARKED
|
|
145
|
+
: MCP_RECOVERY_GUIDANCE_RECONNECTING
|
|
146
|
+
: MCP_RECOVERY_GUIDANCE;
|
|
129
147
|
if (system.some((part) => part?.text === text)) return;
|
|
130
148
|
system.push({ type: 'text', text });
|
|
131
149
|
}
|
package/src/ide-launcher.js
CHANGED
|
@@ -12,8 +12,6 @@
|
|
|
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';
|
|
@@ -37,62 +35,6 @@ export function resolveIdeApp(value) {
|
|
|
37
35
|
return IDE_APPS[value.toLowerCase()] ?? value;
|
|
38
36
|
}
|
|
39
37
|
|
|
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
38
|
/**
|
|
97
39
|
* Open `directory` in the IDE, reusing a running instance when possible.
|
|
98
40
|
* 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,15 +18,6 @@
|
|
|
17
18
|
|
|
18
19
|
export const PROJECT_HEADER = 'IJ_MCP_SERVER_PROJECT_PATH';
|
|
19
20
|
|
|
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
|
-
|
|
29
21
|
/** OpenCode MCP server name registered for the IDE. */
|
|
30
22
|
export const IDEA_SERVER_NAME = 'idea';
|
|
31
23
|
|
|
@@ -65,48 +57,13 @@ export function serverConfig(port, projectPath, options = {}) {
|
|
|
65
57
|
return withExecutionTimeout(config, options);
|
|
66
58
|
}
|
|
67
59
|
|
|
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
60
|
/**
|
|
103
61
|
* Attach OpenCode's per-server execution timeout to an MCP config.
|
|
104
62
|
*
|
|
105
|
-
* OpenCode defaults this to **12 hours**.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* an ordinary timeout error.
|
|
63
|
+
* OpenCode defaults this to **12 hours**. A wedged IDE MCP server can accept the
|
|
64
|
+
* POST and never answer it, so without a cap a call made after the IDE died
|
|
65
|
+
* would sit unresolved for the whole default. A finite cap turns that hang into
|
|
66
|
+
* an ordinary timeout error the plugin can classify and heal from.
|
|
110
67
|
*
|
|
111
68
|
* @param {Record<string, unknown>} config
|
|
112
69
|
* @param {{ executionTimeoutMs?: number }} options
|
|
@@ -146,7 +103,7 @@ export async function probePort(port, options = {}) {
|
|
|
146
103
|
params: {
|
|
147
104
|
protocolVersion: PROTOCOL_VERSION,
|
|
148
105
|
capabilities: {},
|
|
149
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
106
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.15' },
|
|
150
107
|
},
|
|
151
108
|
}),
|
|
152
109
|
signal: controller.signal,
|
|
@@ -217,7 +174,7 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
|
|
|
217
174
|
params: {
|
|
218
175
|
protocolVersion: PROTOCOL_VERSION,
|
|
219
176
|
capabilities: {},
|
|
220
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
177
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.15' },
|
|
221
178
|
},
|
|
222
179
|
});
|
|
223
180
|
// 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,12 @@ 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 {
|
|
50
56
|
createLaunchGuard,
|
|
51
|
-
DEFAULT_IDE_APP,
|
|
52
57
|
openInIde,
|
|
53
58
|
resolveIdeApp,
|
|
54
|
-
resolveIdeExecutable,
|
|
55
59
|
} from './ide-launcher.js';
|
|
56
60
|
import {
|
|
57
61
|
appendIdeGuidance,
|
|
@@ -69,22 +73,28 @@ const DEFAULT_MCP_PROBE_TIMEOUT_MS = 1000;
|
|
|
69
73
|
const DEFAULT_MCP_START_TIMEOUT_MS = 60000;
|
|
70
74
|
const MCP_START_POLL_MS = 1000;
|
|
71
75
|
const DEFAULT_TOOL_REFRESH_TIMEOUT_MS = 5000;
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
+
// 等工具目录落地时的轮询间隔。这是一次**有界等待**(见 waitForIdeaTools),不是
|
|
77
|
+
// 状态轮询:100ms 足够快,又不会在 5s 上限内空转几十次。
|
|
78
|
+
const TOOL_REFRESH_POLL_MS = 100;
|
|
79
|
+
// Hard cap on ONE IDE MCP tool call. OpenCode's own default is 12 hours, and a
|
|
80
|
+
// wedged IDE MCP server can accept a POST and never answer it — so a call made
|
|
76
81
|
// after the IDE died would otherwise sit unresolved for 12h. 10 minutes is far
|
|
77
|
-
// beyond any normal IDE tool and turns that
|
|
82
|
+
// beyond any normal IDE tool and turns that hang into a normal error the failure
|
|
83
|
+
// classifier can act on.
|
|
78
84
|
const DEFAULT_EXECUTION_TIMEOUT_MS = 600000;
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// Every IDE tool comes from the MCP server the plugin registers as `idea`, and
|
|
86
|
+
// OpenCode namespaces a server's tools with its name — so `idea_*` IS the whole
|
|
87
|
+
// set, the same predicate the request hooks use. "Ready" means at least one of
|
|
88
|
+
// them is listed: OpenCode publishes an MCP catalog wholesale (one reload), so
|
|
89
|
+
// one tool means the batch landed.
|
|
90
|
+
//
|
|
91
|
+
// This used to be a hard-coded pair (`idea_read_file` + `idea_apply_patch`),
|
|
92
|
+
// which is not a property the plugin may assume: the IDE can rename a tool, and
|
|
93
|
+
// the user can switch one off on the IDE's Exposed Tools page. Either one made
|
|
94
|
+
// the wait unsatisfiable, so every `/open-in-idea` and every auto-rebuild burned
|
|
95
|
+
// the full `toolRefreshTimeoutMs` and still reported `tools-loading` — telling
|
|
96
|
+
// the user to re-run a command that could never succeed.
|
|
97
|
+
const IDEA_TOOL_PREFIX = 'idea_';
|
|
88
98
|
|
|
89
99
|
/**
|
|
90
100
|
* IDEA MCP tools that must never reach the model.
|
|
@@ -191,7 +201,10 @@ function toolIds(tools) {
|
|
|
191
201
|
* - `project`: the IDE is up but the requested project is not open in it, e.g.
|
|
192
202
|
* "`projectPath`=... doesn't correspond to any open project." Re-opening the
|
|
193
203
|
* project fixes it.
|
|
194
|
-
* - `mcp`: the IDE MCP server itself is gone
|
|
204
|
+
* - `mcp`: the IDE MCP server itself is gone — the endpoint stopped answering,
|
|
205
|
+
* the transport closed, or the connection was refused. See the marker list in
|
|
206
|
+
* the body; it includes the connect failure OpenCode's own MCP client emits
|
|
207
|
+
* (`Unable to connect…`, and the structural `code: "ConnectionRefused"`).
|
|
195
208
|
*
|
|
196
209
|
* Everything else is `none`.
|
|
197
210
|
*
|
|
@@ -202,11 +215,10 @@ function classifyIdeaFailure(error) {
|
|
|
202
215
|
const message = typeof error?.message === 'string' ? error.message : '';
|
|
203
216
|
// "Project not open in the IDE" comes in two shapes, both meaning the same
|
|
204
217
|
// thing and both fixed by opening the project again:
|
|
205
|
-
// - the
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// - an explicit `projectPath` was passed but is not open: "`projectPath`=...
|
|
218
|
+
// - the server could not pick a project for the call: "Unable to determine
|
|
219
|
+
// the target project for the current MCP tool call." (the project path in
|
|
220
|
+
// the request header did not resolve — the IDE may still be opening it)
|
|
221
|
+
// - the project was resolved but is not open: "`projectPath`=...
|
|
210
222
|
// doesn't correspond to any open project."
|
|
211
223
|
if (
|
|
212
224
|
message.includes('Unable to determine the target project') ||
|
|
@@ -214,20 +226,35 @@ function classifyIdeaFailure(error) {
|
|
|
214
226
|
) {
|
|
215
227
|
return 'project';
|
|
216
228
|
}
|
|
217
|
-
// Only transport-level markers, which OpenCode
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
229
|
+
// Only transport-level markers, which OpenCode's own MCP client emits. The
|
|
230
|
+
// message of an MCP *tool error* is the tool's own content (`tool/mcp.ts`
|
|
231
|
+
// turns `result.isError` into `ToolFailure({ message: <text> })`), so a
|
|
232
|
+
// generic string such as "Connection refused" from a SQL or run-config tool
|
|
233
|
+
// would otherwise be misread as a dropped connection and rebuild a healthy
|
|
234
|
+
// server.
|
|
235
|
+
//
|
|
236
|
+
// MEASURED (2026-09-30, OpenCode 2.0.19, IDE process killed): a dead remote
|
|
237
|
+
// MCP endpoint does NOT produce any of the shapes above. OpenCode's client
|
|
238
|
+
// reports its own connect failure instead:
|
|
239
|
+
//
|
|
240
|
+
// level=WARN message="mcp http request failed"
|
|
241
|
+
// errors="[{\"type\":\"TypeError\",
|
|
242
|
+
// \"message\":\"Unable to connect. Is the computer able to access the url?\",
|
|
243
|
+
// \"code\":\"ConnectionRefused\",\"errno\":0}]"
|
|
244
|
+
//
|
|
245
|
+
// so the text is matched on its stable prefix, and the underlying `code` is
|
|
246
|
+
// accepted as a structural signal too (a wording change must not silently
|
|
247
|
+
// disable the heal).
|
|
223
248
|
if (
|
|
224
249
|
message.includes(`MCP server "${IDEA_SERVER_NAME}" is not available`) ||
|
|
225
250
|
message.includes('MCP server is not connected') ||
|
|
226
251
|
message.includes('Error POSTing to endpoint') ||
|
|
227
|
-
message.includes('
|
|
252
|
+
message.includes('Unable to connect')
|
|
228
253
|
) {
|
|
229
254
|
return 'mcp';
|
|
230
255
|
}
|
|
256
|
+
const causeCode = error?.error?.code ?? error?.cause?.code ?? error?.code;
|
|
257
|
+
if (causeCode === 'ConnectionRefused' || causeCode === 'ECONNREFUSED') return 'mcp';
|
|
231
258
|
return 'none';
|
|
232
259
|
}
|
|
233
260
|
|
|
@@ -344,10 +371,6 @@ export default {
|
|
|
344
371
|
* mcpStartTimeoutMs?: number,
|
|
345
372
|
* toolRefreshTimeoutMs?: number,
|
|
346
373
|
* executionTimeoutMs?: number,
|
|
347
|
-
* heartbeatIntervalMs?: number,
|
|
348
|
-
* heartbeatFailures?: number,
|
|
349
|
-
* transport?: 'stdio' | 'http',
|
|
350
|
-
* ideExecutable?: string,
|
|
351
374
|
* feedback?: false | 'message',
|
|
352
375
|
* },
|
|
353
376
|
* command?: {
|
|
@@ -407,28 +430,12 @@ export default {
|
|
|
407
430
|
const ports = asArray(options.ports, DEFAULT_PORTS);
|
|
408
431
|
const injectGuidance = options.injectGuidance !== false;
|
|
409
432
|
const ideApp = resolveIdeApp(options.openInIde);
|
|
410
|
-
// Transport for the IDE MCP server. `stdio` (default) registers the IDE's
|
|
411
|
-
// stdio bridge as a local server: the client side is a child-process pipe,
|
|
412
|
-
// which — unlike the remote Streamable-HTTP stream — does not get closed
|
|
413
|
-
// while idle and never reconnected. `http` keeps the older remote config.
|
|
414
|
-
const transport = options.transport === 'http' ? 'http' : 'stdio';
|
|
415
|
-
const ideExecutable = typeof options.ideExecutable === 'string'
|
|
416
|
-
? options.ideExecutable
|
|
417
|
-
// Resolve independently of `openInIde`: that option only controls whether
|
|
418
|
-
// the plugin may LAUNCH the IDE, not which transport the MCP server uses.
|
|
419
|
-
// Falling back to the default app when launching is disabled keeps stdio
|
|
420
|
-
// the default there too (otherwise `openInIde: false` silently registered
|
|
421
|
-
// the remote HTTP transport).
|
|
422
|
-
: resolveIdeExecutable(ideApp ?? DEFAULT_IDE_APP);
|
|
423
|
-
const useStdio = transport === 'stdio' && typeof ideExecutable === 'string';
|
|
424
433
|
const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
|
|
425
434
|
const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
|
|
426
435
|
const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
|
|
427
|
-
// Cap on a single IDE tool call, so a
|
|
428
|
-
//
|
|
436
|
+
// Cap on a single IDE tool call, so a wedged server fails in finite time
|
|
437
|
+
// instead of OpenCode's 12h default. See DEFAULT_EXECUTION_TIMEOUT_MS.
|
|
429
438
|
const executionTimeoutMs = asPositiveNumber(options.executionTimeoutMs, DEFAULT_EXECUTION_TIMEOUT_MS);
|
|
430
|
-
const heartbeatIntervalMs = asPositiveNumber(options.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
431
|
-
const heartbeatFailures = asPositiveNumber(options.heartbeatFailures, DEFAULT_HEARTBEAT_FAILURES);
|
|
432
439
|
// "message" (default) injects a clearly-labelled notification that the
|
|
433
440
|
// model only acknowledges ("收到"); the user sees the clean notice via
|
|
434
441
|
// metadata.displayText. false is silent.
|
|
@@ -453,14 +460,15 @@ export default {
|
|
|
453
460
|
let skillsRegistered = false;
|
|
454
461
|
let disposed = false;
|
|
455
462
|
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
// Latches when the IDE was seen down and the server was deactivated. It is
|
|
461
|
-
// cleared ONLY by a successful rebuild — never by the endpoint coming back —
|
|
462
|
-
// because the bridge does not heal on its own.
|
|
463
|
+
// Latches when a request showed the `idea_*` tools are gone. It is cleared
|
|
464
|
+
// only by a successful rebuild. It is set from the `context` hook (a request
|
|
465
|
+
// that HAD the tools no longer does); the `prompt` hook also heals without
|
|
466
|
+
// this latch when the instance has no registration at all.
|
|
463
467
|
let needsRebuild = false;
|
|
468
|
+
// Set once a request in this session actually carried `idea_*` tools, so a
|
|
469
|
+
// later request without them is a DROP of this session's own connection —
|
|
470
|
+
// not a project that simply never connected.
|
|
471
|
+
let wasServing = false;
|
|
464
472
|
|
|
465
473
|
// Project-level mark: has the user opted this project into IDEA via
|
|
466
474
|
// `/open-in-idea`? One plugin instance serves one project (OpenCode
|
|
@@ -534,7 +542,7 @@ export default {
|
|
|
534
542
|
while (Date.now() < deadline) {
|
|
535
543
|
try {
|
|
536
544
|
const ids = toolIds(await ctx.tool.list());
|
|
537
|
-
if (
|
|
545
|
+
if (ids.some((id) => id.startsWith(IDEA_TOOL_PREFIX))) return true;
|
|
538
546
|
} catch {
|
|
539
547
|
// The MCP catalog may still be settling; retry within the bounded wait.
|
|
540
548
|
}
|
|
@@ -554,9 +562,7 @@ export default {
|
|
|
554
562
|
};
|
|
555
563
|
|
|
556
564
|
const activateIde = async (port) => {
|
|
557
|
-
const config =
|
|
558
|
-
? stdioServerConfig(ideExecutable, port, projectPath, { executionTimeoutMs })
|
|
559
|
-
: serverConfig(port, projectPath, { executionTimeoutMs });
|
|
565
|
+
const config = serverConfig(port, projectPath, { executionTimeoutMs });
|
|
560
566
|
const mcpRegistration = await ctx.mcp.transform((editor) => {
|
|
561
567
|
editor.set(IDEA_SERVER_NAME, config);
|
|
562
568
|
});
|
|
@@ -576,15 +582,11 @@ export default {
|
|
|
576
582
|
return false;
|
|
577
583
|
}
|
|
578
584
|
|
|
579
|
-
// Always tear the server down and register it again.
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
// the
|
|
584
|
-
// reconciled config, which is what actually kills the child process — so a
|
|
585
|
-
// full rebuild is the only reliable way back to a working bridge.
|
|
586
|
-
// `/open-in-idea` is manual and rare; rebuilding an already-healthy bridge
|
|
587
|
-
// costs one restart, which is the right trade for never leaving a zombie.
|
|
585
|
+
// Always tear the server down and register it again. OpenCode's reconcile
|
|
586
|
+
// SKIPS a re-`set` whose config is unchanged, so re-registering without a
|
|
587
|
+
// teardown would leave a dropped server in place forever. `/open-in-idea`
|
|
588
|
+
// and the request hooks are rare; rebuilding costs one restart, which is
|
|
589
|
+
// the right trade for never leaving a dead registration behind.
|
|
588
590
|
await deactivateIde();
|
|
589
591
|
if (port === undefined) return false;
|
|
590
592
|
return activateIde(port);
|
|
@@ -593,13 +595,18 @@ export default {
|
|
|
593
595
|
// Serialize reconciles so overlapping callers can never register/activate
|
|
594
596
|
// the IDE concurrently (which would leak registrations).
|
|
595
597
|
let reconcileChain = Promise.resolve();
|
|
598
|
+
// Non-zero while a reconcile is queued or running.
|
|
599
|
+
let reconcileBusy = 0;
|
|
596
600
|
const enqueue = (task) => {
|
|
601
|
+
reconcileBusy += 1;
|
|
597
602
|
const next = reconcileChain.then(task);
|
|
598
603
|
reconcileChain = next.then(
|
|
599
604
|
() => {},
|
|
600
605
|
() => {},
|
|
601
606
|
);
|
|
602
|
-
return next
|
|
607
|
+
return next.finally(() => {
|
|
608
|
+
reconcileBusy -= 1;
|
|
609
|
+
});
|
|
603
610
|
};
|
|
604
611
|
const reconcile = (options) => enqueue(() => reconcileOnce(options));
|
|
605
612
|
|
|
@@ -744,50 +751,50 @@ export default {
|
|
|
744
751
|
: result;
|
|
745
752
|
};
|
|
746
753
|
|
|
747
|
-
// Setup never probes or connects the IDE — everything is on `/open-in-idea`.
|
|
748
|
-
await reconcile({ probeIde: false });
|
|
749
|
-
|
|
750
754
|
/**
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
755
|
+
* Coalesced background reconnect.
|
|
756
|
+
*
|
|
757
|
+
* The `context` hook latches the rebuild the moment a request shows the tools
|
|
758
|
+
* are gone, but a latch alone only fires on the NEXT user message — one whole
|
|
759
|
+
* turn later, which is exactly the gap that made a dead session sit there
|
|
760
|
+
* until `/open-in-idea` was run by hand. This runs out of band (it never
|
|
761
|
+
* blocks the request) so the next request — the following agent-loop step, or
|
|
762
|
+
* any later message — already carries the tools again.
|
|
763
|
+
*
|
|
764
|
+
* @returns {Promise<void>}
|
|
756
765
|
*/
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
766
|
+
/** @type {Promise<void> | undefined} */
|
|
767
|
+
let reviveInFlight;
|
|
768
|
+
const reviveIde = () => {
|
|
769
|
+
// The mark gates EVERY reconnect, not just the ones started from a user
|
|
770
|
+
// message: a background attempt must never resurrect the IDE for a project
|
|
771
|
+
// the user closed with `/close-in-idea`.
|
|
772
|
+
if (disposed || markCache !== true) return Promise.resolve();
|
|
773
|
+
if (reviveInFlight) return reviveInFlight;
|
|
774
|
+
const run = (async () => {
|
|
775
|
+
try {
|
|
776
|
+
const result = await runOpenInIdea();
|
|
777
|
+
// `/close-in-idea` can land while this was in flight; it wins.
|
|
778
|
+
if (markCache !== true) {
|
|
779
|
+
await deactivateIde();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (result.port !== undefined) {
|
|
783
|
+
needsRebuild = false;
|
|
784
|
+
}
|
|
785
|
+
} catch {
|
|
786
|
+
// best effort: the latch stays set, and the prompt hook retries
|
|
770
787
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
if (disposed || registrations.length === 0) return false;
|
|
778
|
-
await deactivateIde();
|
|
779
|
-
return true;
|
|
780
|
-
});
|
|
781
|
-
if (kicked) needsRebuild = true;
|
|
782
|
-
} finally {
|
|
783
|
-
heartbeatBusy = false;
|
|
784
|
-
}
|
|
788
|
+
})();
|
|
789
|
+
reviveInFlight = run;
|
|
790
|
+
void run.then(() => {
|
|
791
|
+
if (reviveInFlight === run) reviveInFlight = undefined;
|
|
792
|
+
});
|
|
793
|
+
return run;
|
|
785
794
|
};
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
}
|
|
789
|
-
// Never keep the host process alive just to probe (also keeps tests from hanging).
|
|
790
|
-
if (typeof heartbeatTimer?.unref === 'function') heartbeatTimer.unref();
|
|
795
|
+
|
|
796
|
+
// Setup never probes or connects the IDE — everything is on `/open-in-idea`.
|
|
797
|
+
await reconcile({ probeIde: false });
|
|
791
798
|
|
|
792
799
|
const sendFeedback = async (input, result) => {
|
|
793
800
|
if (feedback !== 'message' || typeof ctx.session?.prompt !== 'function') return false;
|
|
@@ -878,10 +885,10 @@ export default {
|
|
|
878
885
|
description: '取消当前项目的 IDEA 接入:注销 IDE MCP 并清除项目标记',
|
|
879
886
|
execute: async (input) => {
|
|
880
887
|
await setMark(false);
|
|
881
|
-
// Unregister the IDE MCP server
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
888
|
+
// Unregister the IDE MCP server. Reuses the serialized reconcile, so
|
|
889
|
+
// a concurrent rebuild (an in-flight `/open-in-idea`, or a
|
|
890
|
+
// request-time heal) cannot re-register the server after the mark is
|
|
891
|
+
// gone.
|
|
885
892
|
await reconcile({ probeIde: false });
|
|
886
893
|
await sendFeedback(input, { status: 'closed' });
|
|
887
894
|
},
|
|
@@ -902,9 +909,29 @@ export default {
|
|
|
902
909
|
if (typeof ctx.session?.hook === 'function') {
|
|
903
910
|
sessionRegistration = await ctx.session.hook('context', (event) => {
|
|
904
911
|
const tools = event?.tools;
|
|
905
|
-
const serving = tools !== undefined && Object.keys(tools).some((name) => name.startsWith(
|
|
906
|
-
if (serving)
|
|
907
|
-
|
|
912
|
+
const serving = tools !== undefined && Object.keys(tools).some((name) => name.startsWith(IDEA_TOOL_PREFIX));
|
|
913
|
+
if (serving) {
|
|
914
|
+
wasServing = true;
|
|
915
|
+
removeIdeaToolDefinitions(tools, HIDDEN_IDEA_TOOLS);
|
|
916
|
+
} else if (wasServing && tools !== undefined && markCache === true) {
|
|
917
|
+
// This session HAD the tools and this request does not. Latch a
|
|
918
|
+
// rebuild here, where "is the IDE serving THIS request?" is a fact: a
|
|
919
|
+
// failed reconnect can tear the registration down without anything else
|
|
920
|
+
// noticing. `wasServing` keeps this from firing for a project that
|
|
921
|
+
// never connected in this session — the `prompt` hook covers that case
|
|
922
|
+
// (a fresh instance has no registration at all) and setup deliberately
|
|
923
|
+
// does not connect on its own.
|
|
924
|
+
needsRebuild = true;
|
|
925
|
+
wasServing = false;
|
|
926
|
+
// Do not wait for the next user message. The latch is read by the
|
|
927
|
+
// `prompt` hook, which has ALREADY run for this request — so without
|
|
928
|
+
// this the session would stay tool-less for the rest of the turn and
|
|
929
|
+
// need one more message before anything happened.
|
|
930
|
+
void reviveIde();
|
|
931
|
+
}
|
|
932
|
+
if (markCache === true || serving) {
|
|
933
|
+
appendIdeRecoveryGuidance(event?.system, { marked: markCache === true, serving });
|
|
934
|
+
}
|
|
908
935
|
if (!injectGuidance) return;
|
|
909
936
|
// Guidance promises tools the model can call, so it only goes out when
|
|
910
937
|
// they are actually in this request.
|
|
@@ -912,12 +939,23 @@ export default {
|
|
|
912
939
|
appendIdeGuidance(event?.system, projectPath);
|
|
913
940
|
});
|
|
914
941
|
|
|
915
|
-
//
|
|
916
|
-
// `
|
|
917
|
-
//
|
|
918
|
-
//
|
|
942
|
+
// 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
|
|
943
|
+
// **本次请求**里就带上 `idea_*` 工具,没有"先回一句正在重连、下一条才行"
|
|
944
|
+
// 的空窗。
|
|
945
|
+
//
|
|
946
|
+
// 两种"需要重连"的形状都从这里进:
|
|
947
|
+
// * `needsRebuild` — 之前的请求已经证明工具没了;
|
|
948
|
+
// * `registrations.length === 0` — 本插件实例压根没有 `idea` 服务器,这
|
|
949
|
+
// 正是 OpenCode 回收 Location 服务(LayerMap idleTimeToLive = 60 分钟)
|
|
950
|
+
// 或进程重启后新实例的样子。setup 故意不连接,没有这一步会话就会永久
|
|
951
|
+
// 没有工具,只能靠用户手敲 `/open-in-idea`。
|
|
952
|
+
//
|
|
953
|
+
// 只探测、不启动 IDE:走 `reconcile({ probeIde: true })`,而不是会 `open -a`
|
|
954
|
+
// 的 `reviveIde()` —— 一条用户消息不该把 IDEA 拉起来。
|
|
919
955
|
promptRegistration = await ctx.session.hook('prompt', async (event) => {
|
|
920
|
-
if (disposed ||
|
|
956
|
+
if (disposed || markCache !== true) return;
|
|
957
|
+
const fresh = registrations.length === 0;
|
|
958
|
+
if (!needsRebuild && !fresh) return;
|
|
921
959
|
// Never react to the plugin's own notices, or recovery would feed on
|
|
922
960
|
// itself. Match the notice TEXT, never `metadata.displayText`: OpenCode
|
|
923
961
|
// also sets `displayText` on ordinary user messages (it is the message
|
|
@@ -925,11 +963,12 @@ export default {
|
|
|
925
963
|
// — verified live, and it made this rebuild never run.
|
|
926
964
|
const text = event?.prompt?.text;
|
|
927
965
|
if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
966
|
+
try {
|
|
967
|
+
await reconcile({ probeIde: true });
|
|
968
|
+
} catch {
|
|
969
|
+
// best effort: the latch stays set and the next message retries
|
|
932
970
|
}
|
|
971
|
+
if (registrations.length > 0) needsRebuild = false;
|
|
933
972
|
});
|
|
934
973
|
|
|
935
974
|
// The only self-initiated action: when an `idea_*` call fails in a way
|
|
@@ -960,7 +999,6 @@ export default {
|
|
|
960
999
|
|
|
961
1000
|
return async () => {
|
|
962
1001
|
disposed = true;
|
|
963
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
964
1002
|
if (sessionRegistration) {
|
|
965
1003
|
try {
|
|
966
1004
|
await sessionRegistration.dispose();
|