opencode-idea 0.0.22 → 0.0.24

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/src/plugin.js CHANGED
@@ -51,38 +51,38 @@
51
51
  // `{ id, setup }` object is the whole plugin contract. Exporting it directly
52
52
  // keeps this package dependency-free at runtime.
53
53
 
54
- import { existsSync } from 'node:fs';
55
- import { resolve } from 'node:path';
54
+ import {existsSync} from 'node:fs';
55
+ import {resolve} from 'node:path';
56
56
 
57
- import { callTool, serverConfig } from './mcp/idea.js';
58
- import { applyIdeToolDefaults, hideForcedArguments } from './mcp/idea-tool-defaults.js';
59
- import { currentProjectPath } from './project.js';
57
+ import {callTool, serverConfig} from './mcp/idea.js';
58
+ import {applyIdeToolDefaults, hideForcedArguments} from './mcp/idea-tool-defaults.js';
59
+ import {currentProjectPath} from './project.js';
60
60
  import {
61
- appNameFor,
62
- autoLaunchSupported,
63
- createLaunchGuard,
64
- detectInstalledIdes,
65
- openInIde,
61
+ appNameFor,
62
+ autoLaunchSupported,
63
+ createLaunchGuard,
64
+ detectInstalledIdes,
65
+ openInIde,
66
66
  } from './ide-launcher.js';
67
67
  import {
68
- appendIdeGuidance,
69
- appendIdeRecoveryGuidance,
68
+ appendIdeGuidance,
69
+ appendIdeRecoveryGuidance,
70
70
  } from './ide-guidance.js';
71
71
  import {
72
- IDE_LABELS,
73
- IDE_PORTS,
74
- SUPPORTED_IDES,
75
- listProjects,
76
- markKeyFor,
77
- parseIdeArgument,
78
- parseMark,
79
- parseSubprojectArgument,
80
- serializeMark,
81
- toolPrefixes,
82
- validateSubproject,
83
- withSubproject,
72
+ IDE_LABELS,
73
+ IDE_PORTS,
74
+ SUPPORTED_IDES,
75
+ listProjects,
76
+ markKeyFor,
77
+ parseIdeArgument,
78
+ parseMark,
79
+ parseSubprojectArgument,
80
+ serializeMark,
81
+ toolPrefixes,
82
+ validateSubproject,
83
+ withSubproject,
84
84
  } from './ide-projects.js';
85
- import { readSkills } from './skill/loader.js';
85
+ import {readSkills} from './skill/loader.js';
86
86
 
87
87
  const DEFAULT_LAUNCH_COOLDOWN_MS = 120000;
88
88
  const DEFAULT_LAUNCH_MAX_ATTEMPTS = 5;
@@ -145,13 +145,22 @@ const PLUGIN_TOOL_NAMES = new Set(['configure_ide_project']);
145
145
  * user configures data sources in the IDE. The query tools are hidden too —
146
146
  * the IDE is not this plugin's idea of a database client, and a model that
147
147
  * wants to inspect a database should be told to use a real one.
148
- * - Plain file access: `search_text` / `search_regex` / `search_file` /
149
- * `apply_patch` / `create_new_file` each have a native tool that does the job
150
- * (`grep` / `grep -E` / `glob` / `edit` / `write`); routing them through the
151
- * IDE only adds a hop. `read_file` is NOT hidden — it is the only way to read
152
- * a file inside a jar (`<jar>!/<entry>`) or the JDK's `jrt://` image, so the
153
- * guidance keeps it for exactly that and sends ordinary reads to the native
154
- * tool.
148
+ * - Plain file **search**: `search_text` / `search_regex` / `search_file` each
149
+ * have a native tool that does the job (`grep` / `grep -E` / `glob`);
150
+ * routing them through the IDE only adds a hop. `read_file` is NOT hidden —
151
+ * it is the only way to read a file inside a jar (`<jar>!/<entry>`) or the
152
+ * JDK's `jrt://` image, so the guidance keeps it for exactly that and sends
153
+ * ordinary reads to the native tool.
154
+ * - Plain file **write**: `apply_patch` / `create_new_file` are **kept**, and
155
+ * they are the ones the guidance sends writes to. The native `write` / `edit`
156
+ * write from **outside** the IDE, and the IDE keeps an in-memory copy of every
157
+ * file it has open — that copy is only re-read when the VFS refreshes, so
158
+ * between the write and the refresh the IDE still sees the OLD text. Measured
159
+ * against a real IDE: after an external write, `read_file` still returned the
160
+ * old content; after `apply_patch` it returned the new one immediately. That
161
+ * gap is not cosmetic — `reformat_file` formats the in-memory copy and
162
+ * **writes it back**, silently discarding whatever was just written. Writing
163
+ * through the IDE (`apply_patch`) keeps the two copies the same thing.
155
164
  * - IDE terminal: `execute_terminal_command` does NOT inherit the project's
156
165
  * environment. It was exposed on the assumption that it ran with the SDKs the
157
166
  * user configured in the IDE (JDK, Node, Python, ...), but measured against a
@@ -164,65 +173,71 @@ const PLUGIN_TOOL_NAMES = new Set(['configure_ide_project']);
164
173
  * through the native shell.
165
174
  */
166
175
  export const HIDDEN_IDE_TOOL_SUFFIXES = [
167
- // VCS
168
- 'git_status',
169
- 'get_repositories',
170
- // Router
171
- 'execute_tool',
172
- // IDE terminal: no advantage over the native shell (see the note above).
173
- 'execute_terminal_command',
174
- // Debugger
175
- 'xdebug_control_session',
176
- 'xdebug_evaluate_expression',
177
- 'xdebug_get_debugger_status',
178
- 'xdebug_get_frame_values',
179
- 'xdebug_get_stack',
180
- 'xdebug_get_threads',
181
- 'xdebug_get_value_by_path',
182
- 'xdebug_list_breakpoints',
183
- 'xdebug_remove_breakpoint',
184
- 'xdebug_run_to_line',
185
- 'xdebug_set_breakpoint',
186
- 'xdebug_set_variable',
187
- 'xdebug_start_debugger_session',
188
- // Dev Kit MCP
189
- 'collect_split_mode_compatibility_issues',
190
- 'create_ij_module',
191
- 'find_lock_requirements_usages',
192
- 'find_threading_requirements_usages',
193
- 'recognize_ij_module_kind',
194
- 'recognize_split_mode_api_kind',
195
- // Inspection KTS MCP
196
- 'generate_inspection_kts_api',
197
- 'generate_inspection_kts_examples',
198
- 'generate_psi_tree',
199
- 'run_inspection_kts',
200
- // Python Environment MCP
201
- 'configure_python_interpreter',
202
- 'get_python_environment',
203
- // Database: connections are the user's, and the IDE is not this plugin's
204
- // database client.
205
- 'create_database_connection',
206
- 'edit_database_connection',
207
- 'list_database_connections',
208
- 'test_database_connection',
209
- 'list_database_schemas',
210
- 'list_schema_object_kinds',
211
- 'list_schema_objects',
212
- 'introspect_schema',
213
- 'get_database_object_description',
214
- 'execute_sql_query',
215
- 'fetch_query_result',
216
- 'preview_table_data',
217
- 'list_recent_sql_queries',
218
- 'cancel_sql_query',
219
- // Plain file access with a native equivalent (`read_file` stays: only it can
220
- // read inside a jar or the jrt:// image).
221
- 'search_text',
222
- 'search_regex',
223
- 'search_file',
224
- 'apply_patch',
225
- 'create_new_file',
176
+ // VCS
177
+ 'git_status',
178
+ 'get_repositories',
179
+ // Router
180
+ 'execute_tool',
181
+ // IDE terminal: no advantage over the native shell (see the note above).
182
+ 'execute_terminal_command',
183
+ // Debugger
184
+ 'xdebug_control_session',
185
+ 'xdebug_evaluate_expression',
186
+ 'xdebug_get_debugger_status',
187
+ 'xdebug_get_frame_values',
188
+ 'xdebug_get_stack',
189
+ 'xdebug_get_threads',
190
+ 'xdebug_get_value_by_path',
191
+ 'xdebug_list_breakpoints',
192
+ 'xdebug_remove_breakpoint',
193
+ 'xdebug_run_to_line',
194
+ 'xdebug_set_breakpoint',
195
+ 'xdebug_set_variable',
196
+ 'xdebug_start_debugger_session',
197
+ // Dev Kit MCP
198
+ 'collect_split_mode_compatibility_issues',
199
+ 'create_ij_module',
200
+ 'find_lock_requirements_usages',
201
+ 'find_threading_requirements_usages',
202
+ 'recognize_ij_module_kind',
203
+ 'recognize_split_mode_api_kind',
204
+ // Inspection KTS MCP
205
+ 'generate_inspection_kts_api',
206
+ 'generate_inspection_kts_examples',
207
+ 'generate_psi_tree',
208
+ 'run_inspection_kts',
209
+ // Python Environment MCP
210
+ 'configure_python_interpreter',
211
+ 'get_python_environment',
212
+ // Database: connections are the user's, and the IDE is not this plugin's
213
+ // database client.
214
+ 'create_database_connection',
215
+ 'edit_database_connection',
216
+ 'list_database_connections',
217
+ 'test_database_connection',
218
+ 'list_database_schemas',
219
+ 'list_schema_object_kinds',
220
+ 'list_schema_objects',
221
+ 'introspect_schema',
222
+ 'get_database_object_description',
223
+ 'execute_sql_query',
224
+ 'fetch_query_result',
225
+ 'preview_table_data',
226
+ 'list_recent_sql_queries',
227
+ 'cancel_sql_query',
228
+ // Plain file SEARCH with a native equivalent (`read_file` stays: only it can
229
+ // read inside a jar or the jrt:// image). Plain file WRITE stays visible on
230
+ // purpose — see the note above: writing through the IDE is what keeps the
231
+ // IDE's in-memory copy and the file on disk the same thing.
232
+ 'search_text',
233
+ 'search_regex',
234
+ 'search_file',
235
+ // The companion plugin's own write tool. The IDE's built-in `apply_patch` /
236
+ // `create_new_file` already cover writing through the IDE, so this one is
237
+ // hidden: two tools for the same job is a worse answer than one. The plugin
238
+ // has dropped it as well — this line only hides the copies still served by
239
+ // the plugin version installed before that.
240
+ 'edit_file',
226
241
  ];
227
242
 
228
243
  /**
@@ -236,22 +251,22 @@ export const HIDDEN_IDE_TOOL_SUFFIXES = [
236
251
  * @param {readonly string[]} prefixes 当前所有项目的工具前缀(含下划线)。
237
252
  */
