opencode-idea 0.0.4 → 0.0.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.5
4
+
5
+ - **冷启动等待 MCP 端点**:`/open-in-idea` 在 `open -a` 之后按 `mcpStartTimeoutMs`(默认 60s)轮询,直到 IDEA 自带的 MCP server 开始监听。冷启动时一次命令即可连上,不再像之前那样探测一次失败就报「未检测到 MCP 服务」。
6
+ - **工具就绪判定**:连接后轮询 `ctx.tool.list()`,确认 `idea_read_file` / `idea_apply_patch` 已注册。未就绪时状态为 `tools-loading` 并提示稍后重试,不再在原生工具表尚未就绪时谎称「已连接」。
7
+ - **重复执行刷新工具列表**:再次执行 `/open-in-idea` 会重建 MCP server 并依次 `ctx.mcp.reload()` + `ctx.tool.reload()`,让已经捕获过工具快照的会话立即看到最新工具,而不是停留在旧快照。
8
+ - **IDE 掉线自动注销**:每次请求前复检端点,IDEA 关闭或某次 `idea_*` 工具调用失败时,立即移除 `idea_*` 工具并恢复原生工具,避免后续请求继续调用已失效的 IDE 工具;只有再次执行 `/open-in-idea` 才能重新注册。
9
+ - **清理**:移除调试日志;删除过时的 `scripts/publish.sh`(旧包名 + bypass token 流程,已由 `scripts/release.mjs` 取代);`package.json` 的 `files` 移除早已删除的 `src/idea-tool-descriptions.js`。
10
+
3
11
  ## 0.0.4
4
12
 
5
13
  - **`/open-in-idea` 刷新工具列表**:在 `ctx.mcp.reload()` 之后显式调用 `ctx.tool.reload()`。之前只有 MCP 层重建目录,已经捕获过工具快照的会话仍然看不到新的 `idea_*` 原生工具;现在下一次模型请求即可用,无需重开会话。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-idea",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
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",
@@ -14,7 +14,6 @@
14
14
  "src/env.js",
15
15
  "src/ide-launcher.js",
16
16
  "src/ide-guidance.js",
17
- "src/idea-tool-descriptions.js",
18
17
  "README.md",
19
18
  "CHANGELOG.md"
20
19
  ],
package/src/idea-mcp.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.4' },
84
+ clientInfo: { name: 'opencode-idea', version: '0.0.5' },
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.4' },
154
+ clientInfo: { name: 'opencode-idea', version: '0.0.5' },
155
155
  },
156
156
  });
