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 CHANGED
@@ -8,18 +8,21 @@ OpenCode 插件,面向 JetBrains 项目 (IntelliJ IDEA / PyCharm / WebStorm 等)
8
8
  (正常回退,无需声明)。
9
9
  3. **注入 IDE 环境变量** —— 从 IDE 集成终端读取它实际运行的环境,注入每个 OpenCode shell。
10
10
 
11
- 非 JetBrains 项目完全不受影响。 **全手动**:插件启动不探测、不连接 IDE,只有跑 `/open-in-idea` 才接入;接入之后也没有
12
- 任何自主动作(环境变量只在 `/open-in-idea` 时刷新)。
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. 读 IDE 集成终端环境 → 通过 shell create.before 钩子注入每个 shell(null: 未连 IDE 时为空)
19
- b. 注册 /open-in-idea 命令 + session context 钩子(注入引导)
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
- 。插件在 **JetBrains 项目**(存在 `.idea`)里注册一个 `idea-run-config` skill 来补上这块:
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 由 `ctx.skill.transform` 在插件 `setup` 时注册, **与 `/open-in-idea` 无关**;非 JetBrains 项目不受影响。文档放在
90
- `skills/<id>/SKILL.md`(每个 skill 一个目录,`name` / `description` 走 YAML frontmatter),由 `src/skill/loader.js` 扫描加载 ——
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
- - **JetBrains 项目**:启动即注入 IDE 终端环境 (若此前已连过 IDE);`/open-in-idea` 接入 IDE MCP + 启用引导。
216
- - **非 JetBrains 项目**:不改环境、不注册、不注入任何 IDEA 提示 (每请求零开销)。
217
- - **手动接入 + 零自主动作**:启动 **不探测、不连接 IDE MCP**;注册、**环境重读**、引导注入全部由 `/open-in-idea` 触发。
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_*` 就剥离隐藏工具并注入引导,没有就只给恢复提示。**不做任何 I/O,没有缓存状态,没有定时器,没有事件订阅**。
220
- 一次失败的调用不会触发探测或注销,断开的连接也不会被自动重连 —— **恢复的唯一方式是用户再次执行 `/open-in-idea`**。
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` 并移除工具,**不会自动重连**。插件刻意不补这个缺口:恢复一律走手动 `/open-in-idea`。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-idea",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "OpenCode plugin that connects to JetBrains IDE MCP servers and prefers IDE tools when the current project is open in the IDE",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
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.9' },
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.9' },
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 JetBrains project this plugin:
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) and then loads the IDE
11
- // capabilities immediately;
12
- // 4. registers the `idea-run-config` skill (JetBrains projects only),
13
- // which teaches the model how to manage `.run/*.run.xml` run
14
- // configurations — a capability the IDE MCP server does not expose.
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
- // Non-JetBrains projects are left untouched. Everything is manual: setup does
17
- // NOT probe or connect the IDE (only the IDE-terminal env is read when a probe
18
- // is requested). `/open-in-idea` probes, opens the IDE, and loads the IDE MCP +
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, hasIdeaDirectory } from './project.js';
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 = isIdeaProject || port !== undefined;
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. JetBrains projects
511
- // only, and independent of `/open-in-idea`.
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
- if (isIdeaProject && typeof ctx.skill?.transform === 'function') {
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 ensureIde({
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 || finalResult.status !== 'mcp-unavailable') {
558
- await sendFeedback(input, finalResult);
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 is deliberately a pure function of the request: no I/O, no cached
568
- // connection state, and no action of its own.
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 (isIdeaProject || serving) appendIdeRecoveryGuidance(event?.system);
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
- }