238
253
  function removeIdeToolDefinitions(tools, suffixes, prefixes) {
239
- if (!tools || typeof tools !== 'object') return 0;
240
- let removed = 0;
241
- for (const prefix of prefixes) {
242
- for (const suffix of suffixes) {
243
- const name = `${prefix}${suffix}`;
244
- if (!(name in tools)) continue;
245
- delete tools[name];
246
- removed += 1;
254
+ if (!tools || typeof tools !== 'object') return 0;
255
+ let removed = 0;
256
+ for (const prefix of prefixes) {
257
+ for (const suffix of suffixes) {
258
+ const name = `${prefix}${suffix}`;
259
+ if (!(name in tools)) continue;
260
+ delete tools[name];
261
+ removed += 1;
262
+ }
247
263
  }
248
- }
249
- return removed;
264
+ return removed;
250
265
  }
251
266
 
252
267
  /** @param {ReadonlyArray<{ id?: string }> | undefined} tools */
253
268
  function toolIds(tools) {
254
- return (tools ?? []).map((tool) => tool.id).filter((id) => typeof id === 'string').sort();
269
+ return (tools ?? []).map((tool) => tool.id).filter((id) => typeof id === 'string').sort();
255
270
  }
256
271
 
257
272
  /**
@@ -275,53 +290,53 @@ function toolIds(tools) {
275
290
  * @returns {'project' | 'mcp' | 'none'}
276
291
  */
277
292
  function classifyIdeFailure(error, serverNames) {
278
- const message = typeof error?.message === 'string' ? error.message : '';
279
- // "Project not open in the IDE" comes in two shapes, both meaning the same
280
- // thing and both fixed by opening the project again:
281
- // - the server could not pick a project for the call: "Unable to determine
282
- // the target project for the current MCP tool call." (the project path in
283
- // the request header did not resolve — the IDE may still be opening it)
284
- // - the project was resolved but is not open: "`projectPath`=...
285
- // doesn't correspond to any open project."
286
- if (
287
- message.includes('Unable to determine the target project') ||
288
- message.includes('correspond to any open project')
289
- ) {
290
- return 'project';
291
- }
292
- // Only transport-level markers, which OpenCode's own MCP client emits. The
293
- // message of an MCP *tool error* is the tool's own content (`tool/mcp.ts`
294
- // turns `result.isError` into `ToolFailure({ message: <text> })`), so a
295
- // generic string such as "Connection refused" from a SQL or run-config tool
296
- // would otherwise be misread as a dropped connection and rebuild a healthy
297
- // server.
298
- //
299
- // MEASURED (2026-09-30, OpenCode 2.0.19, IDE process killed): a dead remote
300
- // MCP endpoint does NOT produce any of the shapes above. OpenCode's client
301
- // reports its own connect failure instead:
302
- //
303
- // level=WARN message="mcp http request failed"
304
- // errors="[{\"type\":\"TypeError\",
305
- // \"message\":\"Unable to connect. Is the computer able to access the url?\",
306
- // \"code\":\"ConnectionRefused\",\"errno\":0}]"
307
- //
308
- // so the text is matched on its stable prefix, and the underlying `code` is
309
- // accepted as a structural signal too (a wording change must not silently
310
- // disable the heal).
311
- //
312
- // serverNames 是当前注册的全部 server 名(`ide` / 各子项目前缀):错误里点名的是
313
- // 哪一个 server,取决于失败的是哪个项目的工具,所以只能逐个匹配。
314
- if (
315
- serverNames.some((name) => message.includes(`MCP server "${name}" is not available`)) ||
316
- message.includes('MCP server is not connected') ||
317
- message.includes('Error POSTing to endpoint') ||
318
- message.includes('Unable to connect')
319
- ) {
320
- return 'mcp';
321
- }
322
- const causeCode = error?.error?.code ?? error?.cause?.code ?? error?.code;
323
- if (causeCode === 'ConnectionRefused' || causeCode === 'ECONNREFUSED') return 'mcp';
324
- return 'none';
293
+ const message = typeof error?.message === 'string' ? error.message : '';
294
+ // "Project not open in the IDE" comes in two shapes, both meaning the same
295
+ // thing and both fixed by opening the project again:
296
+ // - the server could not pick a project for the call: "Unable to determine
297
+ // the target project for the current MCP tool call." (the project path in
298
+ // the request header did not resolve — the IDE may still be opening it)
299
+ // - the project was resolved but is not open: "`projectPath`=...
300
+ // doesn't correspond to any open project."
301
+ if (
302
+ message.includes('Unable to determine the target project') ||
303
+ message.includes('correspond to any open project')
304
+ ) {
305
+ return 'project';
306
+ }
307
+ // Only transport-level markers, which OpenCode's own MCP client emits. The
308
+ // message of an MCP *tool error* is the tool's own content (`tool/mcp.ts`
309
+ // turns `result.isError` into `ToolFailure({ message: <text> })`), so a
310
+ // generic string such as "Connection refused" from a SQL or run-config tool
311
+ // would otherwise be misread as a dropped connection and rebuild a healthy
312
+ // server.
313
+ //
314
+ // MEASURED (2026-09-30, OpenCode 2.0.19, IDE process killed): a dead remote
315
+ // MCP endpoint does NOT produce any of the shapes above. OpenCode's client
316
+ // reports its own connect failure instead:
317
+ //
318
+ // level=WARN message="mcp http request failed"
319
+ // errors="[{\"type\":\"TypeError\",
320
+ // \"message\":\"Unable to connect. Is the computer able to access the url?\",
321
+ // \"code\":\"ConnectionRefused\",\"errno\":0}]"
322
+ //
323
+ // so the text is matched on its stable prefix, and the underlying `code` is
324
+ // accepted as a structural signal too (a wording change must not silently
325
+ // disable the heal).
326
+ //
327
+ // serverNames 是当前注册的全部 server 名(`ide` / 各子项目前缀):错误里点名的是
328
+ // 哪一个 server,取决于失败的是哪个项目的工具,所以只能逐个匹配。
329
+ if (
330
+ serverNames.some((name) => message.includes(`MCP server "${name}" is not available`)) ||
331
+ message.includes('MCP server is not connected') ||
332
+ message.includes('Error POSTing to endpoint') ||
333
+ message.includes('Unable to connect')
334
+ ) {
335
+ return 'mcp';
336
+ }
337
+ const causeCode = error?.error?.code ?? error?.cause?.code ?? error?.code;
338
+ if (causeCode === 'ConnectionRefused' || causeCode === 'ECONNREFUSED') return 'mcp';
339
+ return 'none';
325
340
  }
326
341
 
327
342
  /**
@@ -338,26 +353,26 @@ function classifyIdeFailure(error, serverNames) {
338
353
  * @returns {boolean}
339
354
  */
340
355
  function rewriteToolError(event, message) {
341
- const current = event?.error;
342
- if (!current || typeof current !== 'object') return false;
343
- try {
344
- const Ctor = current.constructor;
345
- if (typeof Ctor === 'function') {
346
- const next = { message };
347
- if (current.error !== undefined) next.error = current.error;
348
- if (current.metadata !== undefined) next.metadata = current.metadata;
349
- event.error = new Ctor(next);
350
- return true;
356
+ const current = event?.error;
357
+ if (!current || typeof current !== 'object') return false;
358
+ try {
359
+ const Ctor = current.constructor;
360
+ if (typeof Ctor === 'function') {
361
+ const next = {message};
362
+ if (current.error !== undefined) next.error = current.error;
363
+ if (current.metadata !== undefined) next.metadata = current.metadata;
364
+ event.error = new Ctor(next);
365
+ return true;
366
+ }
367
+ } catch {
368
+ // fall through to mutation
369
+ }
370
+ try {
371
+ current.message = message;
372
+ return true;
373
+ } catch {
374
+ return false;
351
375
  }
352
- } catch {
353
- // fall through to mutation
354
- }
355
- try {
356
- current.message = message;
357
- return true;
358
- } catch {
359
- return false;
360
- }
361
376
  }
362
377
 
363
378
  /**
@@ -391,47 +406,47 @@ function rewriteToolError(event, message) {
391
406
  * @param {number} fallback
392
407
  */
393
408
  const asPositiveNumber = (value, fallback) =>
394
- typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
409
+ typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
395
410
 
396
411
  /** 面向用户的状态文案(三个命令与 `configure_ide_project` 共用)。 */
397
412
  /** @param {IdeCommandResult} result */
398
413
  export function ideFeedback(result) {
399
- const appOf = (project) => IDE_LABELS[project?.app] ?? 'IDE';
400
- const nameOf = (project) =>
401
- project?.isRoot === false ? `${appOf(project)}(子项目 ${project.label ?? ''})` : appOf(project);
402
- switch (result?.status) {
403
- case 'connected': {
404
- const list = (result.projects ?? []).map((project) => `${project.label}(${appOf(project)})`).join('、');
405
- return list ? `IDE MCP 已连接并接入:${list}。` : 'IDE MCP 已连接。';
406
- }
407
- case 'tools-loading':
408
- return 'IDE MCP 已连接,但原生 IDE 工具列表尚未完成注册。请稍后再次执行 /open-in-ide。';
409
- case 'mcp-unavailable': {
410
- const where = nameOf(result?.project);
411
- const ide = appOf(result?.project);
412
- return result?.opened
413
- ? `已请求打开/激活 ${where} 并等待 MCP 服务启动,但未检测到 MCP 服务。请在 ${ide} 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-ide。`
414
- : `未检测到 ${where} 的 MCP 服务。请确认已打开,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-ide。`;
414
+ const appOf = (project) => IDE_LABELS[project?.app] ?? 'IDE';
415
+ const nameOf = (project) =>
416
+ project?.isRoot === false ? `${appOf(project)}(子项目 ${project.label ?? ''})` : appOf(project);
417
+ switch (result?.status) {
418
+ case 'connected': {
419
+ const list = (result.projects ?? []).map((project) => `${project.label}(${appOf(project)})`).join('、');
420
+ return list ? `IDE MCP 已连接并接入:${list}。` : 'IDE MCP 已连接。';
421
+ }
422
+ case 'tools-loading':
423
+ return 'IDE MCP 已连接,但原生 IDE 工具列表尚未完成注册。请稍后再次执行 /open-in-ide。';
424
+ case 'mcp-unavailable': {
425
+ const where = nameOf(result?.project);
426
+ const ide = appOf(result?.project);
427
+ return result?.opened
428
+ ? `已请求打开/激活 ${where} 并等待 MCP 服务启动,但未检测到 MCP 服务。请在 ${ide} 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-ide。`
429
+ : `未检测到 ${where} 的 MCP 服务。请确认已打开,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-ide。`;
430
+ }
431
+ case 'launch-failed':
432
+ return `无法拉起 ${nameOf(result?.project)}(未找到应用或启动失败)。请确认它已安装 —— 没装的话先装上,再重试 /open-in-ide。`;
433
+ case 'disabled':
434
+ return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-ide。';
435
+ case 'manual-launch':
436
+ return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-ide。';
437
+ case 'deleted':
438
+ return '已删除本项目的 IDE 配置:已注销全部 IDE MCP 并清除配置。如需重新接入,请执行 /open-in-ide。';
439
+ case 'bad-argument':
440
+ return `命令参数有误:${result?.message ?? ''}`;
441
+ case 'not-configured':
442
+ return '当前项目还没有 IDE 配置。请先执行 /open-in-ide 接入根项目,再登记子项目。';
443
+ case 'invalid-subproject':
444
+ return `无法登记子项目:${result?.message ?? ''}`;
445
+ case 'needs-ide':
446
+ return '需要确定用哪个 IDE 打开。请回答 AI 的提问(IntelliJ IDEA 或 PyCharm),AI 会把选择写入配置。';
447
+ default:
448
+ return '已处理。';
415
449
  }
416
- case 'launch-failed':
417
- return `无法拉起 ${nameOf(result?.project)}(未找到应用或启动失败)。请确认它已安装 —— 没装的话先装上,再重试 /open-in-ide。`;
418
- case 'disabled':
419
- return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-ide。';
420
- case 'manual-launch':
421
- return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-ide。';
422
- case 'deleted':
423
- return '已删除本项目的 IDE 配置:已注销全部 IDE MCP 并清除配置。如需重新接入,请执行 /open-in-ide。';
424
- case 'bad-argument':
425
- return `命令参数有误:${result?.message ?? ''}`;
426
- case 'not-configured':
427
- return '当前项目还没有 IDE 配置。请先执行 /open-in-ide 接入根项目,再登记子项目。';
428
- case 'invalid-subproject':
429
- return `无法登记子项目:${result?.message ?? ''}`;
430
- case 'needs-ide':
431
- return '需要确定用哪个 IDE 打开。请回答 AI 的提问(IntelliJ IDEA 或 PyCharm),AI 会把选择写入配置。';
432
- default:
433
- return '已处理。';
434
- }
435
450
  }
436
451
 
437
452
  /**
@@ -443,1139 +458,1061 @@ export function ideFeedback(result) {
443
458
  export const NOTICE_PREFIX = '【插件通知】';
444
459
 
445
460
  export function idePromptText(result) {
446
- return [`${NOTICE_PREFIX}${ideFeedback(result)}`, '这是发给用户的通知,你无需处理。请只回复"收到"。'].join('\n\n');
461
+ return [`${NOTICE_PREFIX}${ideFeedback(result)}`, '这是发给用户的通知,你无需处理。请只回复"收到"。'].join('\n\n');
447
462
  }
448
463
 
449
464
  /** `metadata` so the UI shows the clean notice while the model sees the instruction. */
450
465
  export function idePromptMetadata(result) {
451
- return { displayText: ideFeedback(result), comments: [] };
466
+ return {displayText: ideFeedback(result), comments: []};
452
467
  }
453
468
 
454
469
  export default {
455
- id: 'opencode-idea',
456
-
457
- /**
458
- * @param {{
459
- * location?: unknown,
460
- * options?: {
461
- * openInIde?: boolean,
462
- * launchCooldownMs?: number,
463
- * launchMaxAttempts?: number,
464
- * injectGuidance?: boolean,
465
- * mcpProbeTimeoutMs?: number,
466
- * mcpStartTimeoutMs?: number,
467
- * toolRefreshTimeoutMs?: number,
468
- * executionTimeoutMs?: number,
469
- * feedback?: false | 'message',
470
- * },
471
- * command?: {
472
- * transform: (cb: (editor: { add: (definition: {
473
- * name: string,
474
- * description?: string,
475
- * execute: (input: { sessionID: string, prompt: { text: string }, delivery: unknown }) => Promise<void>,
476
- * }) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
477
- * },
478
- * skill?: {
479
- * transform: (cb: (editor: { add: (definition: {
480
- * id: string,
481
- * name: string,
482
- * description?: string,
483
- * autoinvoke?: boolean,
484
- * path: string,
485
- * content: string,
486
- * }) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
487
- * },
488
- * session?: {
489
- * prompt: (input: { sessionID: string, text: string, delivery: unknown }) => Promise<unknown>,
490
- * hook: (name: string, cb: (event: {
491
- * sessionID?: string,
492
- * agent?: string,
493
- * system?: unknown[],
494
- * tools?: Record<string, unknown>,
495
- * }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
496
- * },
497
- * tool?: {
498
- * list?: () => Promise<ReadonlyArray<{ id?: string }>>,
499
- * reload?: () => Promise<void>,
500
- * transform?: (cb: (editor: {
501
- * list: () => ReadonlyArray<{ id?: string, input?: unknown }>,
502
- * get: (id: string) => ({ id?: string, input?: unknown } | undefined),
503
- * add: (definition: unknown) => void,
504
- * update: (id: string, update: (tool: { input?: unknown, description?: string }) => void) => void,
505
- * remove: (id: string) => void,
506
- * }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
507
- * hook?: (name: string, cb: (event: {
508
- * tool?: string,
509
- * sessionID?: string,
510
- * input?: Record<string, unknown>,
511
- * status?: string,
512
- * result?: unknown,
513
- * error?: unknown,
514
- * }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
515
- * },
516
- * mcp: {
517
- * transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
518
- * reload?: () => Promise<void>,
519
- * list?: () => Promise<{ data?: ReadonlyArray<{ name?: string, status?: { status?: string } }> }>,
520
- * },
521
- * shell: { hook: (name: string, cb: (input: { env: Record<string, string | undefined> }) => void) => Promise<{ dispose: () => Promise<void> | void }> },
522
- * storage?: {
523
- * get: (key: string) => Promise<unknown>,
524
- * set: (key: string, value: unknown) => Promise<void>,
525
- * remove: (key: string) => Promise<void>,
526
- * },
527
- * }} ctx
528
- */
529
- async setup(ctx) {
530
- const projectPath = currentProjectPath(ctx.location);
531
- if (!projectPath) return;
532
-
533
- const options = ctx.options || {};
534
- const injectGuidance = options.injectGuidance !== false;
535
- // 自动拉起 IDE 的总开关。用哪个 IDE 由**每个项目自己的配置**决定
536
- // (`project.app`),这个选项不再指定应用名,只决定"允不允许拉起"。
537
- const launchEnabled = options.openInIde !== false;
538
- const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
539
- const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
540
- const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
541
- // Cap on a single IDE tool call, so a wedged server fails in finite time
542
- // instead of OpenCode's 12h default. See DEFAULT_EXECUTION_TIMEOUT_MS.
543
- const executionTimeoutMs = asPositiveNumber(options.executionTimeoutMs, DEFAULT_EXECUTION_TIMEOUT_MS);
544
- // "message" (default) injects a clearly-labelled notification that the
545
- // model only acknowledges ("收到"); the user sees the clean notice via
546
- // metadata.displayText. false is silent.
547
- const feedback = options.feedback === false ? false : 'message';
548
- const launchCooldownMs = asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS);
549
- const launchMaxAttempts = asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS);
550
-
551
- /** @type {Array<{ dispose: () => Promise<void> | void }>} */
552
- let registrations = [];
553
- /** @type {{ dispose: () => Promise<void> | void } | undefined} */
554
- let sessionRegistration;
555
- /** @type {{ dispose: () => Promise<void> | void } | undefined} */
556
- let toolRegistration;
557
- let toolBeforeRegistration;
558
- let toolTransformRegistration;
559
- /** `configure_ide_project` 的注册。 */
560
- let configToolRegistration;
561
- /** @type {{ dispose: () => Promise<void> | void } | undefined} */
562
- let promptRegistration;
563
- /** @type {{ dispose: () => Promise<void> | void } | undefined} */
564
- let commandRegistration;
565
- /** @type {{ dispose: () => Promise<void> | void } | undefined} */
566
- let skillRegistration;
567
- /** Ids of the skills currently registered, so a sync can tell "unchanged". */
568
- let registeredSkillIds = [];
569
- let disposed = false;
570
-
571
- // Latches when a request showed the IDE tools are gone. It is cleared
572
- // only by a successful rebuild. It is set from the `context` hook (a request
573
- // that HAD the tools no longer does); the `prompt` hook also heals without
574
- // this latch when the instance has no registration at all.
575
- let needsRebuild = false;
576
- // Set once a request in this session actually carried IDE tools, so a
577
- // later request without them is a DROP of this session's own connection —
578
- // not a root that simply never connected.
579
- let wasServing = false;
580
-
581
- // 本会话的 IDE 配置:root 用哪个 IDE,以及登记了哪些子项目、各自用哪个 IDE。
582
- // 一个插件实例服务一个 root(OpenCode 按 Location 激活插件),所以这就是全部
583
- // 状态。持久化在 `ctx.storage` 的 `project/<rootPath>` 下。
584
- //
585
- // `undefined` = 还没读过;`null` = 读过、但没有配置。两者必须分开:混成一种,
586
- // 请求钩子就会把"还没读"当成"未配置",漏掉一次本该发生的自愈。
587
- const storage = ctx.storage;
588
- const markKey = markKeyFor(projectPath);
589
- /** @type {import('./ide-projects.js').IdeConfig | null | undefined} */
590
- let config;
591
-
592
- /** 读一次持久化配置(最多一次 I/O),之后走内存缓存。 */
593
- const readConfig = async () => {
594
- if (config !== undefined) return config;
595
- if (!storage || typeof storage.get !== 'function') {
596
- config = null;
597
- return config;
598
- }
599
- try {
600
- config = parseMark(await storage.get(markKey), projectPath) ?? null;
601
- } catch {
602
- // storage is best effort;读不到就当作未配置
603
- config = null;
604
- }
605
- return config;
606
- };
607
-
608
- /**
609
- * 写入配置(`null` = 删除),并同步内存缓存。
610
- *
611
- * 先写 storage 再更新缓存(write-through):写失败也要更新缓存,否则用户刚做的
612
- * 配置在本进程里不可见,行为会自相矛盾。命令与 `configure_ide_project` 是唯一的
613
- * 写入者。
614
- *
615
- * @param {import('./ide-projects.js').IdeConfig | null} next
616
- */
617
- const writeConfig = async (next) => {
618
- try {
619
- if (next) await storage?.set?.(markKey, serializeMark(next));
620
- else await storage?.remove?.(markKey);
621
- } catch {
622
- // best effort
623
- }
624
- config = next;
625
- // skill 生命周期跟随配置:配置在就注册 IDEA skill,删掉配置就移除。
626
- await syncSkills();
627
- };
628
-
629
- /** 当前接入的全部项目(root 在前),每个带工具前缀与展示标签。 */
630
- const currentProjects = () =>
631
- config
632
- ? listProjects(config).map((project) => ({
633
- ...project,
634
- label: project.isRoot ? '.' : project.path.slice(projectPath.length + 1),
635
- }))
636
- : [];
637
-
638
- /** 当前所有项目的工具前缀(含下划线);未配置时为空。 */
639
- const prefixes = () => (config ? toolPrefixes(config) : []);
640
-
641
- /** 当前注册的全部 server 名(= 前缀去掉下划线)。 */
642
- const serverNames = () => (config ? listProjects(config).map((project) => project.server) : []);
643
-
644
- /** 这个工具名是否属于某个 IDE server(插件自己的工具不算)。 */
645
- const isIdeToolName = (name) => {
646
- if (PLUGIN_TOOL_NAMES.has(name)) return false;
647
- return prefixes().some((prefix) => name.startsWith(prefix));
648
- };
649
-
650
- /**
651
- * 这个 IDE 工具属于哪个项目?
652
- *
653
- * 按**最长前缀**匹配:`modelserver_` 必须先于 `model_` 命中,否则一个叫 `model`
654
- * 的项目会把 `modelserver_*` 的工具认领走。
655
- *
656
- * @param {string} name
657
- */
658
- const projectForTool = (name) =>
659
- currentProjects()
660
- .filter((project) => name.startsWith(`${project.server}_`))
661
- .sort((a, b) => b.server.length - a.server.length)[0];
662
-
663
- // 先解析一次,请求钩子之后就能同步读到。
664
- await readConfig();
665
-
666
- /** 注销当前注册的全部 IDE server。 */
667
- const deactivateIde = async () => {
668
- const current = registrations.slice();
669
- registrations.length = 0;
670
- if (current.length === 0) return;
671
- for (const registration of current) {
672
- try {
673
- await registration.dispose();
674
- } catch {
675
- // best effort
676
- }
677
- }
678
- };
679
-
680
- const waitForIdeTools = async () => {
681
- if (typeof ctx.tool?.list !== 'function') return false;
682
- const deadline = Date.now() + toolRefreshTimeoutMs;
683
- while (Date.now() < deadline) {
684
- try {
685
- const ids = toolIds(await ctx.tool.list());
686
- if (ids.some((id) => isIdeToolName(id))) return true;
687
- } catch {
688
- // The MCP catalog may still be settling; retry within the bounded wait.
689
- }
690
- await new Promise((resolve) => setTimeout(resolve, TOOL_REFRESH_POLL_MS));
691
- }
692
- return false;
693
- };
470
+ id: 'opencode-idea',
694
471
 
695
472
  /**
696
- * 一个项目的 MCP 端口,由它的 IDE 唯一决定(`IDE_PORTS`,固定值)。
697
- *
698
- * @param {{ path: string, app: string }} project
699
- * @returns {number | undefined}
473
+ * @param {{
474
+ * location?: unknown,
475
+ * options?: {
476
+ * openInIde?: boolean,
477
+ * launchCooldownMs?: number,
478
+ * launchMaxAttempts?: number,
479
+ * injectGuidance?: boolean,
480
+ * mcpProbeTimeoutMs?: number,
481
+ * mcpStartTimeoutMs?: number,
482
+ * toolRefreshTimeoutMs?: number,
483
+ * executionTimeoutMs?: number,
484
+ * feedback?: false | 'message',
485
+ * },
486
+ * command?: {
487
+ * transform: (cb: (editor: { add: (definition: {
488
+ * name: string,
489
+ * description?: string,
490
+ * execute: (input: { sessionID: string, prompt: { text: string }, delivery: unknown }) => Promise<void>,
491
+ * }) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
492
+ * },
493
+ * skill?: {
494
+ * transform: (cb: (editor: { add: (definition: {
495
+ * id: string,
496
+ * name: string,
497
+ * description?: string,
498
+ * autoinvoke?: boolean,
499
+ * path: string,
500
+ * content: string,
501
+ * }) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
502
+ * },
503
+ * session?: {
504
+ * prompt: (input: { sessionID: string, text: string, delivery: unknown }) => Promise<unknown>,
505
+ * hook: (name: string, cb: (event: {
506
+ * sessionID?: string,
507
+ * agent?: string,
508
+ * system?: unknown[],
509
+ * tools?: Record<string, unknown>,
510
+ * }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
511
+ * },
512
+ * tool?: {
513
+ * list?: () => Promise<ReadonlyArray<{ id?: string }>>,
514
+ * reload?: () => Promise<void>,
515
+ * transform?: (cb: (editor: {
516
+ * list: () => ReadonlyArray<{ id?: string, input?: unknown }>,
517
+ * get: (id: string) => ({ id?: string, input?: unknown } | undefined),
518
+ * add: (definition: unknown) => void,
519
+ * update: (id: string, update: (tool: { input?: unknown, description?: string }) => void) => void,
520
+ * remove: (id: string) => void,
521
+ * }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
522
+ * hook?: (name: string, cb: (event: {
523
+ * tool?: string,
524
+ * sessionID?: string,
525
+ * input?: Record<string, unknown>,
526
+ * status?: string,
527
+ * result?: unknown,
528
+ * error?: unknown,
529
+ * }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
530
+ * },
531
+ * mcp: {
532
+ * transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
533
+ * reload?: () => Promise<void>,
534
+ * list?: () => Promise<{ data?: ReadonlyArray<{ name?: string, status?: { status?: string } }> }>,
535
+ * },
536
+ * shell: { hook: (name: string, cb: (input: { env: Record<string, string | undefined> }) => void) => Promise<{ dispose: () => Promise<void> | void }> },
537
+ * storage?: {
538
+ * get: (key: string) => Promise<unknown>,
539
+ * set: (key: string, value: unknown) => Promise<void>,
540
+ * remove: (key: string) => Promise<void>,
541
+ * },
542
+ * }} ctx
700
543
  */
701
- const portFor = (project) => IDE_PORTS[project.app];
702
-
703
- const refreshIdeTools = async () => {
704
- if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
705
- // Reconcile the MCP servers above, then replay the tool registry so the
706
- // refreshed catalog lands in the next model request's tool snapshot.
707
- // Without this, a session that already captured a snapshot keeps the old
708
- // tool list until the session is reopened.
709
- if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
710
- const ready = await waitForIdeTools();
711
- // The catalog decides which of the shipped skills may be offered, so this
712
- // is the first moment that question has an answer; see `syncSkills`.
713
- await syncSkills();
714
- return ready;
715
- };
716
-
717
- /**
718
- * 按配置给**每个**已打开的项目各注册一个 MCP server。
719
- *
720
- * server 名就是项目的工具前缀(root = `ide`,子项目 = 目录名),所以 `editor.set`
721
- * 的 key 与 `serverConfig` 里带的项目路径必须成对出现 —— 一个 server 只服务一个
722
- * 项目(路径走 header,注册后无法逐次改写)。
723
- *
724
- * @param {ReadonlyArray<{ project: { path: string, server: string }, port: number }>} bindings
725
- */
726
- const activateIde = async (bindings) => {
727
- const mcpRegistration = await ctx.mcp.transform((editor) => {
728
- for (const binding of bindings) {
729
- editor.set(
730
- binding.project.server,
731
- serverConfig(binding.port, binding.project.path, { executionTimeoutMs }),
732
- );
733
- }
734
- });
735
- registrations = [mcpRegistration];
736
- return refreshIdeTools();
737
- };
738
-
739
- const reconcileOnce = async ({ probeIde = true } = {}) => {
740
- if (disposed) return false;
741
-
742
- // 未配置:没有任何项目要接入。
743
- if (!config) {
744
- await deactivateIde();
745
- return false;
746
- }
747
- // Manual: only the commands probe the IDE. Setup calls this with
748
- // `probeIde: false` so it never connects until a command asks.
749
- if (!probeIde) {
750
- await deactivateIde();
751
- return false;
752
- }
753
-
754
- const bindings = [];
755
- for (const project of currentProjects()) {
756
- const port = await portForProject(project);
757
- if (port !== undefined) bindings.push({ project, port });
758
- }
759
-
760
- // Always tear the servers down and register them again. OpenCode's reconcile
761
- // SKIPS a re-`set` whose config is unchanged, so re-registering without a
762
- // teardown would leave a dropped server in place forever. The commands and
763
- // the request hooks are rare; rebuilding costs one restart, which is the
764
- // right trade for never leaving a dead registration behind.
765
- await deactivateIde();
766
- if (bindings.length === 0) return false;
767
- return activateIde(bindings);
768
- };
544
+ async setup(ctx) {
545
+ const projectPath = currentProjectPath(ctx.location);
546
+ if (!projectPath) return;
547
+
548
+ const options = ctx.options || {};
549
+ const injectGuidance = options.injectGuidance !== false;
550
+ // 自动拉起 IDE 的总开关。用哪个 IDE 由**每个项目自己的配置**决定
551
+ // (`project.app`),这个选项不再指定应用名,只决定"允不允许拉起"。
552
+ const launchEnabled = options.openInIde !== false;
553
+ const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
554
+ const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
555
+ const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
556
+ // Cap on a single IDE tool call, so a wedged server fails in finite time
557
+ // instead of OpenCode's 12h default. See DEFAULT_EXECUTION_TIMEOUT_MS.
558
+ const executionTimeoutMs = asPositiveNumber(options.executionTimeoutMs, DEFAULT_EXECUTION_TIMEOUT_MS);
559
+ // "message" (default) injects a clearly-labelled notification that the
560
+ // model only acknowledges ("收到"); the user sees the clean notice via
561
+ // metadata.displayText. false is silent.
562
+ const feedback = options.feedback === false ? false : 'message';
563
+ const launchCooldownMs = asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS);
564
+ const launchMaxAttempts = asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS);
565
+
566
+ /** @type {Array<{ dispose: () => Promise<void> | void }>} */
567
+ let registrations = [];
568
+ /** @type {{ dispose: () => Promise<void> | void } | undefined} */
569
+ let sessionRegistration;
570
+ /** @type {{ dispose: () => Promise<void> | void } | undefined} */
571
+ let toolRegistration;
572
+ let toolBeforeRegistration;
573
+ let toolTransformRegistration;
574
+ /** `configure_ide_project` 的注册。 */
575
+ let configToolRegistration;
576
+ /** @type {{ dispose: () => Promise<void> | void } | undefined} */
577
+ let promptRegistration;
578
+ /** @type {{ dispose: () => Promise<void> | void } | undefined} */
579
+ let commandRegistration;
580
+ /** @type {{ dispose: () => Promise<void> | void } | undefined} */
581
+ let skillRegistration;
582
+ let disposed = false;
583
+
584
+ // Latches when a request showed the IDE tools are gone. It is cleared
585
+ // only by a successful rebuild. It is set from the `context` hook (a request
586
+ // that HAD the tools no longer does); the `prompt` hook also heals without
587
+ // this latch when the instance has no registration at all.
588
+ let needsRebuild = false;
589
+ // Set once a request in this session actually carried IDE tools, so a
590
+ // later request without them is a DROP of this session's own connection —
591
+ // not a root that simply never connected.
592
+ let wasServing = false;
593
+
594
+ // 本会话的 IDE 配置:root 用哪个 IDE,以及登记了哪些子项目、各自用哪个 IDE。
595
+ // 一个插件实例服务一个 root(OpenCode 按 Location 激活插件),所以这就是全部
596
+ // 状态。持久化在 `ctx.storage` 的 `project/<rootPath>` 下。
597
+ //
598
+ // `undefined` = 还没读过;`null` = 读过、但没有配置。两者必须分开:混成一种,
599
+ // 请求钩子就会把"还没读"当成"未配置",漏掉一次本该发生的自愈。
600
+ const storage = ctx.storage;
601
+ const markKey = markKeyFor(projectPath);
602
+ /** @type {import('./ide-projects.js').IdeConfig | null | undefined} */
603
+ let config;
604
+
605
+ /** 读一次持久化配置(最多一次 I/O),之后走内存缓存。 */
606
+ const readConfig = async () => {
607
+ if (config !== undefined) return config;
608
+ if (!storage || typeof storage.get !== 'function') {
609
+ config = null;
610
+ return config;
611
+ }
612
+ try {
613
+ config = parseMark(await storage.get(markKey), projectPath) ?? null;
614
+ } catch {
615
+ // storage is best effort;读不到就当作未配置
616
+ config = null;
617
+ }
618
+ return config;
619
+ };
769
620
 
770
- // Serialize reconciles so overlapping callers can never register/activate
771
- // the IDE concurrently (which would leak registrations).
772
- let reconcileChain = Promise.resolve();
773
- // Non-zero while a reconcile is queued or running.
774
- let reconcileBusy = 0;
775
- const enqueue = (task) => {
776
- reconcileBusy += 1;
777
- const next = reconcileChain.then(task);
778
- reconcileChain = next.then(
779
- () => {},
780
- () => {},
781
- );
782
- return next.finally(() => {
783
- reconcileBusy -= 1;
784
- });
785
- };
786
- const reconcile = (options) => enqueue(() => reconcileOnce(options));
621
+ /**
622
+ * 写入配置(`null` = 删除),并同步内存缓存。
623
+ *
624
+ * 先写 storage 再更新缓存(write-through):写失败也要更新缓存,否则用户刚做的
625
+ * 配置在本进程里不可见,行为会自相矛盾。命令与 `configure_ide_project` 是唯一的
626
+ * 写入者。
627
+ *
628
+ * @param {import('./ide-projects.js').IdeConfig | null} next
629
+ */
630
+ const writeConfig = async (next) => {
631
+ try {
632
+ if (next) await storage?.set?.(markKey, serializeMark(next));
633
+ else await storage?.remove?.(markKey);
634
+ } catch {
635
+ // best effort
636
+ }
637
+ config = next;
638
+ };
787
639
 
788
- /**
789
- * Is `path` actually OPEN in the IDE that serves `port`?
790
- *
791
- * A live endpoint only means the IDE is RUNNING — it may have a different
792
- * project open (or sit on the Welcome screen), while everything we do is
793
- * scoped to THIS project. So ask the IDE directly with one project-scoped
794
- * call: the server resolves the project from `IJ_MCP_SERVER_PROJECT_PATH`
795
- * BEFORE running the tool, and for a project that is not open it fails with
796
- * "Unable to determine the target project ..." — the same shape
797
- * `classifyIdeFailure` maps to `project`. Any other outcome (a real result,
798
- * or an unrelated tool error) means the project WAS resolved, i.e. open.
799
- *
800
- * A transport failure (`undefined`) counts as "not confirmed open", so the
801
- * caller still asks the IDE to open the project: `open -a` is idempotent,
802
- * and the only cost is a redundant focus.
803
- *
804
- * 这个判定现在只回答一个问题:"这个 IDE 里打开着这个项目吗?" —— 端口本身由
805
- * `IDE_PORTS` 按 IDE 唯一确定,不再需要靠它去区分端口归属。
806
- *
807
- * ⚠️ 实测(2026-10-16,IntelliJ IDEA 2026.2 + PyCharm 2026.2):请求路径**落在某个已打开
808
- * 项目的目录树内**时,IDE 会把它解析到那个项目 —— `get_project_modules` 正常返回它的模块,
809
- * 而不是报错。只有路径完全不在任何已打开项目下(`/tmp`、不存在的路径)才报
810
- * "Unable to determine the target project"。
811
- *
812
- * 因此「子项目与 root 用同一个 IDE」时必须知道:只要那个 IDE 打开了 root,子项目就会被判成
813
- * "已打开" —— 它确实是 root 项目的一部分,工具能操作它的文件,只是语言支持按 **root 项目**
814
- * 走(Python 子项目被 root 的 Web 模块解析,符号/检查会不准)。这是 IDE 的目录包含语义,
815
- * 不是探测可以绕过的;要让子项目按自己的模块解析,得让它在那个 IDE 里**单独打开**一个项目
816
- * —— 或者(正常做法)给它配它真正被打开的那个 IDE。
817
- *
818
- * A transport failure (`undefined`) counts as "not confirmed open", so the
819
- * caller still asks the IDE to open the project: `open -a` is idempotent,
820
- * and the only cost is a redundant focus.
821
- *
822
- * @param {number} port
823
- * @param {string} path
824
- */
825
- const isProjectOpen = async (port, path) => {
826
- const result = await callTool(port, path, 'get_project_modules', {}, {
827
- timeoutMs: mcpProbeTimeoutMs,
828
- }).catch(() => undefined);
829
- if (!result) return false;
830
- if (result.isError !== true) return true;
831
- const text = (result.content ?? [])
832
- .filter((part) => part?.type === 'text' && typeof part.text === 'string')
833
- .map((part) => part.text)
834
- .join('\n');
835
- return classifyIdeFailure({ message: text }, serverNames()) !== 'project';
836
- };
640
+ /** 当前接入的全部项目(root 在前),每个带工具前缀与展示标签。 */
641
+ const currentProjects = () =>
642
+ config
643
+ ? listProjects(config).map((project) => ({
644
+ ...project,
645
+ label: project.isRoot ? '.' : project.path.slice(projectPath.length + 1),
646
+ }))
647
+ : [];
648
+
649
+ /** 当前所有项目的工具前缀(含下划线);未配置时为空。 */
650
+ const prefixes = () => (config ? toolPrefixes(config) : []);
651
+
652
+ /** 当前注册的全部 server 名(= 前缀去掉下划线)。 */
653
+ const serverNames = () => (config ? listProjects(config).map((project) => project.server) : []);
654
+
655
+ /** 这个工具名是否属于某个 IDE server(插件自己的工具不算)。 */
656
+ const isIdeToolName = (name) => {
657
+ if (PLUGIN_TOOL_NAMES.has(name)) return false;
658
+ return prefixes().some((prefix) => name.startsWith(prefix));
659
+ };
837
660
 
838
- /**
839
- * 这个项目的 IDE 端口上,服务着它吗?是就返回端口,否则 `undefined`。
840
- *
841
- * @param {{ path: string, app: string }} project
842
- */
843
- const portForProject = async (project) => {
844
- const port = portFor(project);
845
- if (port === undefined) return undefined;
846
- return (await isProjectOpen(port, project.path)) ? port : undefined;
847
- };
661
+ /**
662
+ * 这个 IDE 工具属于哪个项目?
663
+ *
664
+ * 按**最长前缀**匹配:`modelserver_` 必须先于 `model_` 命中,否则一个叫 `model`
665
+ * 的项目会把 `modelserver_*` 的工具认领走。
666
+ *
667
+ * @param {string} name
668
+ */
669
+ const projectForTool = (name) =>
670
+ currentProjects()
671
+ .filter((project) => name.startsWith(`${project.server}_`))
672
+ .sort((a, b) => b.server.length - a.server.length)[0];
673
+
674
+ // 先解析一次,请求钩子之后就能同步读到。
675
+ await readConfig();
676
+
677
+ /** 注销当前注册的全部 IDE server。 */
678
+ const deactivateIde = async () => {
679
+ const current = registrations.slice();
680
+ registrations.length = 0;
681
+ if (current.length === 0) return;
682
+ for (const registration of current) {
683
+ try {
684
+ await registration.dispose();
685
+ } catch {
686
+ // best effort
687
+ }
688
+ }
689
+ };
848
690
 
849
- /**
850
- * `open -a` only requests activation/opening. A cold IDE needs time before
851
- * its bundled MCP server listens, so poll until the endpoint appears AND
852
- * answers for THIS project. This is the step that makes a single
853
- * `/open-in-ide` enough on a cold start.
854
- *
855
- * @param {{ path: string, app: string }} project
856
- * @returns {Promise<number | undefined>}
857
- */
858
- const waitForProjectServer = async (project) => {
859
- const deadline = Date.now() + mcpStartTimeoutMs;
860
- while (Date.now() < deadline) {
861
- const port = await portForProject(project).catch(() => undefined);
862
- if (port !== undefined) return port;
863
- const remaining = deadline - Date.now();
864
- if (remaining <= 0) break;
865
- await new Promise((resolve) => setTimeout(resolve, Math.min(MCP_START_POLL_MS, remaining)));
866
- }
867
- return undefined;
868
- };
691
+ const waitForIdeTools = async () => {
692
+ if (typeof ctx.tool?.list !== 'function') return false;
693
+ const deadline = Date.now() + toolRefreshTimeoutMs;
694
+ while (Date.now() < deadline) {
695
+ try {
696
+ const ids = toolIds(await ctx.tool.list());
697
+ if (ids.some((id) => isIdeToolName(id))) return true;
698
+ } catch {
699
+ // The MCP catalog may still be settling; retry within the bounded wait.
700
+ }
701
+ await new Promise((resolve) => setTimeout(resolve, TOOL_REFRESH_POLL_MS));
702
+ }
703
+ return false;
704
+ };
869
705
 
870
- /**
871
- * 每个项目各自的启动状态:in-flight、拉起冷却、是否已发过通知。
872
- *
873
- * 按项目分开是必需的:IDEA 与 PyCharm 是两个独立进程,一个的冷却不能拖住另一个;
874
- * 而同一个项目的重复调用仍然共享一次拉起(re-entrant)。
875
- */
876
- const launchStates = new Map();
877
- const launchStateFor = (path) => {
878
- let state = launchStates.get(path);
879
- if (!state) {
880
- state = {
881
- /** @type {Promise<IdeCommandResult> | undefined} */
882
- inFlight: undefined,
883
- noticeSent: false,
884
- guard: createLaunchGuard({ cooldownMs: launchCooldownMs, maxAttempts: launchMaxAttempts }),
706
+ /**
707
+ * 一个项目的 MCP 端口,由它的 IDE 唯一决定(`IDE_PORTS`,固定值)。
708
+ *
709
+ * @param {{ path: string, app: string }} project
710
+ * @returns {number | undefined}
711
+ */
712
+ const portFor = (project) => IDE_PORTS[project.app];
713
+
714
+ const refreshIdeTools = async () => {
715
+ if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
716
+ // Reconcile the MCP servers above, then replay the tool registry so the
717
+ // refreshed catalog lands in the next model request's tool snapshot.
718
+ // Without this, a session that already captured a snapshot keeps the old
719
+ // tool list until the session is reopened.
720
+ if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
721
+ return await waitForIdeTools();
885
722
  };
886
- launchStates.set(path, state);
887
- }
888
- return state;
889
- };
890
723
 
891
- /**
892
- * 确保 `project` 的 IDE 正开着、并且打开着这个项目;返回它的端口。
893
- *
894
- * @param {{ path: string, server: string, app: string, label: string, isRoot: boolean }} project
895
- * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
896
- * @returns {Promise<IdeCommandResult>}
897
- */
898
- const ensureProject = (project, { onUnavailable } = {}) => {
899
- const state = launchStateFor(project.path);
900
- if (state.inFlight) return state.inFlight;
901
- const reportUnavailable = async (opened) => {
902
- if (state.noticeSent || typeof onUnavailable !== 'function') return;
903
- // Fast-fail: notify as soon as the endpoint is known to be missing so
904
- // the user can enable MCP + Brave Mode and retry the command.
905
- const delivered = await onUnavailable({ status: 'mcp-unavailable', opened, project });
906
- if (delivered) state.noticeSent = true;
907
- };
908
- const run = (async () => {
909
- // 已经开着并且就打开着这个项目:什么都不用做。
910
- const serving = await portForProject(project).catch(() => undefined);
911
- if (serving !== undefined) {
912
- state.guard.reset();
913
- state.noticeSent = false;
914
- return { status: 'connected', port: serving, project, unavailableNoticeSent: false };
915
- }
916
- // 需要一个 app 才能拉起。拿不到 app 的原因有两种,交给 autoLaunchSupported 区分。
917
- const app = launchEnabled ? appNameFor(project.app) : undefined;
918
- if (!app) {
919
- return { status: autoLaunchSupported() ? 'disabled' : 'manual-launch', project };
920
- }
921
- if (state.guard.exhausted() || state.guard.coolingDown()) {
922
- // There is no reason to wait here: MCP is disabled/not ready, and
923
- // the user must enable it in the IDE before retrying the command.
924
- await reportUnavailable(false);
925
- return { status: 'mcp-unavailable', opened: false, project, unavailableNoticeSent: state.noticeSent };
926
- }
927
- state.guard.markSpawn();
928
- const launched = await openInIde(project.path, { app });
929
- if (!launched) return { status: 'launch-failed', project };
930
- const started = await waitForProjectServer(project);
931
- if (started !== undefined) {
932
- state.guard.reset();
933
- state.noticeSent = false;
934
- return { status: 'connected', port: started, project, unavailableNoticeSent: false };
935
- }
936
- await reportUnavailable(true);
937
- return { status: 'mcp-unavailable', opened: true, project, unavailableNoticeSent: state.noticeSent };
938
- })();
939
- state.inFlight = run;
940
- void run
941
- .catch(() => {})
942
- .then(() => {
943
- if (state.inFlight === run) state.inFlight = undefined;
944
- });
945
- return run;
946
- };
724
+ /**
725
+ * 按配置给**每个**已打开的项目各注册一个 MCP server。
726
+ *
727
+ * server 名就是项目的工具前缀(root = `ide`,子项目 = 目录名),所以 `editor.set`
728
+ * 的 key 与 `serverConfig` 里带的项目路径必须成对出现 —— 一个 server 只服务一个
729
+ * 项目(路径走 header,注册后无法逐次改写)。
730
+ *
731
+ * @param {ReadonlyArray<{ project: { path: string, server: string }, port: number }>} bindings
732
+ */
733
+ const activateIde = async (bindings) => {
734
+ const mcpRegistration = await ctx.mcp.transform((editor) => {
735
+ for (const binding of bindings) {
736
+ editor.set(
737
+ binding.project.server,
738
+ serverConfig(binding.port, binding.project.path, {executionTimeoutMs}),
739
+ );
740
+ }
741
+ });
742
+ registrations = [mcpRegistration];
743
+ return refreshIdeTools();
744
+ };
947
745
 
948
- /**
949
- * `/open-in-ide` 的主流程:让每个已登记项目的 IDE 都就绪,然后把它们注册进
950
- * OpenCode。
951
- *
952
- * 每个项目各自拉起**自己的** IDE —— root 在 IDEA、某子项目在 PyCharm 是常态,
953
- * 所以这里不是"连一个 IDE",而是"连一组",彼此并行。
954
- *
955
- * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
956
- * @returns {Promise<IdeCommandResult>}
957
- */
958
- const runOpenInIdea = async ({ onUnavailable } = {}) => {
959
- const projects = currentProjects();
960
- if (projects.length === 0) return { status: 'not-configured' };
961
- const results = await Promise.all(
962
- projects.map((project) => ensureProject(project, { onUnavailable })),
963
- );
964
- const connected = results.filter((result) => result.status === 'connected');
965
- if (connected.length === 0) {
966
- // 一个都没连上:注销残留注册,并挑一个代表性结果回去(文案已带上具体项目)。
967
- await reconcile({ probeIde: false }).catch(() => {});
968
- return results[0];
969
- }
970
- let toolsReady = false;
971
- try {
972
- // 重新探测一次并整体注册:ensureProject 只保证 IDE 就绪,注册要按"当前真正
973
- // 打开着哪些项目"重来一遍,这样 `/open-in-ide` 每次都是同一套结果。
974
- toolsReady = (await reconcile({ probeIde: true })) === true;
975
- } catch {
976
- // capability load is best effort; feedback still matters
977
- }
978
- const connectedProjects = connected.map((result) => result.project);
979
- return toolsReady
980
- ? { status: 'connected', port: connected[0].port, projects: connectedProjects }
981
- : { ...connected[0], status: 'tools-loading', projects: connectedProjects };
982
- };
746
+ const reconcileOnce = async ({probeIde = true} = {}) => {
747
+ if (disposed) return false;
983
748
 
984
- /**
985
- * Coalesced background reconnect.
986
- *
987
- * The `context` hook latches the rebuild the moment a request shows the tools
988
- * are gone, but a latch alone only fires on the NEXT user message — one whole
989
- * turn later, which is exactly the gap that made a dead session sit there
990
- * until `/open-in-ide` was run by hand. This runs out of band (it never
991
- * blocks the request) so the next request — the following agent-loop step, or
992
- * any later message — already carries the tools again.
993
- *
994
- * @returns {Promise<void>}
995
- */
996
- /** @type {Promise<void> | undefined} */
997
- let reviveInFlight;
998
- const reviveIde = () => {
999
- // 配置 gate 住每一次重连,包括后台发起的那些:后台尝试绝不能把用户已经用
1000
- // `/delete-in-ide` 删掉的项目重新拉起来。
1001
- if (disposed || !config) return Promise.resolve();
1002
- if (reviveInFlight) return reviveInFlight;
1003
- const run = (async () => {
1004
- try {
1005
- const result = await runOpenInIdea();
1006
- // `/delete-in-ide` 可能在这期间落地;它赢。
1007
- if (!config) {
1008
- await deactivateIde();
1009
- return;
1010
- }
1011
- if (result.port !== undefined) {
1012
- needsRebuild = false;
1013
- }
1014
- } catch {
1015
- // best effort: the latch stays set, and the prompt hook retries
1016
- }
1017
- })();
1018
- reviveInFlight = run;
1019
- void run.then(() => {
1020
- if (reviveInFlight === run) reviveInFlight = undefined;
1021
- });
1022
- return run;
1023
- };
749
+ // 未配置:没有任何项目要接入。
750
+ if (!config) {
751
+ await deactivateIde();
752
+ return false;
753
+ }
754
+ // Manual: only the commands probe the IDE. Setup calls this with
755
+ // `probeIde: false` so it never connects until a command asks.
756
+ if (!probeIde) {
757
+ await deactivateIde();
758
+ return false;
759
+ }
1024
760
 
1025
- // Setup never probes or connects any IDE — everything is on the commands.
1026
- await reconcile({ probeIde: false });
761
+ const bindings = [];
762
+ for (const project of currentProjects()) {
763
+ const port = await portForProject(project);
764
+ if (port !== undefined) bindings.push({project, port});
765
+ }
1027
766
 
1028
- const sendFeedback = async (input, result) => {
1029
- if (feedback !== 'message' || typeof ctx.session?.prompt !== 'function') return false;
1030
- try {
1031
- await ctx.session.prompt({
1032
- ...input.prompt,
1033
- sessionID: input.sessionID,
1034
- text: idePromptText(result),
1035
- metadata: idePromptMetadata(result),
1036
- delivery: input.delivery,
1037
- });
1038
- return true;
1039
- } catch {
1040
- // feedback is best effort
1041
- return false;
1042
- }
1043
- };
767
+ // Always tear the servers down and register them again. OpenCode's reconcile
768
+ // SKIPS a re-`set` whose config is unchanged, so re-registering without a
769
+ // teardown would leave a dropped server in place forever. The commands and
770
+ // the request hooks are rare; rebuilding costs one restart, which is the
771
+ // right trade for never leaving a dead registration behind.
772
+ await deactivateIde();
773
+ if (bindings.length === 0) return false;
774
+ return activateIde(bindings);
775
+ };
1044
776
 
1045
- /**
1046
- * The IDEA tools this project's IDE is serving right now.
1047
- *
1048
- * Empty is the honest answer for "does the IDE serve X?" before anything has
1049
- * connected — and it fails that way on error too, because the alternative
1050
- * (assuming a tool exists) is the one mistake that cannot be walked back
1051
- * after the model has seen it.
1052
- *
1053
- * @returns {Promise<Set<string>>}
1054
- */
1055
- const servedIdeaToolNames = async () => {
1056
- if (typeof ctx.tool?.list !== 'function') return new Set();
1057
- try {
1058
- return new Set(toolIds(await ctx.tool.list()));
1059
- } catch {
1060
- return new Set();
1061
- }
1062
- };
777
+ // Serialize reconciles so overlapping callers can never register/activate
778
+ // the IDE concurrently (which would leak registrations).
779
+ let reconcileChain = Promise.resolve();
780
+ // Non-zero while a reconcile is queued or running.
781
+ let reconcileBusy = 0;
782
+ const enqueue = (task) => {
783
+ reconcileBusy += 1;
784
+ const next = reconcileChain.then(task);
785
+ reconcileChain = next.then(
786
+ () => {
787
+ },
788
+ () => {
789
+ },
790
+ );
791
+ return next.finally(() => {
792
+ reconcileBusy -= 1;
793
+ });
794
+ };
795
+ const reconcile = (options) => enqueue(() => reconcileOnce(options));
796
+
797
+ /**
798
+ * Is `path` actually OPEN in the IDE that serves `port`?
799
+ *
800
+ * A live endpoint only means the IDE is RUNNING — it may have a different
801
+ * project open (or sit on the Welcome screen), while everything we do is
802
+ * scoped to THIS project. So ask the IDE directly with one project-scoped
803
+ * call: the server resolves the project from `IJ_MCP_SERVER_PROJECT_PATH`
804
+ * BEFORE running the tool, and for a project that is not open it fails with
805
+ * "Unable to determine the target project ..." — the same shape
806
+ * `classifyIdeFailure` maps to `project`. Any other outcome (a real result,
807
+ * or an unrelated tool error) means the project WAS resolved, i.e. open.
808
+ *
809
+ * A transport failure (`undefined`) counts as "not confirmed open", so the
810
+ * caller still asks the IDE to open the project: `open -a` is idempotent,
811
+ * and the only cost is a redundant focus.
812
+ *
813
+ * 这个判定现在只回答一个问题:"这个 IDE 里打开着这个项目吗?" —— 端口本身由
814
+ * `IDE_PORTS` 按 IDE 唯一确定,不再需要靠它去区分端口归属。
815
+ *
816
+ * ⚠️ 实测(2026-10-16,IntelliJ IDEA 2026.2 + PyCharm 2026.2):请求路径**落在某个已打开
817
+ * 项目的目录树内**时,IDE 会把它解析到那个项目 —— `get_project_modules` 正常返回它的模块,
818
+ * 而不是报错。只有路径完全不在任何已打开项目下(`/tmp`、不存在的路径)才报
819
+ * "Unable to determine the target project"。
820
+ *
821
+ * 因此「子项目与 root 用同一个 IDE」时必须知道:只要那个 IDE 打开了 root,子项目就会被判成
822
+ * "已打开" —— 它确实是 root 项目的一部分,工具能操作它的文件,只是语言支持按 **root 项目**
823
+ * 走(Python 子项目被 root 的 Web 模块解析,符号/检查会不准)。这是 IDE 的目录包含语义,
824
+ * 不是探测可以绕过的;要让子项目按自己的模块解析,得让它在那个 IDE 里**单独打开**一个项目
825
+ * —— 或者(正常做法)给它配它真正被打开的那个 IDE。
826
+ *
827
+ * A transport failure (`undefined`) counts as "not confirmed open", so the
828
+ * caller still asks the IDE to open the project: `open -a` is idempotent,
829
+ * and the only cost is a redundant focus.
830
+ *
831
+ * @param {number} port
832
+ * @param {string} path
833
+ */
834
+ const isProjectOpen = async (port, path) => {
835
+ const result = await callTool(port, path, 'get_project_modules', {}, {
836
+ timeoutMs: mcpProbeTimeoutMs,
837
+ }).catch(() => undefined);
838
+ if (!result) return false;
839
+ if (result.isError !== true) return true;
840
+ const text = (result.content ?? [])
841
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
842
+ .map((part) => part.text)
843
+ .join('\n');
844
+ return classifyIdeFailure({message: text}, serverNames()) !== 'project';
845
+ };
1063
846
 
1064
- /** Same ids in the same order — `readSkills()` sorts, so the order is stable. */
1065
- const isSameSkillSet = (skills, ids) =>
1066
- skills.length === ids.length && skills.every((skill, index) => skill.id === ids[index]);
847
+ /**
848
+ * 这个项目的 IDE 端口上,服务着它吗?是就返回端口,否则 `undefined`。
849
+ *
850
+ * @param {{ path: string, app: string }} project
851
+ */
852
+ const portForProject = async (project) => {
853
+ const port = portFor(project);
854
+ if (port === undefined) return undefined;
855
+ return (await isProjectOpen(port, project.path)) ? port : undefined;
856
+ };
1067
857
 
1068
- // The shipped skills are static knowledge (`skills/<id>/SKILL.md`) that write
1069
- // a project's own rules into its `AGENTS.md`: `idea-standalone` fixes how
1070
- // resident apps are started, `idea-run-config-manager` fixes which repeated
1071
- // flows are solidified into configurations. They pass two independent gates,
1072
- // because a skill can be wrong twice over:
1073
- //
1074
- // * the project config — an unrelated project never sees an IDE skill;
1075
- // * the tools the IDE actually serves — a skill that teaches a call the
1076
- // model cannot make is not knowledge, it is a failed call waiting to
1077
- // happen. Both skills drive tools that only the companion plugin
1078
- // `idea-mcp-enhance` serves (rerun for standalone, create for the
1079
- // manager), so a stock IDE must not see them at all. Each skill declares
1080
- // its own dependency in its frontmatter (`requires:`), as a SUFFIX — tool
1081
- // names carry a per-project prefix, so a hard-coded `idea_...` could only
1082
- // ever match one project.
1083
- //
1084
- // The catalog is unknown at setup: the config is read from storage offline,
1085
- // before anything has connected to the IDE. So this is a reconcile rather
1086
- // than a one-shot — every catalog refresh re-runs it, and it offers exactly
1087
- // what is justified right now. Until the IDE has answered, the gated skills
1088
- // simply do not exist.
1089
- //
1090
- // Deliberately NOT wrapped in a catch: these are core features of the plugin,
1091
- // so a rejected definition must fail loudly. Swallowing it would ship a
1092
- // silently incomplete plugin — which is exactly how 0.0.6 lost
1093
- // `/open-in-ide` without anyone noticing until the command was gone.
1094
- const syncSkillsOnce = async () => {
1095
- if (typeof ctx.skill?.transform !== 'function') return;
858
+ /**
859
+ * `open -a` only requests activation/opening. A cold IDE needs time before
860
+ * its bundled MCP server listens, so poll until the endpoint appears AND
861
+ * answers for THIS project. This is the step that makes a single
862
+ * `/open-in-ide` enough on a cold start.
863
+ *
864
+ * @param {{ path: string, app: string }} project
865
+ * @returns {Promise<number | undefined>}
866
+ */
867
+ const waitForProjectServer = async (project) => {
868
+ const deadline = Date.now() + mcpStartTimeoutMs;
869
+ while (Date.now() < deadline) {
870
+ const port = await portForProject(project).catch(() => undefined);
871
+ if (port !== undefined) return port;
872
+ const remaining = deadline - Date.now();
873
+ if (remaining <= 0) break;
874
+ await new Promise((resolve) => setTimeout(resolve, Math.min(MCP_START_POLL_MS, remaining)));
875
+ }
876
+ return undefined;
877
+ };
1096
878
 
1097
- const served = await servedIdeaToolNames();
1098
- const wanted = (config ? readSkills() : [])
1099
- .filter(({ requires }) =>
1100
- requires.every((suffix) => prefixes().some((prefix) => served.has(`${prefix}${suffix}`))),
1101
- )
1102
- .map(({ skill }) => skill);
1103
- if (isSameSkillSet(wanted, registeredSkillIds)) return;
879
+ /**
880
+ * 每个项目各自的启动状态:in-flight、拉起冷却、是否已发过通知。
881
+ *
882
+ * 按项目分开是必需的:IDEA 与 PyCharm 是两个独立进程,一个的冷却不能拖住另一个;
883
+ * 而同一个项目的重复调用仍然共享一次拉起(re-entrant)。
884
+ */
885
+ const launchStates = new Map();
886
+ const launchStateFor = (path) => {
887
+ let state = launchStates.get(path);
888
+ if (!state) {
889
+ state = {
890
+ /** @type {Promise<IdeCommandResult> | undefined} */
891
+ inFlight: undefined,
892
+ noticeSent: false,
893
+ guard: createLaunchGuard({cooldownMs: launchCooldownMs, maxAttempts: launchMaxAttempts}),
894
+ };
895
+ launchStates.set(path, state);
896
+ }
897
+ return state;
898
+ };
1104
899
 
1105
- // Tear down and register again, the same way the MCP server is
1106
- // re-registered: a transform that only ever adds would leave a stale skill
1107
- // behind, and one that only removes could never bring it back.
1108
- registeredSkillIds = [];
1109
- const previous = skillRegistration;
1110
- skillRegistration = undefined;
1111
- if (previous) {
1112
- try {
1113
- await previous.dispose();
1114
- } catch {
1115
- // best effort
1116
- }
1117
- }
1118
- if (wanted.length === 0) return;
1119
- skillRegistration = await ctx.skill.transform((editor) => {
1120
- for (const skill of wanted) editor.add(skill);
1121
- });
1122
- registeredSkillIds = wanted.map((skill) => skill.id);
1123
- };
900
+ /**
901
+ * 确保 `project` 的 IDE 正开着、并且打开着这个项目;返回它的端口。
902
+ *
903
+ * @param {{ path: string, server: string, app: string, label: string, isRoot: boolean }} project
904
+ * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
905
+ * @returns {Promise<IdeCommandResult>}
906
+ */
907
+ const ensureProject = (project, {onUnavailable} = {}) => {
908
+ const state = launchStateFor(project.path);
909
+ if (state.inFlight) return state.inFlight;
910
+ const reportUnavailable = async (opened) => {
911
+ if (state.noticeSent || typeof onUnavailable !== 'function') return;
912
+ // Fast-fail: notify as soon as the endpoint is known to be missing so
913
+ // the user can enable MCP + Brave Mode and retry the command.
914
+ const delivered = await onUnavailable({status: 'mcp-unavailable', opened, project});
915
+ if (delivered) state.noticeSent = true;
916
+ };
917
+ const run = (async () => {
918
+ // 已经开着并且就打开着这个项目:什么都不用做。
919
+ const serving = await portForProject(project).catch(() => undefined);
920
+ if (serving !== undefined) {
921
+ state.guard.reset();
922
+ state.noticeSent = false;
923
+ return {status: 'connected', port: serving, project, unavailableNoticeSent: false};
924
+ }
925
+ // 需要一个 app 才能拉起。拿不到 app 的原因有两种,交给 autoLaunchSupported 区分。
926
+ const app = launchEnabled ? appNameFor(project.app) : undefined;
927
+ if (!app) {
928
+ return {status: autoLaunchSupported() ? 'disabled' : 'manual-launch', project};
929
+ }
930
+ if (state.guard.exhausted() || state.guard.coolingDown()) {
931
+ // There is no reason to wait here: MCP is disabled/not ready, and
932
+ // the user must enable it in the IDE before retrying the command.
933
+ await reportUnavailable(false);
934
+ return {status: 'mcp-unavailable', opened: false, project, unavailableNoticeSent: state.noticeSent};
935
+ }
936
+ state.guard.markSpawn();
937
+ const launched = await openInIde(project.path, {app});
938
+ if (!launched) return {status: 'launch-failed', project};
939
+ const started = await waitForProjectServer(project);
940
+ if (started !== undefined) {
941
+ state.guard.reset();
942
+ state.noticeSent = false;
943
+ return {status: 'connected', port: started, project, unavailableNoticeSent: false};
944
+ }
945
+ await reportUnavailable(true);
946
+ return {status: 'mcp-unavailable', opened: true, project, unavailableNoticeSent: state.noticeSent};
947
+ })();
948
+ state.inFlight = run;
949
+ void run
950
+ .catch(() => {
951
+ })
952
+ .then(() => {
953
+ if (state.inFlight === run) state.inFlight = undefined;
954
+ });
955
+ return run;
956
+ };
1124
957
 
1125
- // Serialized like the reconciler, and for the same reason: one call disposes
1126
- // a registration before it makes the next, so two overlapping calls would
1127
- // leak one of them. That overlap is easy to reach — `/delete-in-ide` can land
1128
- // while a reconnect is still in flight. With the chain in place, callers may
1129
- // fire this without awaiting.
1130
- let skillSyncChain = Promise.resolve();
1131
- const syncSkills = () => {
1132
- const run = () => syncSkillsOnce();
1133
- const next = skillSyncChain.then(run, run);
1134
- skillSyncChain = next.then(
1135
- () => {},
1136
- () => {},
1137
- );
1138
- return next;
1139
- };
1140
- // A project configured in an earlier run gets its skills as soon as the IDE
1141
- // has answered; a project configured for the first time gets them when
1142
- // `/open-in-ide` runs (both paths go through `writeConfig` / this setup hook).
1143
- await syncSkills();
958
+ /**
959
+ * `/open-in-ide` 的主流程:让每个已登记项目的 IDE 都就绪,然后把它们注册进
960
+ * OpenCode。
961
+ *
962
+ * 每个项目各自拉起**自己的** IDE —— root 在 IDEA、某子项目在 PyCharm 是常态,
963
+ * 所以这里不是"连一个 IDE",而是"连一组",彼此并行。
964
+ *
965
+ * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
966
+ * @returns {Promise<IdeCommandResult>}
967
+ */
968
+ const runOpenInIdea = async ({onUnavailable} = {}) => {
969
+ const projects = currentProjects();
970
+ if (projects.length === 0) return {status: 'not-configured'};
971
+ const results = await Promise.all(
972
+ projects.map((project) => ensureProject(project, {onUnavailable})),
973
+ );
974
+ const connected = results.filter((result) => result.status === 'connected');
975
+ if (connected.length === 0) {
976
+ // 一个都没连上:注销残留注册,并挑一个代表性结果回去(文案已带上具体项目)。
977
+ await reconcile({probeIde: false}).catch(() => {
978
+ });
979
+ return results[0];
980
+ }
981
+ let toolsReady = false;
982
+ try {
983
+ // 重新探测一次并整体注册:ensureProject 只保证 IDE 就绪,注册要按"当前真正
984
+ // 打开着哪些项目"重来一遍,这样 `/open-in-ide` 每次都是同一套结果。
985
+ toolsReady = (await reconcile({probeIde: true})) === true;
986
+ } catch {
987
+ // capability load is best effort; feedback still matters
988
+ }
989
+ const connectedProjects = connected.map((result) => result.project);
990
+ return toolsReady
991
+ ? {status: 'connected', port: connected[0].port, projects: connectedProjects}
992
+ : {...connected[0], status: 'tools-loading', projects: connectedProjects};
993
+ };
1144
994
 
1145
- /**
1146
- * 问用户"这个项目用哪个 IDE",由模型转达并回写。
1147
- *
1148
- * 插件自己不能弹选择框,也不能挂起等待;唯一可靠的通道是让模型去问、再把答案用
1149
- * `configure_ide_project` 结构化地写回来。所以这里发的**不是通知而是任务**:文本
1150
- * 里必须写明"调用了工具才算完成",否则模型很容易只回一句"好的,用 PyCharm"。
1151
- *
1152
- * @param {{ sessionID?: string, prompt?: unknown, delivery?: unknown }} input
1153
- * @param {{ label: string, isRoot: boolean }} target
1154
- */
1155
- const askIdeChoice = async (input, target) => {
1156
- if (typeof ctx.session?.prompt !== 'function') return;
1157
- const where = target.isRoot ? `根项目(${projectPath})` : `子项目 ${target.label}`;
1158
- const call = target.isRoot
1159
- ? 'configure_ide_project({ ide: "<idea|pycharm>" })'
1160
- : `configure_ide_project({ path: "${target.label}", ide: "<idea|pycharm>" })`;
1161
- // 插件自己摸底,而不是让模型去"找 IDE" —— 它找到的办法通常是 `ps aux | grep`(只能
1162
- // 看到**正在运行**的进程,装了没开就漏),又慢又不准。这里是按应用目录读一次得到的。
1163
- const installed = detectInstalledIdes();
1164
- const missing = SUPPORTED_IDES.filter((app) => !installed.includes(app));
1165
- const detectLines = installed.length
1166
- ? [
1167
- `插件已探测本机安装情况:装有 ${installed.map((app) => IDE_LABELS[app] ?? app).join('、')}` +
1168
- (missing.length
1169
- ? `;没找到 ${missing.map((app) => IDE_LABELS[app] ?? app).join('、')}(用户若要用它,需要自己先装)`
1170
- : '') +
1171
- '。',
1172
- '**不要再自己去跑命令或翻进程** —— 这是按应用目录得到的结论,进程里没有不代表没装。',
1173
- '',
1174
- ]
1175
- : [];
1176
- const text = [
1177
- `${NOTICE_PREFIX}需要确定 ${where} 用哪个 IDE 打开。`,
1178
- '',
1179
- ...detectLines,
1180
- '请向用户提问,并**在得到答复后调用 `configure_ide_project` 工具**写入配置。可选值只有两个:',
1181
- '- `idea` → IntelliJ IDEA',
1182
- '- `pycharm` → PyCharm',
1183
- '',
1184
- '要选它**真正被打开**的那个 IDE(例如 Python 子项目通常是 PyCharm;选错了,IDE 会把请求解析到',
1185
- '包含它的另一个已打开项目上,符号检索与检查会按那个项目的语言支持走)。',
1186
- '',
1187
- `调用形式:${call}`,
1188
- '',
1189
- '不要替用户决定,也不要只回答"已配置" —— 没有真正调用工具就等于没配置。',
1190
- ].join('\n');
1191
- try {
1192
- await ctx.session.prompt({
1193
- ...input.prompt,
1194
- sessionID: input.sessionID,
1195
- text,
1196
- metadata: {
1197
- displayText: `请选择 ${where} 使用的 IDE:IntelliJ IDEA 还是 PyCharm?`,
1198
- comments: [],
1199
- },
1200
- delivery: input.delivery,
1201
- });
1202
- } catch {
1203
- // best effort
1204
- }
1205
- };
995
+ /**
996
+ * Coalesced background reconnect.
997
+ *
998
+ * The `context` hook latches the rebuild the moment a request shows the tools
999
+ * are gone, but a latch alone only fires on the NEXT user message — one whole
1000
+ * turn later, which is exactly the gap that made a dead session sit there
1001
+ * until `/open-in-ide` was run by hand. This runs out of band (it never
1002
+ * blocks the request) so the next request — the following agent-loop step, or
1003
+ * any later message — already carries the tools again.
1004
+ *
1005
+ * @returns {Promise<void>}
1006
+ */
1007
+ /** @type {Promise<void> | undefined} */
1008
+ let reviveInFlight;
1009
+ const reviveIde = () => {
1010
+ // 配置 gate 住每一次重连,包括后台发起的那些:后台尝试绝不能把用户已经用
1011
+ // `/delete-in-ide` 删掉的项目重新拉起来。
1012
+ if (disposed || !config) return Promise.resolve();
1013
+ if (reviveInFlight) return reviveInFlight;
1014
+ const run = (async () => {
1015
+ try {
1016
+ const result = await runOpenInIdea();
1017
+ // `/delete-in-ide` 可能在这期间落地;它赢。
1018
+ if (!config) {
1019
+ await deactivateIde();
1020
+ return;
1021
+ }
1022
+ if (result.port !== undefined) {
1023
+ needsRebuild = false;
1024
+ }
1025
+ } catch {
1026
+ // best effort: the latch stays set, and the prompt hook retries
1027
+ }
1028
+ })();
1029
+ reviveInFlight = run;
1030
+ void run.then(() => {
1031
+ if (reviveInFlight === run) reviveInFlight = undefined;
1032
+ });
1033
+ return run;
1034
+ };
1206
1035
 
1207
- /**
1208
- * `configure_ide_project` 的执行体:校验后写入配置并立即接入。
1209
- *
1210
- * 这是模型把用户的选择写回插件的**唯一**通道,所以它自己也做全套校验:模型可能
1211
- * 传错路径、传一个不是 IDE 的值,插件不能因为"是模型给的"就放行。
1212
- *
1213
- * @param {{ path?: unknown, ide?: unknown }} args
1214
- */
1215
- const configureIdeProject = async (args) => {
1216
- const ide = typeof args?.ide === 'string' ? args.ide.toLowerCase() : '';
1217
- if (!SUPPORTED_IDES.includes(ide)) {
1218
- return { content: `不支持的 IDE \`${String(args?.ide)}\`。可选:${SUPPORTED_IDES.join(' / ')}。` };
1219
- }
1220
- const rawPath = typeof args?.path === 'string' ? args.path.trim() : '';
1221
- let current = await readConfig();
1222
- if (rawPath === '') {
1223
- current = current
1224
- ? { root: { ...current.root, app: ide }, children: current.children }
1225
- : { root: { path: projectPath, app: ide }, children: [] };
1226
- await writeConfig(current);
1227
- await runOpenInIdea();
1228
- return { content: `已把根项目配置为 ${IDE_LABELS[ide]} 并接入。` };
1229
- }
1230
- if (!current) {
1231
- return { content: '当前项目还没有 IDE 配置。请先执行 /open-in-ide。' };
1232
- }
1233
- const absPath = resolve(projectPath, rawPath);
1234
- if (!existsSync(absPath)) {
1235
- return { content: `目录不存在:${absPath}。` };
1236
- }
1237
- const verdict = validateSubproject(current, absPath);
1238
- if (!verdict.ok) {
1239
- return { content: `无法登记子项目:${verdict.message}` };
1240
- }
1241
- current = withSubproject(current, absPath, ide);
1242
- await writeConfig(current);
1243
- await runOpenInIdea();
1244
- return { content: `已登记子项目 \`${rawPath}\`(${IDE_LABELS[ide]})并接入。` };
1245
- };
1036
+ // Setup never probes or connects any IDE — everything is on the commands.
1037
+ await reconcile({probeIde: false});
1038
+
1039
+ const sendFeedback = async (input, result) => {
1040
+ if (feedback !== 'message' || typeof ctx.session?.prompt !== 'function') return false;
1041
+ try {
1042
+ await ctx.session.prompt({
1043
+ ...input.prompt,
1044
+ sessionID: input.sessionID,
1045
+ text: idePromptText(result),
1046
+ metadata: idePromptMetadata(result),
1047
+ delivery: input.delivery,
1048
+ });
1049
+ return true;
1050
+ } catch {
1051
+ // feedback is best effort
1052
+ return false;
1053
+ }
1054
+ };
1246
1055
 
1247
- /** `configure_ide_project` 的工具定义。 */
1248
- const configureIdeTool = () => ({
1249
- name: 'configure_ide_project',
1250
- description:
1251
- '把用户选定的 IDE 写入本项目的 IDE 配置(即"这个项目用哪个 IDE 打开")。' +
1252
- '仅当用户明确回答了"用 IntelliJ IDEA 还是 PyCharm"之后调用,不要在用户没答复前猜。' +
1253
- 'path 省略表示配置 root 项目本身;ide 只取 idea / pycharm。',
1254
- input: {
1255
- type: 'object',
1256
- properties: {
1257
- path: {
1258
- type: 'string',
1259
- description: '子项目相对 root 的路径(例如 modelserver);省略表示 root 项目本身。',
1260
- },
1261
- ide: {
1262
- type: 'string',
1263
- enum: [...SUPPORTED_IDES],
1264
- description: '该项目的 IDE:idea = IntelliJ IDEA,pycharm = PyCharm。',
1265
- },
1266
- },
1267
- required: ['ide'],
1268
- },
1269
- execute: async (args) => configureIdeProject(args),
1270
- });
1056
+ // The shipped skills are static knowledge (`skills/<id>/SKILL.md`) that write
1057
+ // a project's own rules into its `AGENTS.md`: `idea-standalone` fixes how
1058
+ // resident apps are started, `idea-run-config-manager` fixes which repeated
1059
+ // flows are solidified into configurations.
1060
+ //
1061
+ // They are registered **once, when the plugin loads** — with no condition on
1062
+ // the project config and none on the IDE's tool catalog. That is a deliberate
1063
+ // reversal of the earlier design, which offered a skill only after the tools
1064
+ // it drives were actually served. The gate looked right in isolation and was
1065
+ // wrong in use: the client snapshots the skill list when the session starts,
1066
+ // so anything registered later (by `/open-in-ide` finishing, or by a catalog
1067
+ // refresh) stays invisible until the page is reloaded — the user reads that
1068
+ // as "the skill is missing" while the registry already has it.
1069
+ //
1070
+ // What the old gate protected against is handled where it belongs: the skill
1071
+ // text itself says what to do when a tool is absent ("hand it back to the
1072
+ // user", "run /open-in-ide"). A missing tool is a fact the model can read and
1073
+ // act on; an invisible skill is not.
1074
+ //
1075
+ // The frontmatter `requires:` key is gone with the gate. It was this
1076
+ // package's own metadata, never read by OpenCode, and keeping it after the
1077
+ // gate disappeared would look like it still decided something.
1078
+ //
1079
+ // Deliberately NOT wrapped in a catch: these are core features of the plugin,
1080
+ // so a rejected definition must fail loudly. Swallowing it would ship a
1081
+ // silently incomplete plugin — which is exactly how 0.0.6 lost
1082
+ // `/open-in-ide` without anyone noticing until the command was gone.
1083
+ const registerSkills = async () => {
1084
+ if (typeof ctx.skill?.transform !== 'function') return;
1085
+ skillRegistration = await ctx.skill.transform((editor) => {
1086
+ for (const {skill} of readSkills()) editor.add(skill);
1087
+ });
1088
+ };
1089
+ await registerSkills();
1090
+
1091
+ /**
1092
+ * 问用户"这个项目用哪个 IDE",由模型转达并回写。
1093
+ *
1094
+ * 插件自己不能弹选择框,也不能挂起等待;唯一可靠的通道是让模型去问、再把答案用
1095
+ * `configure_ide_project` 结构化地写回来。所以这里发的**不是通知而是任务**:文本
1096
+ * 里必须写明"调用了工具才算完成",否则模型很容易只回一句"好的,用 PyCharm"。
1097
+ *
1098
+ * @param {{ sessionID?: string, prompt?: unknown, delivery?: unknown }} input
1099
+ * @param {{ label: string, isRoot: boolean }} target
1100
+ */
1101
+ const askIdeChoice = async (input, target) => {
1102
+ if (typeof ctx.session?.prompt !== 'function') return;
1103
+ const where = target.isRoot ? `根项目(${projectPath})` : `子项目 ${target.label}`;
1104
+ const call = target.isRoot
1105
+ ? 'configure_ide_project({ ide: "<idea|pycharm>" })'
1106
+ : `configure_ide_project({ path: "${target.label}", ide: "<idea|pycharm>" })`;
1107
+ // 插件自己摸底,而不是让模型去"找 IDE" —— 它找到的办法通常是 `ps aux | grep`(只能
1108
+ // 看到**正在运行**的进程,装了没开就漏),又慢又不准。这里是按应用目录读一次得到的。
1109
+ const installed = detectInstalledIdes();
1110
+ const missing = SUPPORTED_IDES.filter((app) => !installed.includes(app));
1111
+ const detectLines = installed.length
1112
+ ? [
1113
+ `插件已探测本机安装情况:装有 ${installed.map((app) => IDE_LABELS[app] ?? app).join('、')}` +
1114
+ (missing.length
1115
+ ? `;没找到 ${missing.map((app) => IDE_LABELS[app] ?? app).join('、')}(用户若要用它,需要自己先装)`
1116
+ : '') +
1117
+ '。',
1118
+ '**不要再自己去跑命令或翻进程** —— 这是按应用目录得到的结论,进程里没有不代表没装。',
1119
+ '',
1120
+ ]
1121
+ : [];
1122
+ const text = [
1123
+ `${NOTICE_PREFIX}需要确定 ${where} 用哪个 IDE 打开。`,
1124
+ '',
1125
+ ...detectLines,
1126
+ '请向用户提问,并**在得到答复后调用 `configure_ide_project` 工具**写入配置。可选值只有两个:',
1127
+ '- `idea` → IntelliJ IDEA',
1128
+ '- `pycharm` → PyCharm',
1129
+ '',
1130
+ '要选它**真正被打开**的那个 IDE(例如 Python 子项目通常是 PyCharm;选错了,IDE 会把请求解析到',
1131
+ '包含它的另一个已打开项目上,符号检索与检查会按那个项目的语言支持走)。',
1132
+ '',
1133
+ `调用形式:${call}`,
1134
+ '',
1135
+ '不要替用户决定,也不要只回答"已配置" —— 没有真正调用工具就等于没配置。',
1136
+ ].join('\n');
1137
+ try {
1138
+ await ctx.session.prompt({
1139
+ ...input.prompt,
1140
+ sessionID: input.sessionID,
1141
+ text,
1142
+ metadata: {
1143
+ displayText: `请选择 ${where} 使用的 IDE:IntelliJ IDEA 还是 PyCharm?`,
1144
+ comments: [],
1145
+ },
1146
+ delivery: input.delivery,
1147
+ });
1148
+ } catch {
1149
+ // best effort
1150
+ }
1151
+ };
1271
1152
 
1272
- // `/open-in-ide` 接入 root 与其全部子项目;`/add-sub-module-in-ide <路径>` 登记
1273
- // 一个子项目(IDE 由模型问用户后写回);`/delete-in-ide` 删除整个配置。
1274
- //
1275
- // 三个名字都带 `-ide` 而不是 `-idea`:这里说的是"接入 IDE",与具体产品无关 —— root 可能是
1276
- // IDEA、子项目可能是 PyCharm,和工具前缀(`ide_*` / `<目录名>_*`)是同一套口径。
1277
- if (typeof ctx.command?.transform === 'function') {
1278
- commandRegistration = await ctx.command.transform((editor) => {
1279
- editor.add({
1280
- name: 'open-in-ide',
1281
- description: '接入当前项目:root 及其全部已登记子项目,各自用配置好的 IDE 打开',
1282
- execute: async (input) => {
1283
- const parsed = parseIdeArgument(input?.prompt?.text);
1284
- if (!parsed.ok) {
1285
- await sendFeedback(input, { status: 'bad-argument', message: parsed.message });
1286
- return;
1153
+ /**
1154
+ * `configure_ide_project` 的执行体:校验后写入配置并立即接入。
1155
+ *
1156
+ * 这是模型把用户的选择写回插件的**唯一**通道,所以它自己也做全套校验:模型可能
1157
+ * 传错路径、传一个不是 IDE 的值,插件不能因为"是模型给的"就放行。
1158
+ *
1159
+ * @param {{ path?: unknown, ide?: unknown }} args
1160
+ */
1161
+ const configureIdeProject = async (args) => {
1162
+ const ide = typeof args?.ide === 'string' ? args.ide.toLowerCase() : '';
1163
+ if (!SUPPORTED_IDES.includes(ide)) {
1164
+ return {content: `不支持的 IDE \`${String(args?.ide)}\`。可选:${SUPPORTED_IDES.join(' / ')}。`};
1287
1165
  }
1166
+ const rawPath = typeof args?.path === 'string' ? args.path.trim() : '';
1288
1167
  let current = await readConfig();
1289
- if (!current) {
1290
- // 第一次:需要知道 root 用哪个 IDE。无参时让模型问用户;带参则直通。
1291
- if (!parsed.app) {
1292
- await askIdeChoice(input, { isRoot: true, label: '.' });
1293
- return;
1294
- }
1295
- current = { root: { path: projectPath, app: parsed.app }, children: [] };
1296
- await writeConfig(current);
1297
- } else if (parsed.app && parsed.app !== current.root.app) {
1298
- // 直通逃生口:显式带参时改绑 root 的 IDE(模型不配合时用户仍能自救)。
1299
- current = { root: { ...current.root, app: parsed.app }, children: current.children };
1300
- await writeConfig(current);
1301
- }
1302
- /** @type {boolean} */
1303
- let unavailableNoticeSent = false;
1304
- const result = await runOpenInIdea({
1305
- onUnavailable: async (notice) => {
1306
- unavailableNoticeSent = await sendFeedback(input, notice);
1307
- return unavailableNoticeSent;
1308
- },
1309
- });
1310
- // The unavailable notice is the final result for this command. If
1311
- // it was already delivered, do not show the same warning twice.
1312
- const unavailableAlreadySent = unavailableNoticeSent || result.unavailableNoticeSent;
1313
- if (!unavailableAlreadySent || result.status !== 'mcp-unavailable') {
1314
- await sendFeedback(input, result);
1315
- }
1316
- },
1317
- });
1318
- editor.add({
1319
- name: 'add-sub-module-in-ide',
1320
- description: '登记一个子项目(参数:子项目相对 root 的路径);随后由 AI 询问用哪个 IDE',
1321
- execute: async (input) => {
1322
- const parsed = parseSubprojectArgument(input?.prompt?.text);
1323
- if (!parsed.ok) {
1324
- await sendFeedback(input, { status: 'bad-argument', message: parsed.message });
1325
- return;
1168
+ if (rawPath === '') {
1169
+ current = current
1170
+ ? {root: {...current.root, app: ide}, children: current.children}
1171
+ : {root: {path: projectPath, app: ide}, children: []};
1172
+ await writeConfig(current);
1173
+ await runOpenInIdea();
1174
+ return {content: `已把根项目配置为 ${IDE_LABELS[ide]} 并接入。`};
1326
1175
  }
1327
- const current = await readConfig();
1328
1176
  if (!current) {
1329
- await sendFeedback(input, { status: 'not-configured' });
1330
- return;
1177
+ return {content: '当前项目还没有 IDE 配置。请先执行 /open-in-ide。'};
1331
1178
  }
1332
- const absPath = resolve(projectPath, parsed.path);
1179
+ const absPath = resolve(projectPath, rawPath);
1333
1180
  if (!existsSync(absPath)) {
1334
- await sendFeedback(input, {
1335
- status: 'invalid-subproject',
1336
- message: `目录不存在:${absPath}`,
1337
- });
1338
- return;
1181
+ return {content: `目录不存在:${absPath}。`};
1339
1182
  }
1340
1183
  const verdict = validateSubproject(current, absPath);
1341
1184
  if (!verdict.ok) {
1342
- await sendFeedback(input, { status: 'invalid-subproject', message: verdict.message });
1343
- return;
1185
+ return {content: `无法登记子项目:${verdict.message}`};
1344
1186
  }
1345
- await askIdeChoice(input, { isRoot: false, label: absPath.slice(projectPath.length + 1) });
1346
- },
1347
- });
1348
- editor.add({
1349
- name: 'delete-in-ide',
1350
- description: '删除本项目的 IDE 配置:注销全部 IDE MCP 并清除 root 与子项目的登记',
1351
- execute: async (input) => {
1352
- await writeConfig(null);
1353
- // 注销全部 server。走串行化的 reconcile,所以在飞的重建(另一个
1354
- // `/open-in-ide`、请求期自愈)不会在配置已删之后再注册回来。
1355
- await reconcile({ probeIde: false });
1356
- await sendFeedback(input, { status: 'deleted' });
1357
- },
1358
- });
1359
- });
1360
- }
1187
+ current = withSubproject(current, absPath, ide);
1188
+ await writeConfig(current);
1189
+ await runOpenInIdea();
1190
+ return {content: `已登记子项目 \`${rawPath}\`(${IDE_LABELS[ide]})并接入。`};
1191
+ };
1361
1192
 
1362
- // `configure_ide_project`:模型把用户选定的 IDE 写回配置的唯一通道。
1363
- //
1364
- // 常驻注册,不做"用时注册、写完撤销":一个工具的 schema 只有几百 token,省不下
1365
- // 什么,却要换来一整套状态机(什么时候注册、模型一直不调用怎么办、用户中途说别
1366
- // 的怎么办)。它该不该被调用由 description 约束。
1367
- if (typeof ctx.tool?.transform === 'function') {
1368
- configToolRegistration = await ctx.tool.transform((editor) => {
1369
- editor.add(configureIdeTool());
1370
- });
1371
- }
1193
+ /** `configure_ide_project` 的工具定义。 */
1194
+ const configureIdeTool = () => ({
1195
+ name: 'configure_ide_project',
1196
+ description:
1197
+ '把用户选定的 IDE 写入本项目的 IDE 配置(即"这个项目用哪个 IDE 打开")。' +
1198
+ '仅当用户明确回答了"用 IntelliJ IDEA 还是 PyCharm"之后调用,不要在用户没答复前猜。' +
1199
+ 'path 省略表示配置 root 项目本身;ide 只取 idea / pycharm。',
1200
+ input: {
1201
+ type: 'object',
1202
+ properties: {
1203
+ path: {
1204
+ type: 'string',
1205
+ description: '子项目相对 root 的路径(例如 modelserver);省略表示 root 项目本身。',
1206
+ },
1207
+ ide: {
1208
+ type: 'string',
1209
+ enum: [...SUPPORTED_IDES],
1210
+ description: '该项目的 IDE:idea = IntelliJ IDEA,pycharm = PyCharm。',
1211
+ },
1212
+ },
1213
+ required: ['ide'],
1214
+ },
1215
+ execute: async (args) => configureIdeProject(args),
1216
+ });
1372
1217
 
1373
- // Hide the tools the model must not see, and point it at the rest. This hook
1374
- // runs for every agent-loop step (including tool-driven continuations), so
1375
- // it stays a cheap, I/O-free function of the request: it checks the cached
1376
- // config and this request's own tool snapshot, and takes no action.
1377
- //
1378
- // `event.tools` is this request's tool snapshot, and OpenCode's own registry
1379
- // adds IDE tools when the servers connect and drops them when they stop
1380
- // (`stopServer` publishes `mcp.tools.changed`, which the registry turns into
1381
- // a reload). So the snapshot is also the honest answer to "is the IDE
1382
- // serving?".
1383
- if (typeof ctx.session?.hook === 'function') {
1384
- sessionRegistration = await ctx.session.hook('context', (event) => {
1385
- const tools = event?.tools;
1386
- const serving =
1387
- tools !== undefined && Object.keys(tools).some((name) => isIdeToolName(name));
1388
- if (serving) {
1389
- wasServing = true;
1390
- removeIdeToolDefinitions(tools, HIDDEN_IDE_TOOL_SUFFIXES, prefixes());
1391
- } else if (wasServing && tools !== undefined && config) {
1392
- // This session HAD the tools and this request does not. Latch a
1393
- // rebuild here, where "is the IDE serving THIS request?" is a fact: a
1394
- // failed reconnect can tear the registration down without anything else
1395
- // noticing. `wasServing` keeps this from firing for a project that
1396
- // never connected in this session — the `prompt` hook covers that case
1397
- // (a fresh instance has no registration at all) and setup deliberately
1398
- // does not connect on its own.
1399
- needsRebuild = true;
1400
- wasServing = false;
1401
- // Do not wait for the next user message. The latch is read by the
1402
- // `prompt` hook, which has ALREADY run for this request — so without
1403
- // this the session would stay tool-less for the rest of the turn and
1404
- // need one more message before anything happened.
1405
- void reviveIde();
1406
- }
1407
- if (config || serving) {
1408
- appendIdeRecoveryGuidance(event?.system, { marked: Boolean(config), serving });
1409
- }
1410
- // A skill follows its tools in both directions, and this is the one place
1411
- // that sees the answer change WITHOUT a reconnect: a request that has
1412
- // lost the enhance tools must not keep the skill that drives them. Fired,
1413
- // not awaited — the hook is synchronous, and the chain in `syncSkills`
1414
- // makes that safe. `refreshIdeTools` covers the other direction.
1218
+ // `/open-in-ide` 接入 root 与其全部子项目;`/add-sub-module-in-ide <路径>` 登记
1219
+ // 一个子项目(IDE 由模型问用户后写回);`/delete-in-ide` 删除整个配置。
1415
1220
  //
1416
- // Skipped when there is nothing it could change, so a project that never
1417
- // touched the IDE does not pay a catalog read on every request.
1418
- if (config || registeredSkillIds.length > 0) void syncSkills();
1419
- if (!injectGuidance) return;
1420
- // Guidance promises tools the model can call, so it only goes out when
1421
- // they are actually in this request.
1422
- if (!serving) return;
1423
- // 逐项目注入:每个项目那一节只写它自己的前缀,模型不需要自己换算。
1424
- // 配套插件 `idea-mcp-enhance` 的两组工具(run-lifecycle / deployment)也按每个项目分别判断,
1425
- // 所以一个项目装了 enhance、另一个没装时,各节的内容本来就不一样。
1426
- appendIdeGuidance(event?.system, currentProjects(), { tools });
1427
- });
1221
+ // 三个名字都带 `-ide` 而不是 `-idea`:这里说的是"接入 IDE",与具体产品无关 —— root 可能是
1222
+ // IDEA、子项目可能是 PyCharm,和工具前缀(`ide_*` / `<目录名>_*`)是同一套口径。
1223
+ if (typeof ctx.command?.transform === 'function') {
1224
+ commandRegistration = await ctx.command.transform((editor) => {
1225
+ editor.add({
1226
+ name: 'open-in-ide',
1227
+ description: '接入当前项目:root 及其全部已登记子项目,各自用配置好的 IDE 打开',
1228
+ execute: async (input) => {
1229
+ const parsed = parseIdeArgument(input?.prompt?.text);
1230
+ if (!parsed.ok) {
1231
+ await sendFeedback(input, {status: 'bad-argument', message: parsed.message});
1232
+ return;
1233
+ }
1234
+ let current = await readConfig();
1235
+ if (!current) {
1236
+ // 第一次:需要知道 root 用哪个 IDE。无参时让模型问用户;带参则直通。
1237
+ if (!parsed.app) {
1238
+ await askIdeChoice(input, {isRoot: true, label: '.'});
1239
+ return;
1240
+ }
1241
+ current = {root: {path: projectPath, app: parsed.app}, children: []};
1242
+ await writeConfig(current);
1243
+ } else if (parsed.app && parsed.app !== current.root.app) {
1244
+ // 直通逃生口:显式带参时改绑 root 的 IDE(模型不配合时用户仍能自救)。
1245
+ current = {root: {...current.root, app: parsed.app}, children: current.children};
1246
+ await writeConfig(current);
1247
+ }
1248
+ /** @type {boolean} */
1249
+ let unavailableNoticeSent = false;
1250
+ const result = await runOpenInIdea({
1251
+ onUnavailable: async (notice) => {
1252
+ unavailableNoticeSent = await sendFeedback(input, notice);
1253
+ return unavailableNoticeSent;
1254
+ },
1255
+ });
1256
+ // The unavailable notice is the final result for this command. If
1257
+ // it was already delivered, do not show the same warning twice.
1258
+ const unavailableAlreadySent = unavailableNoticeSent || result.unavailableNoticeSent;
1259
+ if (!unavailableAlreadySent || result.status !== 'mcp-unavailable') {
1260
+ await sendFeedback(input, result);
1261
+ }
1262
+ },
1263
+ });
1264
+ editor.add({
1265
+ name: 'add-sub-module-in-ide',
1266
+ description: '登记一个子项目(参数:子项目相对 root 的路径);随后由 AI 询问用哪个 IDE',
1267
+ execute: async (input) => {
1268
+ const parsed = parseSubprojectArgument(input?.prompt?.text);
1269
+ if (!parsed.ok) {
1270
+ await sendFeedback(input, {status: 'bad-argument', message: parsed.message});
1271
+ return;
1272
+ }
1273
+ const current = await readConfig();
1274
+ if (!current) {
1275
+ await sendFeedback(input, {status: 'not-configured'});
1276
+ return;
1277
+ }
1278
+ const absPath = resolve(projectPath, parsed.path);
1279
+ if (!existsSync(absPath)) {
1280
+ await sendFeedback(input, {
1281
+ status: 'invalid-subproject',
1282
+ message: `目录不存在:${absPath}`,
1283
+ });
1284
+ return;
1285
+ }
1286
+ const verdict = validateSubproject(current, absPath);
1287
+ if (!verdict.ok) {
1288
+ await sendFeedback(input, {status: 'invalid-subproject', message: verdict.message});
1289
+ return;
1290
+ }
1291
+ await askIdeChoice(input, {isRoot: false, label: absPath.slice(projectPath.length + 1)});
1292
+ },
1293
+ });
1294
+ editor.add({
1295
+ name: 'delete-in-ide',
1296
+ description: '删除本项目的 IDE 配置:注销全部 IDE MCP 并清除 root 与子项目的登记',
1297
+ execute: async (input) => {
1298
+ await writeConfig(null);
1299
+ // 注销全部 server。走串行化的 reconcile,所以在飞的重建(另一个
1300
+ // `/open-in-ide`、请求期自愈)不会在配置已删之后再注册回来。
1301
+ await reconcile({probeIde: false});
1302
+ await sendFeedback(input, {status: 'deleted'});
1303
+ },
1304
+ });
1305
+ });
1306
+ }
1428
1307
 
1429
- // 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
1430
- // **本次请求**里就带上 IDE 工具,没有"先回一句正在重连、下一条才行"
1431
- // 的空窗。
1432
- //
1433
- // 两种"需要重连"的形状都从这里进:
1434
- // * `needsRebuild` — 之前的请求已经证明工具没了;
1435
- // * `registrations.length === 0` — 本插件实例压根没有注册任何 IDE server,这
1436
- // 正是 OpenCode 回收 Location 服务(LayerMap idleTimeToLive = 60 分钟)
1437
- // 或进程重启后新实例的样子。setup 故意不连接,没有这一步会话就会永久
1438
- // 没有工具,只能靠用户手敲 `/open-in-ide`。
1439
- //
1440
- // 只探测、不启动 IDE:走 `reconcile({ probeIde: true })`,而不是会 `open -a`
1441
- // 的 `reviveIde()` —— 一条用户消息不该把 IDE 拉起来。
1442
- promptRegistration = await ctx.session.hook('prompt', async (event) => {
1443
- if (disposed || !config) return;
1444
- const fresh = registrations.length === 0;
1445
- if (!needsRebuild && !fresh) return;
1446
- // Never react to the plugin's own notices, or recovery would feed on
1447
- // itself. Match the notice TEXT, never `metadata.displayText`: OpenCode
1448
- // also sets `displayText` on ordinary user messages (it is the message
1449
- // text itself), so a displayText guard silently skips every real message
1450
- // — verified live, and it made this rebuild never run.
1451
- const text = event?.prompt?.text;
1452
- if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
1453
- try {
1454
- await reconcile({ probeIde: true });
1455
- } catch {
1456
- // best effort: the latch stays set and the next message retries
1308
+ // `configure_ide_project`:模型把用户选定的 IDE 写回配置的唯一通道。
1309
+ //
1310
+ // 常驻注册,不做"用时注册、写完撤销":一个工具的 schema 只有几百 token,省不下
1311
+ // 什么,却要换来一整套状态机(什么时候注册、模型一直不调用怎么办、用户中途说别
1312
+ // 的怎么办)。它该不该被调用由 description 约束。
1313
+ if (typeof ctx.tool?.transform === 'function') {
1314
+ configToolRegistration = await ctx.tool.transform((editor) => {
1315
+ editor.add(configureIdeTool());
1316
+ });
1457
1317
  }
1458
- if (registrations.length > 0) needsRebuild = false;
1459
- });
1460
1318
 
1461
- // The IDE's tool-catalog policy, in two halves that must stay in sync:
1462
- // an argument this plugin always overwrites must not also be advertised,
1463
- // or the model sets it, reasons about it, and is wrong. The schema half
1464
- // runs first, so a request never sees a knob that is already decided; see
1465
- // `mcp/idea-tool-defaults.js` for the measurements and for why this is a
1466
- // narrow exception to "never touch tool definitions".
1467
- //
1468
- // Both halves are deliberately unconditional on the project config: they are
1469
- // properties of the tool call, not of whether this project is IDE-connected.
1470
- if (typeof ctx.tool?.transform === 'function') {
1471
- toolTransformRegistration = await ctx.tool.transform((editor) => {
1472
- hideForcedArguments(editor, prefixes());
1473
- });
1474
- }
1475
- if (typeof ctx.tool?.hook === 'function') {
1476
- toolBeforeRegistration = await ctx.tool.hook('execute.before', (event) => {
1477
- if (typeof event?.tool !== 'string') return;
1478
- applyIdeToolDefaults(event.tool, event.input, prefixes());
1479
- });
1480
- }
1319
+ // Hide the tools the model must not see, and point it at the rest. This hook
1320
+ // runs for every agent-loop step (including tool-driven continuations), so
1321
+ // it stays a cheap, I/O-free function of the request: it checks the cached
1322
+ // config and this request's own tool snapshot, and takes no action.
1323
+ //
1324
+ // `event.tools` is this request's tool snapshot, and OpenCode's own registry
1325
+ // adds IDE tools when the servers connect and drops them when they stop
1326
+ // (`stopServer` publishes `mcp.tools.changed`, which the registry turns into
1327
+ // a reload). So the snapshot is also the honest answer to "is the IDE
1328
+ // serving?".
1329
+ if (typeof ctx.session?.hook === 'function') {
1330
+ sessionRegistration = await ctx.session.hook('context', (event) => {
1331
+ const tools = event?.tools;
1332
+ const serving =
1333
+ tools !== undefined && Object.keys(tools).some((name) => isIdeToolName(name));
1334
+ if (serving) {
1335
+ wasServing = true;
1336
+ removeIdeToolDefinitions(tools, HIDDEN_IDE_TOOL_SUFFIXES, prefixes());
1337
+ } else if (wasServing && tools !== undefined && config) {
1338
+ // This session HAD the tools and this request does not. Latch a
1339
+ // rebuild here, where "is the IDE serving THIS request?" is a fact: a
1340
+ // failed reconnect can tear the registration down without anything else
1341
+ // noticing. `wasServing` keeps this from firing for a project that
1342
+ // never connected in this session — the `prompt` hook covers that case
1343
+ // (a fresh instance has no registration at all) and setup deliberately
1344
+ // does not connect on its own.
1345
+ needsRebuild = true;
1346
+ wasServing = false;
1347
+ // Do not wait for the next user message. The latch is read by the
1348
+ // `prompt` hook, which has ALREADY run for this request — so without
1349
+ // this the session would stay tool-less for the rest of the turn and
1350
+ // need one more message before anything happened.
1351
+ void reviveIde();
1352
+ }
1353
+ if (config || serving) {
1354
+ appendIdeRecoveryGuidance(event?.system, {marked: Boolean(config), serving});
1355
+ }
1356
+ if (!injectGuidance) return;
1357
+ // Guidance promises tools the model can call, so it only goes out when
1358
+ // they are actually in this request.
1359
+ if (!serving) return;
1360
+ // 逐项目注入:每个项目那一节只写它自己的前缀,模型不需要自己换算。
1361
+ // 配套插件 `idea-mcp-enhance` 的两组工具(run-lifecycle / deployment)也按每个项目分别判断,
1362
+ // 所以一个项目装了 enhance、另一个没装时,各节的内容本来就不一样。
1363
+ appendIdeGuidance(event?.system, currentProjects(), {tools});
1364
+ });
1481
1365
 
1482
- // The only self-initiated action: when an IDE tool call fails in a way
1483
- // that means the IDE MCP is not usable, run the open flow again. The
1484
- // failure is classified — a plain business error (a bad path, a missing
1485
- // file) never triggers a reconnect, only "the MCP is not available" or
1486
- // "the project is not open in the IDE" do.
1487
- if (typeof ctx.tool?.hook === 'function') {
1488
- toolRegistration = await ctx.tool.hook('execute.after', async (event) => {
1489
- if (event?.status !== 'error') return;
1490
- if (typeof event?.tool !== 'string' || !isIdeToolName(event.tool)) return;
1491
- if (!config) return;
1492
- const kind = classifyIdeFailure(event.error, serverNames());
1493
- if (kind === 'none') return;
1494
- if (kind === 'project') {
1495
- // The IDE is up and answering; only the PROJECT is missing. That is
1496
- // never a connection problem, so this must never fall through to
1497
- // the reconnect below — on a platform with no launcher there is
1498
- // nothing to call, and falling through would report "reconnected"
1499
- // and send the model round after round after a call that can only
1500
- // fail. Leave the IDE's own message, which names the real cause.
1501
- if (!launchEnabled) return;
1502
- // 只有这个工具所属的那个项目的 IDE 需要重开(多项目下别去动别的项目)。
1503
- const project = projectForTool(event.tool);
1504
- const app = project ? appNameFor(project.app) : undefined;
1505
- if (!project || !app) return;
1506
- await openInIde(project.path, { app });
1507
- rewriteToolError(
1508
- event,
1509
- `该项目已在 ${IDE_LABELS[project.app] ?? 'IDE'} 中重新打开,请重试刚才的操作。`,
1510
- );
1511
- return;
1512
- }
1513
- const result = await runOpenInIdea();
1514
- if (result.port === undefined) return;
1515
- rewriteToolError(event, 'IDE MCP 已重新连接,请重试刚才的操作。');
1516
- });
1517
- }
1518
- }
1366
+ // 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
1367
+ // **本次请求**里就带上 IDE 工具,没有"先回一句正在重连、下一条才行"
1368
+ // 的空窗。
1369
+ //
1370
+ // 两种"需要重连"的形状都从这里进:
1371
+ // * `needsRebuild` — 之前的请求已经证明工具没了;
1372
+ // * `registrations.length === 0` — 本插件实例压根没有注册任何 IDE server,这
1373
+ // 正是 OpenCode 回收 Location 服务(LayerMap idleTimeToLive = 60 分钟)
1374
+ // 或进程重启后新实例的样子。setup 故意不连接,没有这一步会话就会永久
1375
+ // 没有工具,只能靠用户手敲 `/open-in-ide`。
1376
+ //
1377
+ // 只探测、不启动 IDE:走 `reconcile({ probeIde: true })`,而不是会 `open -a`
1378
+ // 的 `reviveIde()` —— 一条用户消息不该把 IDE 拉起来。
1379
+ promptRegistration = await ctx.session.hook('prompt', async (event) => {
1380
+ if (disposed || !config) return;
1381
+ const fresh = registrations.length === 0;
1382
+ if (!needsRebuild && !fresh) return;
1383
+ // Never react to the plugin's own notices, or recovery would feed on
1384
+ // itself. Match the notice TEXT, never `metadata.displayText`: OpenCode
1385
+ // also sets `displayText` on ordinary user messages (it is the message
1386
+ // text itself), so a displayText guard silently skips every real message
1387
+ // — verified live, and it made this rebuild never run.
1388
+ const text = event?.prompt?.text;
1389
+ if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
1390
+ try {
1391
+ await reconcile({probeIde: true});
1392
+ } catch {
1393
+ // best effort: the latch stays set and the next message retries
1394
+ }
1395
+ if (registrations.length > 0) needsRebuild = false;
1396
+ });
1519
1397
 
1520
- return async () => {
1521
- disposed = true;
1522
- if (sessionRegistration) {
1523
- try {
1524
- await sessionRegistration.dispose();
1525
- } catch {
1526
- // best effort
1527
- }
1528
- }
1529
- if (toolRegistration) {
1530
- try {
1531
- await toolRegistration.dispose();
1532
- } catch {
1533
- // best effort
1534
- }
1535
- }
1536
- if (toolBeforeRegistration) {
1537
- try {
1538
- await toolBeforeRegistration.dispose();
1539
- } catch {
1540
- // best effort
1541
- }
1542
- }
1543
- if (toolTransformRegistration) {
1544
- try {
1545
- await toolTransformRegistration.dispose();
1546
- } catch {
1547
- // best effort
1548
- }
1549
- }
1550
- if (configToolRegistration) {
1551
- try {
1552
- await configToolRegistration.dispose();
1553
- } catch {
1554
- // best effort
1555
- }
1556
- }
1557
- if (promptRegistration) {
1558
- try {
1559
- await promptRegistration.dispose();
1560
- } catch {
1561
- // best effort
1562
- }
1563
- }
1564
- if (commandRegistration) {
1565
- try {
1566
- await commandRegistration.dispose();
1567
- } catch {
1568
- // best effort
1569
- }
1570
- }
1571
- if (skillRegistration) {
1572
- try {
1573
- await skillRegistration.dispose();
1574
- } catch {
1575
- // best effort
1398
+ // The IDE's tool-catalog policy, in two halves that must stay in sync:
1399
+ // an argument this plugin always overwrites must not also be advertised,
1400
+ // or the model sets it, reasons about it, and is wrong. The schema half
1401
+ // runs first, so a request never sees a knob that is already decided; see
1402
+ // `mcp/idea-tool-defaults.js` for the measurements and for why this is a
1403
+ // narrow exception to "never touch tool definitions".
1404
+ //
1405
+ // Both halves are deliberately unconditional on the project config: they are
1406
+ // properties of the tool call, not of whether this project is IDE-connected.
1407
+ if (typeof ctx.tool?.transform === 'function') {
1408
+ toolTransformRegistration = await ctx.tool.transform((editor) => {
1409
+ hideForcedArguments(editor, prefixes());
1410
+ });
1411
+ }
1412
+ if (typeof ctx.tool?.hook === 'function') {
1413
+ toolBeforeRegistration = await ctx.tool.hook('execute.before', (event) => {
1414
+ if (typeof event?.tool !== 'string') return;
1415
+ applyIdeToolDefaults(event.tool, event.input, prefixes());
1416
+ });
1417
+ }
1418
+
1419
+ // The only self-initiated action: when an IDE tool call fails in a way
1420
+ // that means the IDE MCP is not usable, run the open flow again. The
1421
+ // failure is classified — a plain business error (a bad path, a missing
1422
+ // file) never triggers a reconnect, only "the MCP is not available" or
1423
+ // "the project is not open in the IDE" do.
1424
+ if (typeof ctx.tool?.hook === 'function') {
1425
+ toolRegistration = await ctx.tool.hook('execute.after', async (event) => {
1426
+ if (event?.status !== 'error') return;
1427
+ if (typeof event?.tool !== 'string' || !isIdeToolName(event.tool)) return;
1428
+ if (!config) return;
1429
+ const kind = classifyIdeFailure(event.error, serverNames());
1430
+ if (kind === 'none') return;
1431
+ if (kind === 'project') {
1432
+ // The IDE is up and answering; only the PROJECT is missing. That is
1433
+ // never a connection problem, so this must never fall through to
1434
+ // the reconnect below — on a platform with no launcher there is
1435
+ // nothing to call, and falling through would report "reconnected"
1436
+ // and send the model round after round after a call that can only
1437
+ // fail. Leave the IDE's own message, which names the real cause.
1438
+ if (!launchEnabled) return;
1439
+ // 只有这个工具所属的那个项目的 IDE 需要重开(多项目下别去动别的项目)。
1440
+ const project = projectForTool(event.tool);
1441
+ const app = project ? appNameFor(project.app) : undefined;
1442
+ if (!project || !app) return;
1443
+ await openInIde(project.path, {app});
1444
+ rewriteToolError(
1445
+ event,
1446
+ `该项目已在 ${IDE_LABELS[project.app] ?? 'IDE'} 中重新打开,请重试刚才的操作。`,
1447
+ );
1448
+ return;
1449
+ }
1450
+ const result = await runOpenInIdea();
1451
+ if (result.port === undefined) return;
1452
+ rewriteToolError(event, 'IDE MCP 已重新连接,请重试刚才的操作。');
1453
+ });
1454
+ }
1576
1455
  }
1577
- }
1578
- await deactivateIde();
1579
- };
1580
- },
1456
+
1457
+ return async () => {
1458
+ disposed = true;
1459
+ if (sessionRegistration) {
1460
+ try {
1461
+ await sessionRegistration.dispose();
1462
+ } catch {
1463
+ // best effort
1464
+ }
1465
+ }
1466
+ if (toolRegistration) {
1467
+ try {
1468
+ await toolRegistration.dispose();
1469
+ } catch {
1470
+ // best effort
1471
+ }
1472
+ }
1473
+ if (toolBeforeRegistration) {
1474
+ try {
1475
+ await toolBeforeRegistration.dispose();
1476
+ } catch {
1477
+ // best effort
1478
+ }
1479
+ }
1480
+ if (toolTransformRegistration) {
1481
+ try {
1482
+ await toolTransformRegistration.dispose();
1483
+ } catch {
1484
+ // best effort
1485
+ }
1486
+ }
1487
+ if (configToolRegistration) {
1488
+ try {
1489
+ await configToolRegistration.dispose();
1490
+ } catch {
1491
+ // best effort
1492
+ }
1493
+ }
1494
+ if (promptRegistration) {
1495
+ try {
1496
+ await promptRegistration.dispose();
1497
+ } catch {
1498
+ // best effort
1499
+ }
1500
+ }
1501
+ if (commandRegistration) {
1502
+ try {
1503
+ await commandRegistration.dispose();
1504
+ } catch {
1505
+ // best effort
1506
+ }
1507
+ }
1508
+ if (skillRegistration) {
1509
+ try {
1510
+ await skillRegistration.dispose();
1511
+ } catch {
1512
+ // best effort
1513
+ }
1514
+ }
1515
+ await deactivateIde();
1516
+ };
1517
+ },
1581
1518
  };