157
157
  const response = await post({
package/src/plugin.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  DEFAULT_PORTS,
24
24
  IDEA_SERVER_NAME,
25
25
  findIdePort,
26
+ probePort,
26
27
  serverConfig,
27
28
  } from './idea-mcp.js';
28
29
  import { readIdeTerminalEnv } from './ide-env.js';
@@ -43,10 +44,38 @@ const DEFAULT_LAUNCH_MAX_ATTEMPTS = 5;
43
44
  // MCP availability is a fast preflight. If it is not ready, fail fast and let
44
45
  // the user enable MCP + Brave Mode in IDEA before retrying the command.
45
46
  const DEFAULT_MCP_PROBE_TIMEOUT_MS = 1000;
47
+ // A cold IDE can take tens of seconds before its bundled MCP server listens.
48
+ // After `open -a`, poll for the endpoint instead of reporting failure once.
49
+ const DEFAULT_MCP_START_TIMEOUT_MS = 60000;
50
+ const MCP_START_POLL_MS = 1000;
51
+ const DEFAULT_TOOL_REFRESH_TIMEOUT_MS = 5000;
52
+ const TOOL_REFRESH_POLL_MS = 25;
53
+ const IDEA_READY_TOOL_IDS = new Set(['idea_read_file', 'idea_apply_patch']);
54
+
55
+ /**
56
+ * Remove IDEA MCP definitions from one request's tool record.
57
+ *
58
+ * @param {Record<string, unknown> | undefined} tools
59
+ */
60
+ function removeIdeaToolDefinitions(tools) {
61
+ if (!tools || typeof tools !== 'object') return 0;
62
+ let removed = 0;
63
+ for (const name of Object.keys(tools)) {
64
+ if (!name.startsWith('idea_')) continue;
65
+ delete tools[name];
66
+ removed += 1;
67
+ }
68
+ return removed;
69
+ }
70
+
71
+ /** @param {ReadonlyArray<{ id?: string }> | undefined} tools */
72
+ function toolIds(tools) {
73
+ return (tools ?? []).map((tool) => tool.id).filter((id) => typeof id === 'string').sort();
74
+ }
46
75
 
47
76
  /**
48
77
  * @typedef {{
49
- * status: 'connected' | 'mcp-unavailable' | 'launch-failed' | 'disabled',
78
+ * status: 'connected' | 'tools-loading' | 'mcp-unavailable' | 'launch-failed' | 'disabled',
50
79
  * port?: number,
51
80
  * opened?: boolean,
52
81
  * unavailableNoticeSent?: boolean
@@ -71,10 +100,12 @@ const asPositiveNumber = (value, fallback) =>
71
100
  export function ideFeedback(result) {
72
101
  switch (result?.status) {
73
102
  case 'connected':
74
- return 'IDE MCP 已连接;IDE MCP 与项目环境已即时加载。';
103
+ return 'IDE MCP 已连接;IDEA 原生工具已注册并可用于后续请求。';
104
+ case 'tools-loading':
105
+ return 'IDE MCP 已连接,但原生 IDEA 工具列表尚未完成注册。请稍后再次执行 /open-in-idea。';
75
106
  case 'mcp-unavailable':
76
107
  return result?.opened
77
- ? '已请求打开/激活 IntelliJ IDEA,但未检测到 MCP 服务。请在 IDEA 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-idea。'
108
+ ? '已请求打开/激活 IntelliJ IDEA 并等待 MCP 服务启动,但未检测到 MCP 服务。请在 IDEA 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-idea。'
78
109
  : '未检测到 IntelliJ IDEA 的 MCP 服务。请确认 IDEA 已打开,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。';
79
110
  case 'launch-failed':
80
111
  return '无法拉起 IntelliJ IDEA(未找到应用或启动失败)。请确认已安装 IDEA,或手动打开后重试 /open-in-idea。';
@@ -112,6 +143,8 @@ export default {
112
143
  * launchCooldownMs?: number,
113
144
  * launchMaxAttempts?: number,
114
145
  * mcpProbeTimeoutMs?: number,
146
+ * mcpStartTimeoutMs?: number,
147
+ * toolRefreshTimeoutMs?: number,
115
148
  * feedback?: false | 'message',
116
149
  * },
117
150
  * command?: {
@@ -123,10 +156,21 @@ export default {
123
156
  * },
124
157
  * session?: {
125
158
  * prompt: (input: { sessionID: string, text: string, delivery: unknown }) => Promise<unknown>,
126
- * hook: (name: string, cb: (event: { system?: unknown[] }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
159
+ * hook: (name: string, cb: (event: {
160
+ * sessionID?: string,
161
+ * agent?: string,
162
+ * system?: unknown[],
163
+ * tools?: Record<string, unknown>,
164
+ * }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
127
165
  * },
128
166
  * tool?: {
167
+ * list?: () => Promise<ReadonlyArray<{ id?: string }>>,
129
168
  * reload?: () => Promise<void>,
169
+ * hook?: (name: string, cb: (event: {
170
+ * tool?: string,
171
+ * status?: string,
172
+ * error?: { message?: string },
173
+ * }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
130
174
  * },
131
175
  * mcp: {
132
176
  * transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
@@ -145,6 +189,8 @@ export default {
145
189
  const injectGuidance = options.injectGuidance !== false;
146
190
  const ideApp = resolveIdeApp(options.openInIde);
147
191
  const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
192
+ const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
193
+ const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
148
194
  // "message" (default) injects a clearly-labelled notification that the
149
195
  // model only acknowledges ("收到"); the user sees the clean notice via
150
196
  // metadata.displayText. false is silent.
@@ -153,9 +199,9 @@ export default {
153
199
  cooldownMs: asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS),
154
200
  maxAttempts: asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS),
155
201
  });
156
-
157
202
  /** @type {number | undefined} */
158
203
  let activePort;
204
+ let activeToolsReady = false;
159
205
  let currentEnv = { env: {}, prependPath: [] };
160
206
 
161
207
  /** @type {Array<{ dispose: () => Promise<void> | void }>} */
@@ -166,10 +212,13 @@ export default {
166
212
  let sessionRegistration;
167
213
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
168
214
  let commandRegistration;
215
+ /** @type {{ dispose: () => Promise<void> | void } | undefined} */
216
+ let toolRegistration;
169
217
  let disposed = false;
170
218
 
171
219
  const deactivateIde = async () => {
172
220
  activePort = undefined;
221
+ activeToolsReady = false;
173
222
  const current = registrations.slice();
174
223
  registrations.length = 0;
175
224
  if (current.length === 0) return;
@@ -182,22 +231,73 @@ export default {
182
231
  }
183
232
  };
184
233
 
185
- const activateIde = async (port) => {
186
- const mcpRegistration = await ctx.mcp.transform((editor) => {
187
- editor.set(IDEA_SERVER_NAME, serverConfig(port, projectPath));
188
- });
189
- registrations = [mcpRegistration];
190
- activePort = port;
234
+ const waitForIdeaTools = async () => {
235
+ if (typeof ctx.tool?.list !== 'function') return false;
236
+ const deadline = Date.now() + toolRefreshTimeoutMs;
237
+ while (Date.now() < deadline) {
238
+ try {
239
+ const ids = toolIds(await ctx.tool.list());
240
+ if ([...IDEA_READY_TOOL_IDS].every((id) => ids.includes(id))) return true;
241
+ } catch {
242
+ // The MCP catalog may still be settling; retry within the bounded wait.
243
+ }
244
+ await new Promise((resolve) => setTimeout(resolve, TOOL_REFRESH_POLL_MS));
245
+ }
246
+ return false;
247
+ };
248
+
249
+ const refreshIdeTools = async () => {
250
+ activeToolsReady = false;
191
251
  if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
192
252
  // Reconcile the MCP servers above, then replay the tool registry so the
193
253
  // refreshed catalog lands in the next model request's tool snapshot.
194
254
  // Without this, a session that already captured a snapshot keeps the old
195
255
  // tool list until the session is reopened.
196
256
  if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
257
+ activeToolsReady = await waitForIdeaTools();
258
+ return activeToolsReady;
259
+ };
260
+
261
+ const activateIde = async (port) => {
262
+ const mcpRegistration = await ctx.mcp.transform((editor) => {
263
+ editor.set(IDEA_SERVER_NAME, serverConfig(port, projectPath));
264
+ });
265
+ registrations = [mcpRegistration];
266
+ activePort = port;
267
+ return refreshIdeTools();
268
+ };
269
+
270
+ /** @type {Promise<boolean> | undefined} */
271
+ let healthCheck;
272
+ /**
273
+ * A manually activated IDE can disappear while the OpenCode background
274
+ * service keeps running (the desktop client and the service have separate
275
+ * lifetimes). Re-check the endpoint before every request that could expose
276
+ * IDEA tools, and unregister them as soon as the endpoint is gone. Only
277
+ * `/open-in-idea` may activate them again.
278
+ *
279
+ * @returns {Promise<boolean>}
280
+ */
281
+ const verifyActiveIde = async () => {
282
+ if (activePort === undefined) return true;
283
+ if (healthCheck) return healthCheck;
284
+ const port = activePort;
285
+ healthCheck = (async () => {
286
+ const available = await probePort(port, { timeoutMs: mcpProbeTimeoutMs });
287
+ if (available) return true;
288
+ if (activePort !== port) return true;
289
+ await deactivateIde();
290
+ if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
291
+ if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
292
+ return false;
293
+ })().finally(() => {
294
+ healthCheck = undefined;
295
+ });
296
+ return healthCheck;
197
297
  };
198
298
 
199
299
  const reconcileOnce = async ({ probeIde = true } = {}) => {
200
- if (disposed) return;
300
+ if (disposed) return false;
201
301
 
202
302
  // Manual: only `/open-in-idea` probes the IDE. Setup calls this with
203
303
  // `probeIde: false` so it never connects until the command asks.
@@ -206,7 +306,7 @@ export default {
206
306
  if (!isJetBrains) {
207
307
  currentEnv = { env: {}, prependPath: [] };
208
308
  await deactivateIde();
209
- return;
309
+ return false;
210
310
  }
211
311
 
212
312
  // Environment: read from the IDE integrated terminal, the single source
@@ -220,9 +320,13 @@ export default {
220
320
  shellRegistration = await ctx.shell.hook('create.before', (input) => applyEnv(input, currentEnv));
221
321
  }
222
322
 
223
- if (port === activePort) return;
323
+ // A repeated /open-in-idea must still refresh the current server. The
324
+ // previous implementation returned here, leaving the session on its old
325
+ // tool snapshot even though the command appeared successful.
326
+ if (port !== undefined && port === activePort) return refreshIdeTools();
224
327
  await deactivateIde();
225
- if (port !== undefined) await activateIde(port);
328
+ if (port === undefined) return false;
329
+ return activateIde(port);
226
330
  };
227
331
 
228
332
  // Serialize reconciles so overlapping callers can never register/activate
@@ -237,6 +341,25 @@ export default {
237
341
  return next;
238
342
  };
239
343
 
344
+ /**
345
+ * `open -a` only requests activation/opening. A cold IDE needs time before
346
+ * its bundled MCP server listens, so poll until the endpoint appears. This
347
+ * is the step that makes a single `/open-in-idea` enough on a cold start.
348
+ *
349
+ * @returns {Promise<number | undefined>}
350
+ */
351
+ const waitForIdeServer = async () => {
352
+ const deadline = Date.now() + mcpStartTimeoutMs;
353
+ while (Date.now() < deadline) {
354
+ const port = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
355
+ if (port !== undefined) return port;
356
+ const remaining = deadline - Date.now();
357
+ if (remaining <= 0) break;
358
+ await new Promise((resolve) => setTimeout(resolve, Math.min(MCP_START_POLL_MS, remaining)));
359
+ }
360
+ return undefined;
361
+ };
362
+
240
363
  // Re-entrant: repeat invocations share one open request so the IDE is not
241
364
  // spawned twice while the application is starting.
242
365
  /** @type {Promise<IdeCommandResult> | undefined} */
@@ -277,9 +400,15 @@ export default {
277
400
  guard.markSpawn();
278
401
  const launched = await openInIde(projectPath, { app: ideApp });
279
402
  if (!launched) return { status: 'launch-failed' };
280
- // `open -a` only requests activation/opening. Fast-fail if the MCP
281
- // endpoint is not already available; the user can enable it in IDEA
282
- // and run `/open-in-idea` again instead of waiting on a timer.
403
+ // `open -a` only requests activation/opening. A cold IDE needs time to
404
+ // start its bundled MCP server, so wait for the endpoint instead of
405
+ // reporting failure immediately.
406
+ const started = await waitForIdeServer();
407
+ if (started !== undefined) {
408
+ guard.reset();
409
+ launchNoticeSent = false;
410
+ return { status: 'connected', port: started, unavailableNoticeSent: false };
411
+ }
283
412
  await reportUnavailable(true);
284
413
  return { status: 'mcp-unavailable', opened: true, unavailableNoticeSent: launchNoticeSent };
285
414
  })();
@@ -328,36 +457,59 @@ export default {
328
457
  return unavailableNoticeSent;
329
458
  },
330
459
  });
331
- const { port, status, unavailableNoticeSent: resultNoticeSent } = result;
460
+ const { port } = result;
461
+ let toolsReady = false;
332
462
  try {
333
463
  // Do not repeat the expensive probe when the preflight already
334
464
  // found no MCP endpoint. The command will retry after the user
335
465
  // enables MCP + Brave Mode.
336
- await reconcile({ probeIde: port !== undefined });
466
+ toolsReady = (await reconcile({ probeIde: port !== undefined })) === true;
337
467
  } catch {
338
468
  // capability load is best effort; feedback still matters
339
469
  }
470
+ const finalResult = result.status === 'connected' && !toolsReady
471
+ ? { ...result, status: 'tools-loading' }
472
+ : result;
340
473
  // The unavailable notice is the final result for this command. If
341
474
  // it was already delivered, do not show the same warning twice.
342
- const unavailableAlreadySent = unavailableNoticeSent || resultNoticeSent;
343
- if (!unavailableAlreadySent || status !== 'mcp-unavailable') {
344
- await sendFeedback(input, result);
475
+ const unavailableAlreadySent = unavailableNoticeSent || result.unavailableNoticeSent;
476
+ if (!unavailableAlreadySent || finalResult.status !== 'mcp-unavailable') {
477
+ await sendFeedback(input, finalResult);
345
478
  }
346
479
  },
347
480
  });
