opencode-idea 0.0.9 → 0.0.10
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 +50 -18
- package/package.json +1 -1
- package/src/mcp/idea.js +2 -2
- package/src/plugin.js +258 -43
- package/src/project.js +0 -15
package/README.md
CHANGED
|
@@ -8,18 +8,21 @@ OpenCode 插件,面向 JetBrains 项目 (IntelliJ IDEA / PyCharm / WebStorm 等)
|
|
|
8
8
|
(正常回退,无需声明)。
|
|
9
9
|
3. **注入 IDE 环境变量** —— 从 IDE 集成终端读取它实际运行的环境,注入每个 OpenCode shell。
|
|
10
10
|
|
|
11
|
-
非 JetBrains 项目完全不受影响。
|
|
12
|
-
|
|
11
|
+
非 JetBrains 项目完全不受影响。 **手动接入**:插件启动不探测、不连接 IDE,只有跑 `/open-in-idea` 才接入。跑过一次后,该**项目**
|
|
12
|
+
被「标记」(持久化在 `ctx.storage`,重启 OpenCode 后仍在):之后每条用户消息都默认 IDE 仍在,一旦发现 IDE MCP 未连接,插件会在
|
|
13
|
+
构建本次请求前自动重跑一遍接入流程并按需补一条通知。`/close-in-idea` 删除该标记。没跑过 `/open-in-idea` 的项目保持全手动、零自主动作。
|
|
13
14
|
|
|
14
15
|
## 工作原理
|
|
15
16
|
|
|
16
17
|
```text
|
|
17
|
-
setup(
|
|
18
|
-
a.
|
|
19
|
-
b.
|
|
18
|
+
setup(启动, 每项目一次):
|
|
19
|
+
a. 读项目标记(ctx.storage: project/<项目路径>; OpenCode 重启后仍生效)
|
|
20
|
+
b. 读 IDE 集成终端环境 → 通过 shell create.before 钩子注入每个 shell(null: 未连 IDE 时为空)
|
|
21
|
+
c. 注册 /open-in-idea 与 /close-in-idea 命令 + session context/prompt 钩子
|
|
20
22
|
不探测、不注册 MCP
|
|
21
23
|
|
|
22
24
|
/open-in-idea(手动触发):
|
|
25
|
+
0. 写项目标记 = true(持久化)+ 清内存缓存(下次读取者回源)
|
|
23
26
|
1. 探测本地 JetBrains IDE MCP 服务(/stream)
|
|
24
27
|
2. 已就绪 → 直接连接
|
|
25
28
|
3. 未就绪 → open -a "IntelliJ IDEA" <项目目录>(复用已运行实例),然后立即提示:
|
|
@@ -30,6 +33,16 @@ setup(启动):
|
|
|
30
33
|
b. 读 IDE 终端环境 → 更新注入每个 shell 的环境变量
|
|
31
34
|
c. 启用系统提示引导(直接调用 `idea_*` 工具)+ IDEA MCP 工具描述前缀
|
|
32
35
|
6. 发一条结果通知(用户看到干净气泡,模型只回"收到")
|
|
36
|
+
|
|
37
|
+
/close-in-idea(手动触发):
|
|
38
|
+
1. 删项目标记 + 清内存缓存
|
|
39
|
+
2. 发一条结果通知(不改动已建立的连接)
|
|
40
|
+
|
|
41
|
+
后续用户消息(仅在被标记的项目里):
|
|
42
|
+
1. 项目已标记 → 默认 IDE 仍在线
|
|
43
|
+
2. 查 OpenCode 里 `idea` MCP server 状态;不是 connected/pending(真断了)→ 在 prompt 钩子里同步重跑上面的接入流程
|
|
44
|
+
3. 成功 → 本条消息即可用 `idea_*` 工具,并补一条"已自动重新连接"通知(排在用户消息之后)
|
|
45
|
+
4. 失败 → 静默,沿用 fast-fail 恢复提示(不会每条消息都重复拉起 IDE)
|
|
33
46
|
```
|
|
34
47
|
|
|
35
48
|
## 在 IDE 中打开项目 (`/open-in-idea`)
|
|
@@ -53,6 +66,11 @@ Brave Mode 后再次执行 `/open-in-idea` 即可接上。
|
|
|
53
66
|
**热更新会断开**:插件源码变更会触发 V2 热重载,注册随旧实例一起释放,`activePort` 归零。这是手动模式的预期行为 ——
|
|
54
67
|
**改完插件后重跑一次 `/open-in-idea`** 即可恢复。
|
|
55
68
|
|
|
69
|
+
## 关闭接入 (`/close-in-idea`)
|
|
70
|
+
|
|
71
|
+
输入 `/close-in-idea`:删除当前项目的接入标记(持久化 + 内存缓存一并清),之后 IDE MCP 即使断开也**不再自动重连**。命令只删标记,
|
|
72
|
+
**不主动断开已建立的连接**——当前已注册的工具会保留到连接自然中断为止。想重新接入,再次执行 `/open-in-idea`。
|
|
73
|
+
|
|
56
74
|
## IDE 工具优先 (直接调用引导)
|
|
57
75
|
|
|
58
76
|
IDE MCP 可用时,插件通过 **系统提示注入**提高模型主动使用 IDEA MCP 的概率;不修改任何工具 (原生或 IDEA)
|
|
@@ -78,7 +96,7 @@ IDE MCP 可用时,插件通过 **系统提示注入**提高模型主动使用 ID
|
|
|
78
96
|
## Run Configuration 管理 (skill)
|
|
79
97
|
|
|
80
98
|
IDE MCP 只能 **查**(`get_run_configurations`)和 **执行**(`execute_run_configuration`)run configuration, **没有增删改**
|
|
81
|
-
|
|
99
|
+
。插件在**被标记的项目**里注册一个 `idea-run-config` skill 来补上这块:
|
|
82
100
|
|
|
83
101
|
- 只操作 **项目级** `.run/<name>.run.xml`(可提交 git、团队共享), **不碰** `.idea/workspace.xml`;
|
|
84
102
|
- 不内置任何按类型的模板 —— 先找项目里的 **真实样本**(`**/*.run.xml`,或让用户在 IDEA 里勾 "Store as project file"
|
|
@@ -86,9 +104,9 @@ IDE MCP 只能 **查**(`get_run_configurations`)和 **执行**(`execute_run_conf
|
|
|
86
104
|
- 写完必须用 `idea_get_run_configurations` **校验它能被列出**、并用 `idea_execute_run_configuration` **实跑确认**;
|
|
87
105
|
- 改配置用 `idea_apply_patch` **定向改 option**,不整文件重写。
|
|
88
106
|
|
|
89
|
-
skill
|
|
90
|
-
`skills/<id>/SKILL.md`(每个 skill 一个目录,`name` /
|
|
91
|
-
**加新 skill 只需加一个目录,不用改代码**。
|
|
107
|
+
**skill 的生命周期跟随项目标记**:`/open-in-idea` 打标记时注册、`/close-in-idea` 清标记时注销;曾被标记过的项目在
|
|
108
|
+
`setup` 时(读回标记)就会注册。未标记的项目不受影响。文档放在 `skills/<id>/SKILL.md`(每个 skill 一个目录,`name` /
|
|
109
|
+
`description` 走 YAML frontmatter),由 `src/skill/loader.js` 扫描加载 —— **加新 skill 只需加一个目录,不用改代码**。
|
|
92
110
|
|
|
93
111
|
## 环境变量注入
|
|
94
112
|
|
|
@@ -212,16 +230,23 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
212
230
|
|
|
213
231
|
## 行为
|
|
214
232
|
|
|
215
|
-
-
|
|
216
|
-
-
|
|
217
|
-
-
|
|
233
|
+
- **被标记的项目**:启动读回标记后挂上 shell 环境钩子 (环境值待接入后填充)、注册 `idea-run-config` skill;
|
|
234
|
+
`/open-in-idea` 接入 IDE MCP + 启用引导。
|
|
235
|
+
- **未标记的项目**:不改环境、不注册、不注入任何 IDEA 提示 (每请求零开销;setup 时只做一次项目标记读取)。
|
|
236
|
+
- **手动接入**:启动 **不探测、不连接 IDE MCP**;注册、**环境重读**、引导注入全部由 `/open-in-idea` 触发。
|
|
218
237
|
`context` 钩子按「每次模型调用」触发 (含工具续跑),是**请求的纯函数**:它只看本次请求自带的工具快照 ——
|
|
219
|
-
有 `idea_*`
|
|
220
|
-
|
|
238
|
+
有 `idea_*` 就剥离隐藏工具并注入引导;没有则只在项目被标记时给恢复提示。**不做任何 I/O(get 只看内存缓存),没有定时器,
|
|
239
|
+
没有事件订阅**。一次失败的调用不会触发探测或注销。
|
|
240
|
+
- **项目标记自动重连**:`/open-in-idea` 会持久化一个**项目级**标记(插件名下 KV,key 为 `project/<项目根路径>`,重启 OpenCode 后
|
|
241
|
+
仍在),并在内存缓存。之后每条用户消息经 `prompt` 钩子处理时,先查 OpenCode 里 `idea` MCP server 的状态:不是
|
|
242
|
+
`connected`/`pending`(真断了)才同步重跑一遍完整接入流程 (探测 → `open -a` → 等 MCP → 注册 + `tool.reload`),让**这条消息
|
|
243
|
+
本身**就能用上 IDEA 工具;恢复成功后补一条会话通知。`/close-in-idea` 删标记后即不再自愈。失败时沿用启动保护的冷却与次数上限
|
|
244
|
+
(`launchCooldownMs` / `launchMaxAttempts`),不会每条消息都重复拉起 IDE。
|
|
245
|
+
- **判定只看 MCP 状态,不看工具报错**:是否重连只取决于 `idea` MCP server 的连接状态(`ctx.mcp.list()`)。工具调用的业务报错
|
|
246
|
+
(如 `File not found`)只可能发生在 server 仍 `connected` 时,所以**永远不会**被误判成断开;插件也不注册 `tool.execute.after`
|
|
247
|
+
钩子,一次失败的调用不会触发探测或注销。
|
|
221
248
|
- **`/open-in-idea`**:手动打开当前项目并即时加载能力 (已在 IDE 打开时则只重新加载);MCP 未开启时只发一次提示并
|
|
222
|
-
fast-fail,用户手动开启 MCP + Brave Mode
|
|
223
|
-
- **不响应工具报错**:插件**不注册** `tool.execute.after` 钩子。一次 `idea_*` 调用失败 (业务错误如 `File not found`、
|
|
224
|
-
或偶发传输抖动) 不会触发任何探测、注销或重连 —— 连接状态只由 `/open-in-idea` 决定。
|
|
249
|
+
fast-fail,用户手动开启 MCP + Brave Mode 后重试。**`/close-in-idea`**:删项目标记,不改动已建立的连接。
|
|
225
250
|
|
|
226
251
|
## 限制
|
|
227
252
|
|
|
@@ -240,7 +265,14 @@ export { default } from "/Users/yutao/Projects/opencode-idea/src/index.js";
|
|
|
240
265
|
- 探测只确认「MCP 服务是否在监听」,不校验「当前项目是否已在 IDE 打开」;MCP 关闭时会用一次短探测并立即给出设置提示。
|
|
241
266
|
- OpenCode 的 MCP 自动重连**只覆盖 legacy Streamable HTTP 的 session 过期**(`SessionExpiredError`:有 session id + 404 /
|
|
242
267
|
"Server not initialized" → `recover()` + 重试该次调用);modern 端点 (IDE 2026.2,protocol 2025-06-18,不带 session id)
|
|
243
|
-
的连接被关只会置 `failed`
|
|
268
|
+
的连接被关只会置 `failed` 并移除工具,**不会自动重连**。**被标记的项目**由插件在 `prompt` 钩子里查 MCP 状态并按需重跑接入
|
|
269
|
+
流程补这个缺口;未标记的项目仍走手动 `/open-in-idea`。
|
|
270
|
+
- 项目标记存在插件名下 KV (`plugin:opencode-idea:project/<项目根路径>`)。插件实例按 **Location**(项目/打开目录)创建,标记按
|
|
271
|
+
**项目根路径**共享;但两个不同 Location(例如项目根与某子目录)各有自己的内存缓存 —— 在其中一个执行 open/close 命令,另一个
|
|
272
|
+
实例要等它下次回源(目前只在 setup 读一次)才会看到。单目录打开项目的常见用法不受影响。
|
|
273
|
+
- `/close-in-idea` **只删标记,不断开已建立的连接**:已注册的 IDEA 工具会保留到连接自然中断,期间标记与实际连接可能短暂不一致。
|
|
274
|
+
- 插件**完全不再检查 `.idea` 目录**:是否是 IDEA 项目只由项目标记(`/open-in-idea` 设置、`/close-in-idea` 清除)决定。
|
|
275
|
+
(`/open-in-idea` 会让 IDEA 打开并导入该项目,`.idea` 由 IDEA 自己生成,插件无需也不应据此判断。)
|
|
244
276
|
- 打开 IDE 仅实现 macOS (`open -a`);首次打开若弹 Trust 对话框,需确认后重试。
|
|
245
277
|
- IDE MCP 没有「打开项目」工具,因此打开动作走 OS/CLI,而非 MCP。
|
|
246
278
|
- 插件热重载会断开 IDE 连接,需重跑 `/open-in-idea`。
|
package/package.json
CHANGED
package/src/mcp/idea.js
CHANGED
|
@@ -81,7 +81,7 @@ export async function probePort(port, options = {}) {
|
|
|
81
81
|
params: {
|
|
82
82
|
protocolVersion: PROTOCOL_VERSION,
|
|
83
83
|
capabilities: {},
|
|
84
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
84
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.10' },
|
|
85
85
|
},
|
|
86
86
|
}),
|
|
87
87
|
signal: controller.signal,
|
|
@@ -151,7 +151,7 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
|
|
|
151
151
|
params: {
|
|
152
152
|
protocolVersion: PROTOCOL_VERSION,
|
|
153
153
|
capabilities: {},
|
|
154
|
-
clientInfo: { name: 'opencode-idea', version: '0.0.
|
|
154
|
+
clientInfo: { name: 'opencode-idea', version: '0.0.10' },
|
|
155
155
|
},
|
|
156
156
|
});
|
|
157
157
|
const response = await post({
|
package/src/plugin.js
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
1
1
|
// opencode-idea — OpenCode plugin entry.
|
|
2
2
|
//
|
|
3
|
-
// For a
|
|
3
|
+
// For a project the user opted into IDEA this plugin:
|
|
4
4
|
// 1. injects the IDE integrated-terminal environment (JAVA_HOME / GOROOT /
|
|
5
5
|
// Node / Maven / ...) into every OpenCode shell;
|
|
6
6
|
// 2. registers the IDE MCP server with OpenCode (scoped to the project via the
|
|
7
7
|
// IJ_MCP_SERVER_PROJECT_PATH header), injects direct-call guidance for
|
|
8
8
|
// IDEA MCP tools, and updates only IDEA MCP tool descriptions;
|
|
9
9
|
// 3. registers the `/open-in-idea` command, which opens the current project
|
|
10
|
-
// in IntelliJ IDEA (reusing a running instance)
|
|
11
|
-
// capabilities immediately;
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// in IntelliJ IDEA (reusing a running instance), loads the IDE
|
|
11
|
+
// capabilities immediately, and marks the project as an IDEA project;
|
|
12
|
+
// `/close-in-idea` clears that mark;
|
|
13
|
+
// 4. registers the `idea-run-config` skill (marked projects only), which
|
|
14
|
+
// teaches the model how to manage `.run/*.run.xml` run configurations — a
|
|
15
|
+
// capability the IDE MCP server does not expose.
|
|
15
16
|
//
|
|
16
|
-
//
|
|
17
|
-
// NOT probe or connect the IDE (only the IDE-terminal env is read
|
|
18
|
-
// is requested). `/open-in-idea` probes, opens the IDE, and loads
|
|
19
|
-
// guidance on demand.
|
|
17
|
+
// A project that was never marked is left untouched. Everything is manual:
|
|
18
|
+
// setup does NOT probe or connect the IDE (only the IDE-terminal env is read
|
|
19
|
+
// when a probe is requested). `/open-in-idea` probes, opens the IDE, and loads
|
|
20
|
+
// the IDE MCP + guidance on demand.
|
|
21
|
+
//
|
|
22
|
+
// Once the user runs `/open-in-idea` in a project, that project is marked as an
|
|
23
|
+
// IDEA project. The mark is persisted through `ctx.storage` (durable across
|
|
24
|
+
// restarts) and cleared by `/close-in-idea`. While a project is marked, every
|
|
25
|
+
// user message assumes the IDE is still up: the plugin asks OpenCode for the
|
|
26
|
+
// `idea` MCP server's status and, when it is not connected, re-runs the open
|
|
27
|
+
// flow before the request is built. Detection is by MCP status, never by tool
|
|
28
|
+
// errors, so a normal business error can never look like a dropped connection.
|
|
20
29
|
//
|
|
21
30
|
// `Plugin.define` from `@opencode/plugin` is an identity function, so a plain
|
|
22
31
|
// `{ id, setup }` object is the whole plugin contract. Exporting it directly
|
|
@@ -29,7 +38,7 @@ import {
|
|
|
29
38
|
serverConfig,
|
|
30
39
|
} from './mcp/idea.js';
|
|
31
40
|
import { readIdeTerminalEnv } from './mcp/ide-env.js';
|
|
32
|
-
import { currentProjectPath
|
|
41
|
+
import { currentProjectPath } from './project.js';
|
|
33
42
|
import { applyEnv, mergeEnv } from './env.js';
|
|
34
43
|
import {
|
|
35
44
|
createLaunchGuard,
|
|
@@ -149,10 +158,11 @@ function toolIds(tools) {
|
|
|
149
158
|
|
|
150
159
|
/**
|
|
151
160
|
* @typedef {{
|
|
152
|
-
* status: 'connected' | 'tools-loading' | 'mcp-unavailable' | 'launch-failed' | 'disabled',
|
|
161
|
+
* status: 'connected' | 'tools-loading' | 'mcp-unavailable' | 'launch-failed' | 'disabled' | 'closed',
|
|
153
162
|
* port?: number,
|
|
154
163
|
* opened?: boolean,
|
|
155
|
-
* unavailableNoticeSent?: boolean
|
|
164
|
+
* unavailableNoticeSent?: boolean,
|
|
165
|
+
* autoRecovered?: boolean
|
|
156
166
|
* }} IdeCommandResult
|
|
157
167
|
*/
|
|
158
168
|
|
|
@@ -172,6 +182,9 @@ const asPositiveNumber = (value, fallback) =>
|
|
|
172
182
|
/** User-facing status text for a `/open-in-idea` result. */
|
|
173
183
|
/** @param {IdeCommandResult} result */
|
|
174
184
|
export function ideFeedback(result) {
|
|
185
|
+
if (result?.autoRecovered) {
|
|
186
|
+
return '检测到 IDE MCP 连接已断开,已自动重新连接并恢复 IDEA 原生工具。';
|
|
187
|
+
}
|
|
175
188
|
switch (result?.status) {
|
|
176
189
|
case 'connected':
|
|
177
190
|
return 'IDE MCP 已连接;IDEA 原生工具已注册并可用于后续请求。';
|
|
@@ -185,6 +198,8 @@ export function ideFeedback(result) {
|
|
|
185
198
|
return '无法拉起 IntelliJ IDEA(未找到应用或启动失败)。请确认已安装 IDEA,或手动打开后重试 /open-in-idea。';
|
|
186
199
|
case 'disabled':
|
|
187
200
|
return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
|
|
201
|
+
case 'closed':
|
|
202
|
+
return '已关闭当前项目的 IDEA 接入标记;IDE MCP 断开后将不再自动重连。如需重新接入,请再次执行 /open-in-idea。';
|
|
188
203
|
default:
|
|
189
204
|
return '已处理 /open-in-idea。';
|
|
190
205
|
}
|
|
@@ -245,6 +260,8 @@ export default {
|
|
|
245
260
|
* agent?: string,
|
|
246
261
|
* system?: unknown[],
|
|
247
262
|
* tools?: Record<string, unknown>,
|
|
263
|
+
* prompt?: { text?: string },
|
|
264
|
+
* metadata?: Record<string, unknown>,
|
|
248
265
|
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
249
266
|
* },
|
|
250
267
|
* tool?: {
|
|
@@ -254,18 +271,19 @@ export default {
|
|
|
254
271
|
* mcp: {
|
|
255
272
|
* transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
256
273
|
* reload?: () => Promise<void>,
|
|
274
|
+
* list?: () => Promise<{ data?: ReadonlyArray<{ name?: string, status?: { status?: string } }> }>,
|
|
257
275
|
* },
|
|
258
276
|
* shell: { hook: (name: string, cb: (input: { env: Record<string, string | undefined> }) => void) => Promise<{ dispose: () => Promise<void> | void }> },
|
|
277
|
+
* storage?: {
|
|
278
|
+
* get: (key: string) => Promise<unknown>,
|
|
279
|
+
* set: (key: string, value: unknown) => Promise<void>,
|
|
280
|
+
* remove: (key: string) => Promise<void>,
|
|
281
|
+
* },
|
|
259
282
|
* }} ctx
|
|
260
283
|
*/
|
|
261
284
|
async setup(ctx) {
|
|
262
285
|
const projectPath = currentProjectPath(ctx.location);
|
|
263
286
|
if (!projectPath) return;
|
|
264
|
-
// Where IDEA tools can exist at all. Computed once: the recovery guidance is
|
|
265
|
-
// injected per model request, so it must not cost a filesystem check each
|
|
266
|
-
// time, and unrelated projects must not receive IDEA prompts they can never
|
|
267
|
-
// use.
|
|
268
|
-
const isIdeaProject = hasIdeaDirectory(projectPath);
|
|
269
287
|
|
|
270
288
|
const options = ctx.options || {};
|
|
271
289
|
const ports = asArray(options.ports, DEFAULT_PORTS);
|
|
@@ -292,11 +310,68 @@ export default {
|
|
|
292
310
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
293
311
|
let sessionRegistration;
|
|
294
312
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
313
|
+
let promptRegistration;
|
|
314
|
+
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
295
315
|
let commandRegistration;
|
|
296
316
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
297
317
|
let skillRegistration;
|
|
318
|
+
let skillsRegistered = false;
|
|
298
319
|
let disposed = false;
|
|
299
320
|
|
|
321
|
+
// Project-level mark: has the user opted this project into IDEA via
|
|
322
|
+
// `/open-in-idea`? One plugin instance serves one project (OpenCode
|
|
323
|
+
// activates plugins per Location), so a single boolean is the whole cache.
|
|
324
|
+
// The durable copy lives in `ctx.storage` under `project/<projectPath>`.
|
|
325
|
+
// The commands are the only writers: they persist first, then invalidate
|
|
326
|
+
// this cache so the next read comes back from storage.
|
|
327
|
+
const storage = ctx.storage;
|
|
328
|
+
const markKey = `project/${projectPath}`;
|
|
329
|
+
/** @type {boolean | undefined} undefined = a storage read is due */
|
|
330
|
+
let markCache;
|
|
331
|
+
// One auto-recovery run per project at a time; overlapping prompts share it.
|
|
332
|
+
/** @type {Promise<void> | undefined} */
|
|
333
|
+
let autoRecovering;
|
|
334
|
+
|
|
335
|
+
/** Read the durable mark into the cache; at most one storage read per invalidation. */
|
|
336
|
+
const readMark = async () => {
|
|
337
|
+
if (markCache !== undefined) return markCache;
|
|
338
|
+
if (!storage || typeof storage.get !== 'function') {
|
|
339
|
+
markCache = false;
|
|
340
|
+
return markCache;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
markCache = (await storage.get(markKey)) === true;
|
|
344
|
+
} catch {
|
|
345
|
+
// storage is best effort; fall back to unmarked
|
|
346
|
+
markCache = false;
|
|
347
|
+
}
|
|
348
|
+
return markCache;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Persist the mark, then invalidate the cache so the next reader re-reads
|
|
353
|
+
* storage. Best effort: an in-memory invalidation still applies if the write
|
|
354
|
+
* fails.
|
|
355
|
+
* @param {boolean} marked
|
|
356
|
+
*/
|
|
357
|
+
const setMark = async (marked) => {
|
|
358
|
+
try {
|
|
359
|
+
if (marked) await storage?.set?.(markKey, true);
|
|
360
|
+
else await storage?.remove?.(markKey);
|
|
361
|
+
} catch {
|
|
362
|
+
// best effort
|
|
363
|
+
}
|
|
364
|
+
markCache = undefined;
|
|
365
|
+
// The skill lifecycle follows the mark: marking the project registers the
|
|
366
|
+
// IDEA skills, clearing the mark removes them again. Same semantics as the
|
|
367
|
+
// reconnect behavior, so the two can never disagree.
|
|
368
|
+
if (marked) await ensureSkills();
|
|
369
|
+
else await removeSkills();
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Resolve the mark once, so the request hooks can read it synchronously.
|
|
373
|
+
await readMark();
|
|
374
|
+
|
|
300
375
|
const deactivateIde = async () => {
|
|
301
376
|
const current = registrations.slice();
|
|
302
377
|
registrations.length = 0;
|
|
@@ -365,7 +440,7 @@ export default {
|
|
|
365
440
|
// Manual: only `/open-in-idea` probes the IDE. Setup calls this with
|
|
366
441
|
// `probeIde: false` so it never connects until the command asks.
|
|
367
442
|
const port = probeIde ? await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }) : undefined;
|
|
368
|
-
const isJetBrains =
|
|
443
|
+
const isJetBrains = markCache === true || port !== undefined;
|
|
369
444
|
if (!isJetBrains) {
|
|
370
445
|
currentEnv = { env: {}, prependPath: [] };
|
|
371
446
|
await deactivateIde();
|
|
@@ -485,6 +560,30 @@ export default {
|
|
|
485
560
|
return run;
|
|
486
561
|
};
|
|
487
562
|
|
|
563
|
+
/**
|
|
564
|
+
* The whole `/open-in-idea` flow, shared by the command and the auto-recovery
|
|
565
|
+
* path: ensure an IDE MCP endpoint, then register it and load capabilities.
|
|
566
|
+
*
|
|
567
|
+
* @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
|
|
568
|
+
* @returns {Promise<IdeCommandResult>}
|
|
569
|
+
*/
|
|
570
|
+
const runOpenInIdea = async ({ onUnavailable } = {}) => {
|
|
571
|
+
const result = await ensureIde({ onUnavailable });
|
|
572
|
+
const { port } = result;
|
|
573
|
+
let toolsReady = false;
|
|
574
|
+
try {
|
|
575
|
+
// Do not repeat the expensive probe when the preflight already found no
|
|
576
|
+
// MCP endpoint. The command will retry after the user enables MCP +
|
|
577
|
+
// Brave Mode.
|
|
578
|
+
toolsReady = (await reconcile({ probeIde: port !== undefined })) === true;
|
|
579
|
+
} catch {
|
|
580
|
+
// capability load is best effort; feedback still matters
|
|
581
|
+
}
|
|
582
|
+
return result.status === 'connected' && !toolsReady
|
|
583
|
+
? { ...result, status: 'tools-loading' }
|
|
584
|
+
: result;
|
|
585
|
+
};
|
|
586
|
+
|
|
488
587
|
// Setup never probes or connects the IDE — everything is on `/open-in-idea`.
|
|
489
588
|
await reconcile({ probeIde: false });
|
|
490
589
|
|
|
@@ -505,67 +604,153 @@ export default {
|
|
|
505
604
|
}
|
|
506
605
|
};
|
|
507
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Is the plugin's `idea` MCP server connected right now? OpenCode tracks
|
|
609
|
+
* every server's status in memory, and this is the authoritative "the IDE
|
|
610
|
+
* MCP is down" signal: it reflects the transport, not the result of any
|
|
611
|
+
* single tool call, so a business error can never look like a disconnect.
|
|
612
|
+
* `pending` means "still connecting" and is treated as healthy, so a server
|
|
613
|
+
* that is merely starting up is left alone.
|
|
614
|
+
*
|
|
615
|
+
* @returns {Promise<boolean>}
|
|
616
|
+
*/
|
|
617
|
+
const isIdeaConnected = async () => {
|
|
618
|
+
if (typeof ctx.mcp?.list !== 'function') return false;
|
|
619
|
+
try {
|
|
620
|
+
const result = await ctx.mcp.list();
|
|
621
|
+
const servers = Array.isArray(result) ? result : (result?.data ?? []);
|
|
622
|
+
return servers.some((server) => {
|
|
623
|
+
const status = server?.status?.status;
|
|
624
|
+
return server?.name === IDEA_SERVER_NAME && (status === 'connected' || status === 'pending');
|
|
625
|
+
});
|
|
626
|
+
} catch {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Marked-project recovery: while the project is marked (the user ran
|
|
633
|
+
* `/open-in-idea`), treat the IDE as expected and rebuild the connection
|
|
634
|
+
* whenever its MCP server is not connected. Runs before the prompt's request
|
|
635
|
+
* is built, so a successful recovery makes its `idea_*` tools available to
|
|
636
|
+
* that very message.
|
|
637
|
+
*
|
|
638
|
+
* Only a confirmed reconnect is announced. A failed recovery stays silent:
|
|
639
|
+
* the per-request recovery guidance already tells the model what to say, and
|
|
640
|
+
* a notice per failed message would be noise (the launch guard, shared with
|
|
641
|
+
* the command, keeps it from re-spawning on every message).
|
|
642
|
+
*
|
|
643
|
+
* @param {string} sessionID the notice target; the mark itself is per-project
|
|
644
|
+
*/
|
|
645
|
+
const autoReconnect = (sessionID) => {
|
|
646
|
+
if (autoRecovering) return autoRecovering;
|
|
647
|
+
const run = (async () => {
|
|
648
|
+
try {
|
|
649
|
+
if (disposed) return;
|
|
650
|
+
if (await isIdeaConnected()) return;
|
|
651
|
+
const result = await runOpenInIdea();
|
|
652
|
+
// A found endpoint is what counts as recovered, even if its tool
|
|
653
|
+
// catalog is still registering (`tools-loading`).
|
|
654
|
+
if (result.port === undefined) return;
|
|
655
|
+
// Queue the notice so it is delivered after the user's own message,
|
|
656
|
+
// never mixed into the same model turn as it.
|
|
657
|
+
await sendFeedback(
|
|
658
|
+
{ sessionID, prompt: { text: '' }, delivery: 'queue' },
|
|
659
|
+
{ status: 'connected', autoRecovered: true },
|
|
660
|
+
);
|
|
661
|
+
} catch {
|
|
662
|
+
// auto-recovery is best effort
|
|
663
|
+
}
|
|
664
|
+
})();
|
|
665
|
+
autoRecovering = run;
|
|
666
|
+
void run.finally(() => {
|
|
667
|
+
if (autoRecovering === run) autoRecovering = undefined;
|
|
668
|
+
});
|
|
669
|
+
return run;
|
|
670
|
+
};
|
|
671
|
+
|
|
508
672
|
// The shipped skills are static knowledge (`skills/<id>/SKILL.md`), e.g. the
|
|
509
673
|
// run-configuration workflow: where `.run/*.run.xml` live, how to bootstrap a
|
|
510
|
-
// schema from a real example, and how to verify a write.
|
|
511
|
-
//
|
|
674
|
+
// schema from a real example, and how to verify a write. Registered only for
|
|
675
|
+
// a project that has been marked as an IDEA project, so unrelated projects
|
|
676
|
+
// never see an IDEA-specific skill.
|
|
512
677
|
//
|
|
513
678
|
// Deliberately NOT wrapped in a catch: these are core features of the plugin,
|
|
514
679
|
// so a rejected definition must fail loudly. Swallowing it would ship a
|
|
515
680
|
// silently incomplete plugin — which is exactly how 0.0.6 lost
|
|
516
681
|
// `/open-in-idea` without anyone noticing until the command was gone.
|
|
517
|
-
|
|
682
|
+
const ensureSkills = async () => {
|
|
683
|
+
if (skillsRegistered || typeof ctx.skill?.transform !== 'function') return;
|
|
684
|
+
skillsRegistered = true;
|
|
518
685
|
// Read the documents before the transform: callbacks must stay synchronous.
|
|
519
686
|
const skills = readSkills();
|
|
520
687
|
skillRegistration = await ctx.skill.transform((editor) => {
|
|
521
688
|
for (const skill of skills) editor.add(skill);
|
|
522
689
|
});
|
|
523
|
-
}
|
|
690
|
+
};
|
|
691
|
+
/** Remove the IDEA skills again when the project is unmarked. */
|
|
692
|
+
const removeSkills = async () => {
|
|
693
|
+
if (!skillsRegistered || !skillRegistration) return;
|
|
694
|
+
skillsRegistered = false;
|
|
695
|
+
const registration = skillRegistration;
|
|
696
|
+
skillRegistration = undefined;
|
|
697
|
+
try {
|
|
698
|
+
await registration.dispose();
|
|
699
|
+
} catch {
|
|
700
|
+
// best effort
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
// A project marked in an earlier run gets its skills at setup; a project
|
|
704
|
+
// marked for the first time gets them when `/open-in-idea` runs (both paths
|
|
705
|
+
// go through `setMark` / this setup hook).
|
|
706
|
+
if (markCache === true) await ensureSkills();
|
|
524
707
|
|
|
525
708
|
// `/open-in-idea`: manual, re-entrant trigger that also loads the IDE
|
|
526
|
-
// capabilities immediately
|
|
709
|
+
// capabilities immediately, and marks the project so later messages keep the
|
|
710
|
+
// IDE alive. `/close-in-idea` removes that mark. Both persist through
|
|
711
|
+
// `ctx.storage` and invalidate the in-memory cache.
|
|
527
712
|
if (typeof ctx.command?.transform === 'function') {
|
|
528
713
|
commandRegistration = await ctx.command.transform((editor) => {
|
|
529
714
|
editor.add({
|
|
530
715
|
name: 'open-in-idea',
|
|
531
716
|
description: '用 IntelliJ IDEA 打开当前项目,并即时加载 IDE MCP 与项目 SDK 环境',
|
|
532
717
|
execute: async (input) => {
|
|
718
|
+
// Mark the project before running: the mark means "this is an IDEA
|
|
719
|
+
// project", so it is persisted as soon as the user asks, whether or
|
|
720
|
+
// not the IDE happens to be reachable right now. `setMark` also
|
|
721
|
+
// registers the IDEA skills.
|
|
722
|
+
await setMark(true);
|
|
533
723
|
/** @type {boolean} */
|
|
534
724
|
let unavailableNoticeSent = false;
|
|
535
|
-
const result = await
|
|
725
|
+
const result = await runOpenInIdea({
|
|
536
726
|
onUnavailable: async (notice) => {
|
|
537
727
|
unavailableNoticeSent = await sendFeedback(input, notice);
|
|
538
728
|
return unavailableNoticeSent;
|
|
539
729
|
},
|
|
540
730
|
});
|
|
541
|
-
const { port } = result;
|
|
542
|
-
let toolsReady = false;
|
|
543
|
-
try {
|
|
544
|
-
// Do not repeat the expensive probe when the preflight already
|
|
545
|
-
// found no MCP endpoint. The command will retry after the user
|
|
546
|
-
// enables MCP + Brave Mode.
|
|
547
|
-
toolsReady = (await reconcile({ probeIde: port !== undefined })) === true;
|
|
548
|
-
} catch {
|
|
549
|
-
// capability load is best effort; feedback still matters
|
|
550
|
-
}
|
|
551
|
-
const finalResult = result.status === 'connected' && !toolsReady
|
|
552
|
-
? { ...result, status: 'tools-loading' }
|
|
553
|
-
: result;
|
|
554
731
|
// The unavailable notice is the final result for this command. If
|
|
555
732
|
// it was already delivered, do not show the same warning twice.
|
|
556
733
|
const unavailableAlreadySent = unavailableNoticeSent || result.unavailableNoticeSent;
|
|
557
|
-
if (!unavailableAlreadySent ||
|
|
558
|
-
await sendFeedback(input,
|
|
734
|
+
if (!unavailableAlreadySent || result.status !== 'mcp-unavailable') {
|
|
735
|
+
await sendFeedback(input, result);
|
|
559
736
|
}
|
|
560
737
|
},
|
|
561
738
|
});
|
|
739
|
+
editor.add({
|
|
740
|
+
name: 'close-in-idea',
|
|
741
|
+
description: '取消当前项目的 IDEA 接入标记,断开后不再自动重连',
|
|
742
|
+
execute: async (input) => {
|
|
743
|
+
await setMark(false);
|
|
744
|
+
await sendFeedback(input, { status: 'closed' });
|
|
745
|
+
},
|
|
746
|
+
});
|
|
562
747
|
});
|
|
563
748
|
}
|
|
564
749
|
|
|
565
750
|
// Hide the tools the model must not see, and point it at the rest. This hook
|
|
566
751
|
// runs for every agent-loop step (including tool-driven continuations), so
|
|
567
|
-
// it
|
|
568
|
-
//
|
|
752
|
+
// it stays a cheap, I/O-free function of the request: it checks the cached
|
|
753
|
+
// project mark and this request's own tool snapshot, and takes no action.
|
|
569
754
|
//
|
|
570
755
|
// `event.tools` is this request's tool snapshot, and OpenCode's own registry
|
|
571
756
|
// adds IDEA tools when the server connects and drops them when it stops
|
|
@@ -577,13 +762,36 @@ export default {
|
|
|
577
762
|
const tools = event?.tools;
|
|
578
763
|
const serving = tools !== undefined && Object.keys(tools).some((name) => name.startsWith('idea_'));
|
|
579
764
|
if (serving) removeIdeaToolDefinitions(tools, HIDDEN_IDEA_TOOLS);
|
|
580
|
-
if (
|
|
765
|
+
if (markCache === true || serving) appendIdeRecoveryGuidance(event?.system);
|
|
581
766
|
if (!injectGuidance) return;
|
|
582
767
|
// Guidance promises tools the model can call, so it only goes out when
|
|
583
768
|
// they are actually in this request.
|
|
584
769
|
if (!serving) return;
|
|
585
770
|
appendIdeGuidance(event?.system, projectPath);
|
|
586
771
|
});
|
|
772
|
+
|
|
773
|
+
// Project-level auto-recovery. The only self-initiated action in the
|
|
774
|
+
// plugin, and only for a marked project. It belongs to the `prompt` hook
|
|
775
|
+
// (not `context`) because the tool catalog for a request is already fixed
|
|
776
|
+
// by the time `context` runs: a `prompt`-time reconnect is what lets the
|
|
777
|
+
// message that triggered it use `idea_*` tools.
|
|
778
|
+
//
|
|
779
|
+
// The returned promise is awaited by OpenCode before the request is built,
|
|
780
|
+
// so a slow cold start delays that message — the accepted cost of treating
|
|
781
|
+
// a marked project's IDE as always up.
|
|
782
|
+
promptRegistration = await ctx.session.hook('prompt', async (event) => {
|
|
783
|
+
const sessionID = event?.sessionID;
|
|
784
|
+
if (typeof sessionID !== 'string') return;
|
|
785
|
+
// Never react to the plugin's own notices, or auto-recovery would feed
|
|
786
|
+
// on itself. Notices carry `metadata.displayText`; the prefix guards
|
|
787
|
+
// against an older notice shape.
|
|
788
|
+
const displayText = event?.metadata?.displayText;
|
|
789
|
+
if (typeof displayText === 'string') return;
|
|
790
|
+
const text = event?.prompt?.text;
|
|
791
|
+
if (typeof text === 'string' && text.startsWith('【插件通知】')) return;
|
|
792
|
+
if (!(await readMark())) return;
|
|
793
|
+
return autoReconnect(sessionID);
|
|
794
|
+
});
|
|
587
795
|
}
|
|
588
796
|
|
|
589
797
|
return async () => {
|
|
@@ -602,6 +810,13 @@ export default {
|
|
|
602
810
|
// best effort
|
|
603
811
|
}
|
|
604
812
|
}
|
|
813
|
+
if (promptRegistration) {
|
|
814
|
+
try {
|
|
815
|
+
await promptRegistration.dispose();
|
|
816
|
+
} catch {
|
|
817
|
+
// best effort
|
|
818
|
+
}
|
|
819
|
+
}
|
|
605
820
|
if (commandRegistration) {
|
|
606
821
|
try {
|
|
607
822
|
await commandRegistration.dispose();
|
package/src/project.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
// Pure helpers for locating the current OpenCode project.
|
|
2
2
|
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
3
|
/**
|
|
7
4
|
* Resolve the absolute project root for the current plugin location.
|
|
8
5
|
* OpenCode exposes a `Location.Info` on the plugin context.
|
|
@@ -15,15 +12,3 @@ export function currentProjectPath(location) {
|
|
|
15
12
|
const projectDirectory = location.project && location.project.directory;
|
|
16
13
|
return projectDirectory || location.directory || undefined;
|
|
17
14
|
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* True when the directory looks like a JetBrains project (has a `.idea` folder).
|
|
21
|
-
* This is a convenience signal only; the plugin does not require it, because an
|
|
22
|
-
* IDE may have created the project entry without writing `.idea` yet.
|
|
23
|
-
*
|
|
24
|
-
* @param {string | undefined} directory
|
|
25
|
-
* @returns {boolean}
|
|
26
|
-
*/
|
|
27
|
-
export function hasIdeaDirectory(directory) {
|
|
28
|
-
return typeof directory === 'string' && existsSync(path.join(directory, '.idea'));
|
|
29
|
-
}
|