opencode-idea 0.0.12 → 0.0.14
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 +38 -12
- package/package.json +1 -1
- package/skills/idea-standalone/SKILL.md +93 -0
- package/src/ide-guidance.js +30 -11
- package/src/mcp/idea.js +2 -2
- package/src/plugin.js +155 -24
package/README.md
CHANGED
|
@@ -48,10 +48,12 @@ setup(启动, 每项目一次):
|
|
|
48
48
|
2. 触发成功 → 把该错误改写为"已重连 / 已重新打开,请重试",模型同一回合重试;失败 → 保持原错误
|
|
49
49
|
|
|
50
50
|
后台探活(心跳;仅"已标记 + 桥已注册"的项目):
|
|
51
|
-
1. 每 5s
|
|
51
|
+
1. 每 5s 探测一次 IDE 端口(/stream);探一次不通 →
|
|
52
52
|
2. **注销 `idea` MCP**:杀掉僵尸桥子进程 + 从注册表移除 `idea_*` 工具
|
|
53
53
|
(→ 模型调不到,也就不会挂起;这一步是"从根上避免挂起"的关键)
|
|
54
|
-
3. 锁存 `needRebuild = true`(不会因端口恢复而清,因为桥不会自愈)
|
|
54
|
+
3. 锁存 `needRebuild = true`(不会因端口恢复而清,因为桥不会自愈);
|
|
55
|
+
同理,任一请求若发现「本会话曾有工具、本请求没有」也置同一锁存——
|
|
56
|
+
端口还在答、只是桥子进程死了这类情况,心跳看不到
|
|
55
57
|
4. 下一条用户消息:纯内存读到锁存 → 自动重跑接入流程(IDE 不在则 `open -a` 拉起),成功后清锁存
|
|
56
58
|
—— 全程静默,用户无感
|
|
57
59
|
※ 这是**每个 Location(项目)各跑一份**探活;IDE 只有一个,所以所有项目的桥几乎同时被注销、再各自重建
|
|
@@ -124,14 +126,33 @@ IDE MCP 只能 **查**(`get_run_configurations`)和 **执行**(`execute_run_conf
|
|
|
124
126
|
`setup` 时(读回标记)就会注册。未标记的项目不受影响。文档放在 `skills/<id>/SKILL.md`(每个 skill 一个目录,`name` /
|
|
125
127
|
`description` 走 YAML frontmatter),由 `src/skill/loader.js` 扫描加载 —— **加新 skill 只需加一个目录,不用改代码**。
|
|
126
128
|
|
|
129
|
+
## Standalone 模式纪律 (skill)
|
|
130
|
+
|
|
131
|
+
本机直跑应用的**项目级纪律**由 `idea-standalone` skill 固化:它为项目生成/维护一份 rule,落在项目根
|
|
132
|
+
**`.opencode/rules/idea-standalone.md`**,四段结构固定 ——
|
|
133
|
+
|
|
134
|
+
1. **什么是 standalone 模式**:**standalone = 通过 IDEA 启动**(每个应用的入口是 IDEA run configuration),应用在本机进程
|
|
135
|
+
直跑(不容器化、不部署),只启动其中一部分是常态;启停一律由用户在 IDEA 里手动完成,AI 不得代为启停(含经
|
|
136
|
+
`idea_execute_run_configuration`),判断需要重启时先取得用户明确授权;
|
|
137
|
+
2. **启动哪些应用**:一行一个应用,启动路径**只能是 IDEA run configuration**(配置名必须能在 `idea_get_run_configurations`
|
|
138
|
+
里查到;缺配置先走 `idea-run-config` skill 建 `.run/*.run.xml`),端口与被依赖的先后顺序一并写明;
|
|
139
|
+
3. **热重载矩阵**:逐应用标"是 / 否" —— 带 HMR 的 dev server 改动后**不用**提示重启;`go run .` 这类无热重载的进程改动后
|
|
140
|
+
**必须**显式点名「需要重启 <应用>」,不得在未提示的情况下宣称"改完可验证";
|
|
141
|
+
4. **项目补充条款**:文件写入位置、测试纪律、访问归属、dev 模式特有限制等,按用户给的条款逐条续写、保留原意。
|
|
142
|
+
|
|
143
|
+
事实一律先查后写:配置名来自 `idea_get_run_configurations`,热重载列来自运行方式,端口读不到就问用户 —— 不猜。
|
|
144
|
+
|
|
127
145
|
## 项目命令执行 (IDE 终端)
|
|
128
146
|
|
|
129
|
-
跑项目相关的命令时,模型使用 **`idea_execute_terminal_command`**,而不是 OpenCode 原生 shell
|
|
130
|
-
|
|
147
|
+
跑项目相关的命令时,模型使用 **`idea_execute_terminal_command`**,而不是 OpenCode 原生 shell。判据是**白名单**:命令用于本项目
|
|
148
|
+
自身的构建 / 测试 / 运行,且首个可执行文件命中下表,才走 IDE 终端;其余一律走原生 shell。
|
|
131
149
|
|
|
132
|
-
-
|
|
133
|
-
|
|
134
|
-
-
|
|
150
|
+
- **Node / 前端**(`package.json`):`node` / `npm` / `npx` / `pnpm` / `yarn` / `vite` / `webpack` / `esbuild` / `tsc` /
|
|
151
|
+
`jest` / `vitest`。
|
|
152
|
+
- **Java**(`pom.xml` / `build.gradle`):`java` / `javac` / `mvn` / `mvnw` / `gradle` / `gradlew`。
|
|
153
|
+
- **Python**(`pyproject.toml` / `requirements.txt`):`python` / `python3` / `pip` / `pip3` / `uv` / `poetry` / `pytest`。
|
|
154
|
+
- **Go**(`go.mod`):`go` / `gofmt` / `golangci-lint`。
|
|
155
|
+
- 白名单未覆盖的语言暂走原生 shell,遇到后再补进上表。
|
|
135
156
|
|
|
136
157
|
原因:
|
|
137
158
|
|
|
@@ -253,7 +274,7 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
253
274
|
"mcpProbeTimeoutMs": 1000, // 单个 MCP 端口的短探测超时
|
|
254
275
|
"executionTimeoutMs": 600000, // 单次 idea_* 工具调用硬上限;默认 10 分钟(OpenCode 默认 12 小时)
|
|
255
276
|
"heartbeatIntervalMs": 5000, // 后台端口探活间隔
|
|
256
|
-
"heartbeatFailures":
|
|
277
|
+
"heartbeatFailures": 1, // 探活失败几次后注销 idea MCP(默认一次即注销)
|
|
257
278
|
"feedback": "message", // "message"(默认,会话通知,AI 只回"收到")| false(静默)
|
|
258
279
|
},
|
|
259
280
|
},
|
|
@@ -275,7 +296,7 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
275
296
|
| `mcpProbeTimeoutMs` | `number` | `1000` | 单个 MCP 端口的短探测超时 |
|
|
276
297
|
| `executionTimeoutMs` | `number` | `600000` | 单次 `idea_*` 工具调用的硬上限(毫秒);防止桥挂起后无限等待(OpenCode 默认 12 小时) |
|
|
277
298
|
| `heartbeatIntervalMs` | `number` | `5000` | 后台 IDE 端口探活间隔(毫秒);仅"已标记 + 桥已注册"的项目有效 |
|
|
278
|
-
| `heartbeatFailures` | `number` | `
|
|
299
|
+
| `heartbeatFailures` | `number` | `1` | 探活失败几次后注销 `idea` MCP(杀僵尸桥 + 移除工具),下一条消息自动重建 |
|
|
279
300
|
| `feedback` | `"message" \| false` | `"message"` | 结果反馈:`message` 会话通知(用户可见,AI 只回"收到");`false` 静默 |
|
|
280
301
|
|
|
281
302
|
## 行为
|
|
@@ -288,9 +309,13 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
288
309
|
僵尸桥只要重跑一次命令就能恢复。命令返回即可用;`/close-in-idea` 注销 `idea` + 删标记 + 清环境。
|
|
289
310
|
- **`context` 钩子是请求的纯函数**:按「每次模型调用」触发 (含工具续跑),只看本次请求自带的工具快照 —— 有 `idea_*` 就剥离隐藏
|
|
290
311
|
工具并注入引导;没有则只在项目被标记时给恢复提示。该钩子本身**不做任何 I/O**(只看内存缓存),也不订阅任何事件。
|
|
291
|
-
- **后台心跳(仅"已标记 + 桥已注册"的项目)**:每 `heartbeatIntervalMs`(默认 5s)探一次 IDE
|
|
292
|
-
(默认
|
|
312
|
+
- **后台心跳(仅"已标记 + 桥已注册"的项目)**:每 `heartbeatIntervalMs`(默认 5s)探一次 IDE 端口;`heartbeatFailures`
|
|
313
|
+
(默认 1)次不通就**注销 `idea` MCP** —— 杀掉僵尸桥子进程 + 移除 `idea_*` 工具,于是模型**调不到、也就不会挂起** —— 并锁存
|
|
293
314
|
`needRebuild`。下一条用户消息在 `prompt` 钩子里读到锁存 → 自动重跑接入流程(IDE 不在则 `open -a` 拉起),成功后清锁存。**全程静默**。
|
|
315
|
+
锁存还有**第二个来源**:`context` 钩子(每个 agent-loop step 都跑)发现「本会话见过 `idea_*` 工具、本请求却没有」也置位 ——
|
|
316
|
+
心跳只认"端口不通",而桥子进程自己死了、或一次失败的重连把注册拆掉(`reconcileOnce` 先拆后建,建不起来就只剩拆)时端口可能还是通的,
|
|
317
|
+
这两种情况以前**不会置锁存**,于是下一条消息什么都不做,只能手动 `/open-in-idea`。工具缺失时注入的恢复引导也改为**明确禁止**
|
|
318
|
+
调用 `idea_*`(此前复用"重试一次"的文案,反而诱导模型去调一个不在工具表里的工具,得到 `No tool named ... available`)。
|
|
294
319
|
探活是**每个 Location(项目)各一份**;IDE 只有一个,所以 IDE 一挂、所有项目的桥几乎同时被注销,再由各自的消息触发重建。
|
|
295
320
|
- **失败触发的自动接入(仅被标记项目)**:`tool.execute.after` 钩子监听 `idea_*` 工具报错并**分类**:
|
|
296
321
|
- IDE MCP 不可用(掉了)→ 重跑 `/open-in-idea` 的接入流程;
|
|
@@ -327,7 +352,8 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
327
352
|
以及**调用进行中**IDE 挂(在 IDE 里跑 `sleep 120` 时 `kill` 掉 IDEA),桥都只在 stderr 打一行错误(`SseClientTransport is closed!` /
|
|
328
353
|
`Request timeout has expired`),**stdout 一个字节都不回**。而且**重开 IDEA 也不会自愈**(实测:重开 IDEA 后连续探测 5 分钟仍全部 TIMEOUT);
|
|
329
354
|
OpenCode 依旧显示 connected,`refresh`/`reload` 只会 `reconcile`,配置没变就跳过,不会重启它。所以:
|
|
330
|
-
- **后台心跳(见上)自动兜底**:5s
|
|
355
|
+
- **后台心跳(见上)自动兜底**:5s 探活、探一次不通就注销 `idea`(杀僵尸桥 + 移除工具);此外任一请求发现工具缺失也会置锁存,
|
|
356
|
+
两种情况都是下一条消息自动重建 —— 常态下**不需要用户手动做什么**;
|
|
331
357
|
- 手动重建仍可用:重开 IDEA 后再执行一次 `/open-in-idea`(先注销旧 `idea`、再重新注册);
|
|
332
358
|
- 插件给 `idea` server 配 `executionTimeoutMs`(默认 **10 分钟**)作为硬上限:兜住"心跳尚未触发、工具已在途"的那次挂起,不再沿用 OpenCode 默认的 12 小时;
|
|
333
359
|
- "IDE 被关掉 → 工具挂起"这类**不会**走工具失败分类(没有 error);能自动分类处理的是"IDE 在、项目没打开"和"server 掉了(启动即失败)"。
|
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,15 +61,15 @@ 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
|
-
` -
|
|
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\`。`,
|
|
70
|
+
` - \`executeInShell: true\`:在用户真实 shell 里执行,继承 IDE 配好的运行时环境;原生 shell 可能用错版本。`,
|
|
70
71
|
` - 按预期耗时传 \`timeout\`(毫秒),长任务给足;必要时传 \`maxLinesCount\` 控制返回行数。`,
|
|
71
|
-
` - 连续命令传 \`reuseExistingTerminalWindow: true
|
|
72
|
-
` - 结果带 \`command_exit_code\` 与 \`command_output\`;\`cd\` 不跨调用保留,多步时在一条命令里用 \`&&\`。`,
|
|
72
|
+
` - 连续命令传 \`reuseExistingTerminalWindow: true\`;结果带 \`command_exit_code\` 与 \`command_output\`,\`cd\` 不跨调用保留,多步用 \`&&\`。`,
|
|
73
73
|
``,
|
|
74
74
|
`### 数据库 (数据源由用户在 IDE 里配置:不要自己建/改,不要向用户索取数据库凭据)`,
|
|
75
75
|
`- \`idea_list_database_connections\`:先看有没有现成数据源;每条给 \`id\` / \`dbms\` / \`jdbcUrl\` / \`readOnly\` / \`isDDL\`。`,
|
|
@@ -103,6 +103,19 @@ export const MCP_RECOVERY_GUIDANCE_MARKED = [
|
|
|
103
103
|
'This project is IDEA-connected and the plugin reconnects the IDE MCP automatically. When an idea_* call fails with a connection error (for example `MCP server "idea" is not available`), do not run extra probes or wait in tool calls: the reconnect is already under way, so retry the operation once. Only if it still fails, tell the user that IDEA MCP is unavailable and to run /open-in-idea.',
|
|
104
104
|
].join('\n');
|
|
105
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
|
+
|
|
106
119
|
/**
|
|
107
120
|
* Push the guidance onto a session `system` array. Called for every primary
|
|
108
121
|
* model request, so it stays idempotent per request.
|
|
@@ -120,11 +133,17 @@ export function appendIdeGuidance(system, projectPath) {
|
|
|
120
133
|
|
|
121
134
|
/**
|
|
122
135
|
* @param {unknown} system
|
|
123
|
-
* @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.
|
|
124
139
|
*/
|
|
125
|
-
export function appendIdeRecoveryGuidance(system, { marked = false } = {}) {
|
|
140
|
+
export function appendIdeRecoveryGuidance(system, { marked = false, serving = true } = {}) {
|
|
126
141
|
if (!Array.isArray(system)) return;
|
|
127
|
-
const text = marked
|
|
142
|
+
const text = marked
|
|
143
|
+
? serving
|
|
144
|
+
? MCP_RECOVERY_GUIDANCE_MARKED
|
|
145
|
+
: MCP_RECOVERY_GUIDANCE_RECONNECTING
|
|
146
|
+
: MCP_RECOVERY_GUIDANCE;
|
|
128
147
|
if (system.some((part) => part?.text === text)) return;
|
|
129
148
|
system.push({ type: 'text', text });
|
|
130
149
|
}
|
package/src/mcp/idea.js
CHANGED
|
@@ -146,7 +146,7 @@ export async function probePort(port, options = {}) {
|
|
|
146
146
|
params: {
|
|
147
147
|
protocolVersion: PROTOCOL_VERSION,
|
|
148
148
|
capabilities: {},
|
|
149
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
149
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.14' },
|
|
150
150
|
},
|
|
151
151
|
}),
|
|
152
152
|
signal: controller.signal,
|
|
@@ -217,7 +217,7 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
|
|
|
217
217
|
params: {
|
|
218
218
|
protocolVersion: PROTOCOL_VERSION,
|
|
219
219
|
capabilities: {},
|
|
220
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
220
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.14' },
|
|
221
221
|
},
|
|
222
222
|
});
|
|
223
223
|
// The IDE's Streamable-HTTP endpoint binds follow-up requests to the session
|
package/src/plugin.js
CHANGED
|
@@ -78,13 +78,29 @@ const TOOL_REFRESH_POLL_MS = 25;
|
|
|
78
78
|
const DEFAULT_EXECUTION_TIMEOUT_MS = 600000;
|
|
79
79
|
// Background IDE liveness probe. The stdio bridge NEVER reports a dropped IDE
|
|
80
80
|
// connection (it just hangs), so the plugin watches the IDE endpoint itself.
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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).
|
|
85
89
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
|
|
86
|
-
const DEFAULT_HEARTBEAT_FAILURES =
|
|
87
|
-
|
|
90
|
+
const DEFAULT_HEARTBEAT_FAILURES = 1;
|
|
91
|
+
// Every IDE tool comes from the MCP server the plugin registers as `idea`, and
|
|
92
|
+
// OpenCode namespaces a server's tools with its name — so `idea_*` IS the whole
|
|
93
|
+
// set, the same predicate the request hooks use. "Ready" means at least one of
|
|
94
|
+
// them is listed: OpenCode publishes an MCP catalog wholesale (one reload), so
|
|
95
|
+
// one tool means the batch landed.
|
|
96
|
+
//
|
|
97
|
+
// This used to be a hard-coded pair (`idea_read_file` + `idea_apply_patch`),
|
|
98
|
+
// which is not a property the plugin may assume: the IDE can rename a tool, and
|
|
99
|
+
// the user can switch one off on the IDE's Exposed Tools page. Either one made
|
|
100
|
+
// the wait unsatisfiable, so every `/open-in-idea` and every auto-rebuild burned
|
|
101
|
+
// the full `toolRefreshTimeoutMs` and still reported `tools-loading` — telling
|
|
102
|
+
// the user to re-run a command that could never succeed.
|
|
103
|
+
const IDEA_TOOL_PREFIX = 'idea_';
|
|
88
104
|
|
|
89
105
|
/**
|
|
90
106
|
* IDEA MCP tools that must never reach the model.
|
|
@@ -459,8 +475,14 @@ export default {
|
|
|
459
475
|
let heartbeatMisses = 0;
|
|
460
476
|
// Latches when the IDE was seen down and the server was deactivated. It is
|
|
461
477
|
// cleared ONLY by a successful rebuild — never by the endpoint coming back —
|
|
462
|
-
// because the bridge does not heal on its own.
|
|
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).
|
|
463
481
|
let needsRebuild = false;
|
|
482
|
+
// Set once a request in this session actually carried `idea_*` tools, so a
|
|
483
|
+
// later request without them is a DROP of this session's own connection —
|
|
484
|
+
// not a project that simply never connected.
|
|
485
|
+
let wasServing = false;
|
|
464
486
|
|
|
465
487
|
// Project-level mark: has the user opted this project into IDEA via
|
|
466
488
|
// `/open-in-idea`? One plugin instance serves one project (OpenCode
|
|
@@ -534,7 +556,7 @@ export default {
|
|
|
534
556
|
while (Date.now() < deadline) {
|
|
535
557
|
try {
|
|
536
558
|
const ids = toolIds(await ctx.tool.list());
|
|
537
|
-
if (
|
|
559
|
+
if (ids.some((id) => id.startsWith(IDEA_TOOL_PREFIX))) return true;
|
|
538
560
|
} catch {
|
|
539
561
|
// The MCP catalog may still be settling; retry within the bounded wait.
|
|
540
562
|
}
|
|
@@ -543,6 +565,27 @@ export default {
|
|
|
543
565
|
return false;
|
|
544
566
|
};
|
|
545
567
|
|
|
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
|
+
|
|
546
589
|
const refreshIdeTools = async () => {
|
|
547
590
|
if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
|
|
548
591
|
// Reconcile the MCP servers above, then replay the tool registry so the
|
|
@@ -593,13 +636,20 @@ export default {
|
|
|
593
636
|
// Serialize reconciles so overlapping callers can never register/activate
|
|
594
637
|
// the IDE concurrently (which would leak registrations).
|
|
595
638
|
let reconcileChain = Promise.resolve();
|
|
639
|
+
// Non-zero while a reconcile is queued or running. A rebuild tears the
|
|
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.
|
|
642
|
+
let reconcileBusy = 0;
|
|
596
643
|
const enqueue = (task) => {
|
|
644
|
+
reconcileBusy += 1;
|
|
597
645
|
const next = reconcileChain.then(task);
|
|
598
646
|
reconcileChain = next.then(
|
|
599
647
|
() => {},
|
|
600
648
|
() => {},
|
|
601
649
|
);
|
|
602
|
-
return next
|
|
650
|
+
return next.finally(() => {
|
|
651
|
+
reconcileBusy -= 1;
|
|
652
|
+
});
|
|
603
653
|
};
|
|
604
654
|
const reconcile = (options) => enqueue(() => reconcileOnce(options));
|
|
605
655
|
|
|
@@ -744,6 +794,49 @@ export default {
|
|
|
744
794
|
: result;
|
|
745
795
|
};
|
|
746
796
|
|
|
797
|
+
/**
|
|
798
|
+
* Coalesced background reconnect.
|
|
799
|
+
*
|
|
800
|
+
* The `context` hook latches the rebuild the moment a request shows the tools
|
|
801
|
+
* are gone, but a latch alone only fires on the NEXT user message — one whole
|
|
802
|
+
* turn later, which is exactly the gap that made a dead session sit there
|
|
803
|
+
* until `/open-in-idea` was run by hand. This runs out of band (it never
|
|
804
|
+
* blocks the request) so the next request — the following agent-loop step, or
|
|
805
|
+
* any later message — already carries the tools again.
|
|
806
|
+
*
|
|
807
|
+
* @returns {Promise<void>}
|
|
808
|
+
*/
|
|
809
|
+
/** @type {Promise<void> | undefined} */
|
|
810
|
+
let reviveInFlight;
|
|
811
|
+
const reviveIde = () => {
|
|
812
|
+
// The mark gates EVERY reconnect, not just the ones started from a user
|
|
813
|
+
// message: a background attempt must never resurrect the IDE for a project
|
|
814
|
+
// the user closed with `/close-in-idea`.
|
|
815
|
+
if (disposed || markCache !== true) return Promise.resolve();
|
|
816
|
+
if (reviveInFlight) return reviveInFlight;
|
|
817
|
+
const run = (async () => {
|
|
818
|
+
try {
|
|
819
|
+
const result = await runOpenInIdea();
|
|
820
|
+
// `/close-in-idea` can land while this was in flight; it wins.
|
|
821
|
+
if (markCache !== true) {
|
|
822
|
+
await deactivateIde();
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (result.port !== undefined) {
|
|
826
|
+
needsRebuild = false;
|
|
827
|
+
heartbeatMisses = 0;
|
|
828
|
+
}
|
|
829
|
+
} catch {
|
|
830
|
+
// best effort: the latch stays set, and the prompt hook retries
|
|
831
|
+
}
|
|
832
|
+
})();
|
|
833
|
+
reviveInFlight = run;
|
|
834
|
+
void run.then(() => {
|
|
835
|
+
if (reviveInFlight === run) reviveInFlight = undefined;
|
|
836
|
+
});
|
|
837
|
+
return run;
|
|
838
|
+
};
|
|
839
|
+
|
|
747
840
|
// Setup never probes or connects the IDE — everything is on `/open-in-idea`.
|
|
748
841
|
await reconcile({ probeIde: false });
|
|
749
842
|
|
|
@@ -764,21 +857,29 @@ export default {
|
|
|
764
857
|
heartbeatBusy = true;
|
|
765
858
|
try {
|
|
766
859
|
const port = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
|
|
767
|
-
|
|
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()))) {
|
|
768
865
|
heartbeatMisses = 0;
|
|
769
866
|
return;
|
|
770
867
|
}
|
|
771
868
|
heartbeatMisses += 1;
|
|
772
869
|
if (heartbeatMisses < heartbeatFailures) return;
|
|
773
|
-
//
|
|
774
|
-
// removes the `idea_*` tools, so the model cannot call into a dead
|
|
775
|
-
// pipe. Latch a rebuild for the next user message.
|
|
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.
|
|
776
872
|
const kicked = await enqueue(async () => {
|
|
777
873
|
if (disposed || registrations.length === 0) return false;
|
|
778
874
|
await deactivateIde();
|
|
779
875
|
return true;
|
|
780
876
|
});
|
|
781
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();
|
|
782
883
|
} finally {
|
|
783
884
|
heartbeatBusy = false;
|
|
784
885
|
}
|
|
@@ -903,8 +1004,32 @@ export default {
|
|
|
903
1004
|
sessionRegistration = await ctx.session.hook('context', (event) => {
|
|
904
1005
|
const tools = event?.tools;
|
|
905
1006
|
const serving = tools !== undefined && Object.keys(tools).some((name) => name.startsWith('idea_'));
|
|
906
|
-
if (serving)
|
|
907
|
-
|
|
1007
|
+
if (serving) {
|
|
1008
|
+
wasServing = true;
|
|
1009
|
+
removeIdeaToolDefinitions(tools, HIDDEN_IDEA_TOOLS);
|
|
1010
|
+
} else if (wasServing && tools !== undefined && markCache === true) {
|
|
1011
|
+
// This session HAD the tools and this request does not. The heartbeat
|
|
1012
|
+
// alone cannot catch every way that happens: a bridge child can die
|
|
1013
|
+
// while the IDE port keeps answering, and a failed reconnect tears the
|
|
1014
|
+
// registration down WITHOUT latching anything (`reconcileOnce` tears
|
|
1015
|
+
// down first and then finds no port, so `kicked` never runs). Without
|
|
1016
|
+
// a latch the `prompt` hook has nothing to fire on, so the next
|
|
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
|
|
1021
|
+
// does not connect on its own.
|
|
1022
|
+
needsRebuild = true;
|
|
1023
|
+
wasServing = false;
|
|
1024
|
+
// Do not wait for the next user message. The latch is read by the
|
|
1025
|
+
// `prompt` hook, which has ALREADY run for this request — so without
|
|
1026
|
+
// this the session would stay tool-less for the rest of the turn and
|
|
1027
|
+
// need one more message before anything happened.
|
|
1028
|
+
void reviveIde();
|
|
1029
|
+
}
|
|
1030
|
+
if (markCache === true || serving) {
|
|
1031
|
+
appendIdeRecoveryGuidance(event?.system, { marked: markCache === true, serving });
|
|
1032
|
+
}
|
|
908
1033
|
if (!injectGuidance) return;
|
|
909
1034
|
// Guidance promises tools the model can call, so it only goes out when
|
|
910
1035
|
// they are actually in this request.
|
|
@@ -912,10 +1037,12 @@ export default {
|
|
|
912
1037
|
appendIdeGuidance(event?.system, projectPath);
|
|
913
1038
|
});
|
|
914
1039
|
|
|
915
|
-
// Rebuild after a
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
1040
|
+
// Rebuild after a detected drop — the heartbeat (dead port) or the context
|
|
1041
|
+
// hook above (tools gone from a request) latches `needsRebuild`. Reading
|
|
1042
|
+
// only that in-memory latch keeps this a cheap no-op on every other
|
|
1043
|
+
// message. OpenCode awaits `prompt` before the request is built, so when
|
|
1044
|
+
// the IDE is back, the message that triggers this already carries working
|
|
1045
|
+
// tools.
|
|
919
1046
|
promptRegistration = await ctx.session.hook('prompt', async (event) => {
|
|
920
1047
|
if (disposed || !needsRebuild || markCache !== true) return;
|
|
921
1048
|
// Never react to the plugin's own notices, or recovery would feed on
|
|
@@ -925,11 +1052,15 @@ export default {
|
|
|
925
1052
|
// — verified live, and it made this rebuild never run.
|
|
926
1053
|
const text = event?.prompt?.text;
|
|
927
1054
|
if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1055
|
+
// Awaited on purpose: the message that triggers this already carries
|
|
1056
|
+
// working tools. A background attempt already in flight is joined — but
|
|
1057
|
+
// joining is not trying: if that attempt had already failed, this message
|
|
1058
|
+
// would be swallowed and would carry no tools, which is exactly the bug
|
|
1059
|
+
// this hook exists to prevent. So when we joined one and the latch is
|
|
1060
|
+
// still set, this message gets an attempt of its own.
|
|
1061
|
+
const joined = reviveInFlight !== undefined;
|
|
1062
|
+
await reviveIde();
|
|
1063
|
+
if (joined && needsRebuild) await reviveIde();
|
|
933
1064
|
});
|
|
934
1065
|
|
|
935
1066
|
// The only self-initiated action: when an `idea_*` call fails in a way
|