348
481
  });
349
482
  }
350
483
 
351
- // Direct-call guidance: while the IDEA MCP is available, append concise
352
- // routing rules to every primary request. Native tools are left untouched.
353
- if (injectGuidance && typeof ctx.session?.hook === 'function') {
354
- sessionRegistration = await ctx.session.hook('context', (event) => {
484
+ // Every model request re-checks a manually activated IDE. If the endpoint
485
+ // is gone, drop IDEA tools from this request and from the registry so only
486
+ // a new `/open-in-idea` can bring them back. Guidance is appended only for
487
+ // an IDE that is still alive.
488
+ if (typeof ctx.session?.hook === 'function') {
489
+ sessionRegistration = await ctx.session.hook('context', async (event) => {
490
+ const ideActive = activePort !== undefined && activeToolsReady;
491
+ const available = ideActive ? await verifyActiveIde() : false;
492
+ const tools = event?.tools;
493
+ if (!available) removeIdeaToolDefinitions(tools);
355
494
  appendIdeRecoveryGuidance(event?.system);
356
- if (activePort === undefined) return;
495
+ if (!injectGuidance) return;
496
+ if (!available) return;
357
497
  appendIdeGuidance(event?.system, projectPath);
358
498
  });
359
499
  }
360
500
 
501
+ // A failed IDEA MCP call is also a signal that the IDE endpoint is gone.
502
+ // Unregister immediately so later requests cannot keep calling a dead tool.
503
+ if (typeof ctx.tool?.hook === 'function') {
504
+ toolRegistration = await ctx.tool.hook('execute.after', async (event) => {
505
+ if (typeof event?.tool !== 'string' || !event.tool.startsWith('idea_')) return;
506
+ if (event.status !== 'error') return;
507
+ await deactivateIde();
508
+ if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
509
+ if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
510
+ });
511
+ }
512
+
361
513
  return async () => {
362
514
  disposed = true;
363
515
  if (shellRegistration) {
@@ -381,6 +533,13 @@ export default {
381
533
  // best effort
382
534
  }
383
535
  }
536
+ if (toolRegistration) {
537
+ try {
538
+ await toolRegistration.dispose();
539
+ } catch {
540
+ // best effort
541
+ }
542
+ }
384
543
  await deactivateIde();
385
544
  };
386
545
  },