opencode-idea 0.0.23 → 0.0.25

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,1057 +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;
470
+ id: 'opencode-idea',
532
471
 
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);
472
+ /**
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
543
+ */
544
+ async setup(ctx) {
545
+ const projectPath = currentProjectPath(ctx.location);
546
+ if (!projectPath) return;
550
547
 
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
- let disposed = false;
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);
568
565
 
569
- // Latches when a request showed the IDE tools are gone. It is cleared
570
- // only by a successful rebuild. It is set from the `context` hook (a request
571
- // that HAD the tools no longer does); the `prompt` hook also heals without
572
- // this latch when the instance has no registration at all.
573
- let needsRebuild = false;
574
- // Set once a request in this session actually carried IDE tools, so a
575
- // later request without them is a DROP of this session's own connection —
576
- // not a root that simply never connected.
577
- let wasServing = false;
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;
578
583
 
579
- // 本会话的 IDE 配置:root 用哪个 IDE,以及登记了哪些子项目、各自用哪个 IDE。
580
- // 一个插件实例服务一个 root(OpenCode 按 Location 激活插件),所以这就是全部
581
- // 状态。持久化在 `ctx.storage` 的 `project/<rootPath>` 下。
582
- //
583
- // `undefined` = 还没读过;`null` = 读过、但没有配置。两者必须分开:混成一种,
584
- // 请求钩子就会把"还没读"当成"未配置",漏掉一次本该发生的自愈。
585
- const storage = ctx.storage;
586
- const markKey = markKeyFor(projectPath);
587
- /** @type {import('./ide-projects.js').IdeConfig | null | undefined} */
588
- let config;
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;
589
593
 
590
- /** 读一次持久化配置(最多一次 I/O),之后走内存缓存。 */
591
- const readConfig = async () => {
592
- if (config !== undefined) return config;
593
- if (!storage || typeof storage.get !== 'function') {
594
- config = null;
595
- return config;
596
- }
597
- try {
598
- config = parseMark(await storage.get(markKey), projectPath) ?? null;
599
- } catch {
600
- // storage is best effort;读不到就当作未配置
601
- config = null;
602
- }
603
- return config;
604
- };
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;
605
604
 
606
- /**
607
- * 写入配置(`null` = 删除),并同步内存缓存。
608
- *
609
- * 先写 storage 再更新缓存(write-through):写失败也要更新缓存,否则用户刚做的
610
- * 配置在本进程里不可见,行为会自相矛盾。命令与 `configure_ide_project` 是唯一的
611
- * 写入者。
612
- *
613
- * @param {import('./ide-projects.js').IdeConfig | null} next
614
- */
615
- const writeConfig = async (next) => {
616
- try {
617
- if (next) await storage?.set?.(markKey, serializeMark(next));
618
- else await storage?.remove?.(markKey);
619
- } catch {
620
- // best effort
621
- }
622
- config = next;
623
- };
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
+ };
624
620
 
625
- /** 当前接入的全部项目(root 在前),每个带工具前缀与展示标签。 */
626
- const currentProjects = () =>
627
- config
628
- ? listProjects(config).map((project) => ({
629
- ...project,
630
- label: project.isRoot ? '.' : project.path.slice(projectPath.length + 1),
631
- }))
632
- : [];
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
+ };
633
639
 
634
- /** 当前所有项目的工具前缀(含下划线);未配置时为空。 */
635
- const prefixes = () => (config ? toolPrefixes(config) : []);
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
+ : [];
636
648
 
637
- /** 当前注册的全部 server 名(= 前缀去掉下划线)。 */
638
- const serverNames = () => (config ? listProjects(config).map((project) => project.server) : []);
649
+ /** 当前所有项目的工具前缀(含下划线);未配置时为空。 */
650
+ const prefixes = () => (config ? toolPrefixes(config) : []);
639
651
 
640
- /** 这个工具名是否属于某个 IDE server(插件自己的工具不算)。 */
641
- const isIdeToolName = (name) => {
642
- if (PLUGIN_TOOL_NAMES.has(name)) return false;
643
- return prefixes().some((prefix) => name.startsWith(prefix));
644
- };
652
+ /** 当前注册的全部 server 名(= 前缀去掉下划线)。 */
653
+ const serverNames = () => (config ? listProjects(config).map((project) => project.server) : []);
645
654
 
646
- /**
647
- * 这个 IDE 工具属于哪个项目?
648
- *
649
- * 按**最长前缀**匹配:`modelserver_` 必须先于 `model_` 命中,否则一个叫 `model`
650
- * 的项目会把 `modelserver_*` 的工具认领走。
651
- *
652
- * @param {string} name
653
- */
654
- const projectForTool = (name) =>
655
- currentProjects()
656
- .filter((project) => name.startsWith(`${project.server}_`))
657
- .sort((a, b) => b.server.length - a.server.length)[0];
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
+ };
658
660
 
659
- // 先解析一次,请求钩子之后就能同步读到。
660
- await readConfig();
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];
661
673
 
662
- /** 注销当前注册的全部 IDE server。 */
663
- const deactivateIde = async () => {
664
- const current = registrations.slice();
665
- registrations.length = 0;
666
- if (current.length === 0) return;
667
- for (const registration of current) {
668
- try {
669
- await registration.dispose();
670
- } catch {
671
- // best effort
672
- }
673
- }
674
- };
674
+ // 先解析一次,请求钩子之后就能同步读到。
675
+ await readConfig();
675
676
 
676
- const waitForIdeTools = async () => {
677
- if (typeof ctx.tool?.list !== 'function') return false;
678
- const deadline = Date.now() + toolRefreshTimeoutMs;
679
- while (Date.now() < deadline) {
680
- try {
681
- const ids = toolIds(await ctx.tool.list());
682
- if (ids.some((id) => isIdeToolName(id))) return true;
683
- } catch {
684
- // The MCP catalog may still be settling; retry within the bounded wait.
685
- }
686
- await new Promise((resolve) => setTimeout(resolve, TOOL_REFRESH_POLL_MS));
687
- }
688
- return false;
689
- };
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
+ };
690
690
 
691
- /**
692
- * 一个项目的 MCP 端口,由它的 IDE 唯一决定(`IDE_PORTS`,固定值)。
693
- *
694
- * @param {{ path: string, app: string }} project
695
- * @returns {number | undefined}
696
- */
697
- const portFor = (project) => IDE_PORTS[project.app];
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
+ };
698
705
 
699
- const refreshIdeTools = async () => {
700
- if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
701
- // Reconcile the MCP servers above, then replay the tool registry so the
702
- // refreshed catalog lands in the next model request's tool snapshot.
703
- // Without this, a session that already captured a snapshot keeps the old
704
- // tool list until the session is reopened.
705
- if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
706
- return await waitForIdeTools();
707
- };
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];
708
713
 
709
- /**
710
- * 按配置给**每个**已打开的项目各注册一个 MCP server。
711
- *
712
- * server 名就是项目的工具前缀(root = `ide`,子项目 = 目录名),所以 `editor.set`
713
- * 的 key 与 `serverConfig` 里带的项目路径必须成对出现 —— 一个 server 只服务一个
714
- * 项目(路径走 header,注册后无法逐次改写)。
715
- *
716
- * @param {ReadonlyArray<{ project: { path: string, server: string }, port: number }>} bindings
717
- */
718
- const activateIde = async (bindings) => {
719
- const mcpRegistration = await ctx.mcp.transform((editor) => {
720
- for (const binding of bindings) {
721
- editor.set(
722
- binding.project.server,
723
- serverConfig(binding.port, binding.project.path, { executionTimeoutMs }),
724
- );
725
- }
726
- });
727
- registrations = [mcpRegistration];
728
- return refreshIdeTools();
729
- };
730
-
731
- const reconcileOnce = async ({ probeIde = true } = {}) => {
732
- if (disposed) return false;
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();
722
+ };
733
723
 
734
- // 未配置:没有任何项目要接入。
735
- if (!config) {
736
- await deactivateIde();
737
- return false;
738
- }
739
- // Manual: only the commands probe the IDE. Setup calls this with
740
- // `probeIde: false` so it never connects until a command asks.
741
- if (!probeIde) {
742
- await deactivateIde();
743
- return false;
744
- }
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
+ };
745
745
 
746
- const bindings = [];
747
- for (const project of currentProjects()) {
748
- const port = await portForProject(project);
749
- if (port !== undefined) bindings.push({ project, port });
750
- }
746
+ const reconcileOnce = async ({probeIde = true} = {}) => {
747
+ if (disposed) return false;
751
748
 
752
- // Always tear the servers down and register them again. OpenCode's reconcile
753
- // SKIPS a re-`set` whose config is unchanged, so re-registering without a
754
- // teardown would leave a dropped server in place forever. The commands and
755
- // the request hooks are rare; rebuilding costs one restart, which is the
756
- // right trade for never leaving a dead registration behind.
757
- await deactivateIde();
758
- if (bindings.length === 0) return false;
759
- return activateIde(bindings);
760
- };
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
+ }
761
760
 
762
- // Serialize reconciles so overlapping callers can never register/activate
763
- // the IDE concurrently (which would leak registrations).
764
- let reconcileChain = Promise.resolve();
765
- // Non-zero while a reconcile is queued or running.
766
- let reconcileBusy = 0;
767
- const enqueue = (task) => {
768
- reconcileBusy += 1;
769
- const next = reconcileChain.then(task);
770
- reconcileChain = next.then(
771
- () => {},
772
- () => {},
773
- );
774
- return next.finally(() => {
775
- reconcileBusy -= 1;
776
- });
777
- };
778
- const reconcile = (options) => enqueue(() => reconcileOnce(options));
761
+ const bindings = [];
762
+ for (const project of currentProjects()) {
763
+ const port = await portForProject(project);
764
+ if (port !== undefined) bindings.push({project, port});
765
+ }
779
766
 
780
- /**
781
- * Is `path` actually OPEN in the IDE that serves `port`?
782
- *
783
- * A live endpoint only means the IDE is RUNNING — it may have a different
784
- * project open (or sit on the Welcome screen), while everything we do is
785
- * scoped to THIS project. So ask the IDE directly with one project-scoped
786
- * call: the server resolves the project from `IJ_MCP_SERVER_PROJECT_PATH`
787
- * BEFORE running the tool, and for a project that is not open it fails with
788
- * "Unable to determine the target project ..." — the same shape
789
- * `classifyIdeFailure` maps to `project`. Any other outcome (a real result,
790
- * or an unrelated tool error) means the project WAS resolved, i.e. open.
791
- *
792
- * A transport failure (`undefined`) counts as "not confirmed open", so the
793
- * caller still asks the IDE to open the project: `open -a` is idempotent,
794
- * and the only cost is a redundant focus.
795
- *
796
- * 这个判定现在只回答一个问题:"这个 IDE 里打开着这个项目吗?" —— 端口本身由
797
- * `IDE_PORTS` 按 IDE 唯一确定,不再需要靠它去区分端口归属。
798
- *
799
- * ⚠️ 实测(2026-10-16,IntelliJ IDEA 2026.2 + PyCharm 2026.2):请求路径**落在某个已打开
800
- * 项目的目录树内**时,IDE 会把它解析到那个项目 —— `get_project_modules` 正常返回它的模块,
801
- * 而不是报错。只有路径完全不在任何已打开项目下(`/tmp`、不存在的路径)才报
802
- * "Unable to determine the target project"。
803
- *
804
- * 因此「子项目与 root 用同一个 IDE」时必须知道:只要那个 IDE 打开了 root,子项目就会被判成
805
- * "已打开" —— 它确实是 root 项目的一部分,工具能操作它的文件,只是语言支持按 **root 项目**
806
- * 走(Python 子项目被 root 的 Web 模块解析,符号/检查会不准)。这是 IDE 的目录包含语义,
807
- * 不是探测可以绕过的;要让子项目按自己的模块解析,得让它在那个 IDE 里**单独打开**一个项目
808
- * —— 或者(正常做法)给它配它真正被打开的那个 IDE。
809
- *
810
- * A transport failure (`undefined`) counts as "not confirmed open", so the
811
- * caller still asks the IDE to open the project: `open -a` is idempotent,
812
- * and the only cost is a redundant focus.
813
- *
814
- * @param {number} port
815
- * @param {string} path
816
- */
817
- const isProjectOpen = async (port, path) => {
818
- const result = await callTool(port, path, 'get_project_modules', {}, {
819
- timeoutMs: mcpProbeTimeoutMs,
820
- }).catch(() => undefined);
821
- if (!result) return false;
822
- if (result.isError !== true) return true;
823
- const text = (result.content ?? [])
824
- .filter((part) => part?.type === 'text' && typeof part.text === 'string')
825
- .map((part) => part.text)
826
- .join('\n');
827
- return classifyIdeFailure({ message: text }, serverNames()) !== 'project';
828
- };
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
+ };
829
776
 
830
- /**
831
- * 这个项目的 IDE 端口上,服务着它吗?是就返回端口,否则 `undefined`。
832
- *
833
- * @param {{ path: string, app: string }} project
834
- */
835
- const portForProject = async (project) => {
836
- const port = portFor(project);
837
- if (port === undefined) return undefined;
838
- return (await isProjectOpen(port, project.path)) ? port : undefined;
839
- };
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));
840
796
 
841
- /**
842
- * `open -a` only requests activation/opening. A cold IDE needs time before
843
- * its bundled MCP server listens, so poll until the endpoint appears AND
844
- * answers for THIS project. This is the step that makes a single
845
- * `/open-in-ide` enough on a cold start.
846
- *
847
- * @param {{ path: string, app: string }} project
848
- * @returns {Promise<number | undefined>}
849
- */
850
- const waitForProjectServer = async (project) => {
851
- const deadline = Date.now() + mcpStartTimeoutMs;
852
- while (Date.now() < deadline) {
853
- const port = await portForProject(project).catch(() => undefined);
854
- if (port !== undefined) return port;
855
- const remaining = deadline - Date.now();
856
- if (remaining <= 0) break;
857
- await new Promise((resolve) => setTimeout(resolve, Math.min(MCP_START_POLL_MS, remaining)));
858
- }
859
- return undefined;
860
- };
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
+ };
861
846
 
862
- /**
863
- * 每个项目各自的启动状态:in-flight、拉起冷却、是否已发过通知。
864
- *
865
- * 按项目分开是必需的:IDEA 与 PyCharm 是两个独立进程,一个的冷却不能拖住另一个;
866
- * 而同一个项目的重复调用仍然共享一次拉起(re-entrant)。
867
- */
868
- const launchStates = new Map();
869
- const launchStateFor = (path) => {
870
- let state = launchStates.get(path);
871
- if (!state) {
872
- state = {
873
- /** @type {Promise<IdeCommandResult> | undefined} */
874
- inFlight: undefined,
875
- noticeSent: false,
876
- guard: createLaunchGuard({ cooldownMs: launchCooldownMs, maxAttempts: launchMaxAttempts }),
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;
877
856
  };
878
- launchStates.set(path, state);
879
- }
880
- return state;
881
- };
882
857
 
883
- /**
884
- * 确保 `project` 的 IDE 正开着、并且打开着这个项目;返回它的端口。
885
- *
886
- * @param {{ path: string, server: string, app: string, label: string, isRoot: boolean }} project
887
- * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
888
- * @returns {Promise<IdeCommandResult>}
889
- */
890
- const ensureProject = (project, { onUnavailable } = {}) => {
891
- const state = launchStateFor(project.path);
892
- if (state.inFlight) return state.inFlight;
893
- const reportUnavailable = async (opened) => {
894
- if (state.noticeSent || typeof onUnavailable !== 'function') return;
895
- // Fast-fail: notify as soon as the endpoint is known to be missing so
896
- // the user can enable MCP + Brave Mode and retry the command.
897
- const delivered = await onUnavailable({ status: 'mcp-unavailable', opened, project });
898
- if (delivered) state.noticeSent = true;
899
- };
900
- const run = (async () => {
901
- // 已经开着并且就打开着这个项目:什么都不用做。
902
- const serving = await portForProject(project).catch(() => undefined);
903
- if (serving !== undefined) {
904
- state.guard.reset();
905
- state.noticeSent = false;
906
- return { status: 'connected', port: serving, project, unavailableNoticeSent: false };
907
- }
908
- // 需要一个 app 才能拉起。拿不到 app 的原因有两种,交给 autoLaunchSupported 区分。
909
- const app = launchEnabled ? appNameFor(project.app) : undefined;
910
- if (!app) {
911
- return { status: autoLaunchSupported() ? 'disabled' : 'manual-launch', project };
912
- }
913
- if (state.guard.exhausted() || state.guard.coolingDown()) {
914
- // There is no reason to wait here: MCP is disabled/not ready, and
915
- // the user must enable it in the IDE before retrying the command.
916
- await reportUnavailable(false);
917
- return { status: 'mcp-unavailable', opened: false, project, unavailableNoticeSent: state.noticeSent };
918
- }
919
- state.guard.markSpawn();
920
- const launched = await openInIde(project.path, { app });
921
- if (!launched) return { status: 'launch-failed', project };
922
- const started = await waitForProjectServer(project);
923
- if (started !== undefined) {
924
- state.guard.reset();
925
- state.noticeSent = false;
926
- return { status: 'connected', port: started, project, unavailableNoticeSent: false };
927
- }
928
- await reportUnavailable(true);
929
- return { status: 'mcp-unavailable', opened: true, project, unavailableNoticeSent: state.noticeSent };
930
- })();
931
- state.inFlight = run;
932
- void run
933
- .catch(() => {})
934
- .then(() => {
935
- if (state.inFlight === run) state.inFlight = undefined;
936
- });
937
- return run;
938
- };
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
+ };
939
878
 
940
- /**
941
- * `/open-in-ide` 的主流程:让每个已登记项目的 IDE 都就绪,然后把它们注册进
942
- * OpenCode。
943
- *
944
- * 每个项目各自拉起**自己的** IDE —— root 在 IDEA、某子项目在 PyCharm 是常态,
945
- * 所以这里不是"连一个 IDE",而是"连一组",彼此并行。
946
- *
947
- * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
948
- * @returns {Promise<IdeCommandResult>}
949
- */
950
- const runOpenInIdea = async ({ onUnavailable } = {}) => {
951
- const projects = currentProjects();
952
- if (projects.length === 0) return { status: 'not-configured' };
953
- const results = await Promise.all(
954
- projects.map((project) => ensureProject(project, { onUnavailable })),
955
- );
956
- const connected = results.filter((result) => result.status === 'connected');
957
- if (connected.length === 0) {
958
- // 一个都没连上:注销残留注册,并挑一个代表性结果回去(文案已带上具体项目)。
959
- await reconcile({ probeIde: false }).catch(() => {});
960
- return results[0];
961
- }
962
- let toolsReady = false;
963
- try {
964
- // 重新探测一次并整体注册:ensureProject 只保证 IDE 就绪,注册要按"当前真正
965
- // 打开着哪些项目"重来一遍,这样 `/open-in-ide` 每次都是同一套结果。
966
- toolsReady = (await reconcile({ probeIde: true })) === true;
967
- } catch {
968
- // capability load is best effort; feedback still matters
969
- }
970
- const connectedProjects = connected.map((result) => result.project);
971
- return toolsReady
972
- ? { status: 'connected', port: connected[0].port, projects: connectedProjects }
973
- : { ...connected[0], status: 'tools-loading', projects: connectedProjects };
974
- };
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
+ };
975
899
 
976
- /**
977
- * Coalesced background reconnect.
978
- *
979
- * The `context` hook latches the rebuild the moment a request shows the tools
980
- * are gone, but a latch alone only fires on the NEXT user message — one whole
981
- * turn later, which is exactly the gap that made a dead session sit there
982
- * until `/open-in-ide` was run by hand. This runs out of band (it never
983
- * blocks the request) so the next request — the following agent-loop step, or
984
- * any later message — already carries the tools again.
985
- *
986
- * @returns {Promise<void>}
987
- */
988
- /** @type {Promise<void> | undefined} */
989
- let reviveInFlight;
990
- const reviveIde = () => {
991
- // 配置 gate 住每一次重连,包括后台发起的那些:后台尝试绝不能把用户已经用
992
- // `/delete-in-ide` 删掉的项目重新拉起来。
993
- if (disposed || !config) return Promise.resolve();
994
- if (reviveInFlight) return reviveInFlight;
995
- const run = (async () => {
996
- try {
997
- const result = await runOpenInIdea();
998
- // `/delete-in-ide` 可能在这期间落地;它赢。
999
- if (!config) {
1000
- await deactivateIde();
1001
- return;
1002
- }
1003
- if (result.port !== undefined) {
1004
- needsRebuild = false;
1005
- }
1006
- } catch {
1007
- // best effort: the latch stays set, and the prompt hook retries
1008
- }
1009
- })();
1010
- reviveInFlight = run;
1011
- void run.then(() => {
1012
- if (reviveInFlight === run) reviveInFlight = undefined;
1013
- });
1014
- return run;
1015
- };
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
+ };
1016
957
 
1017
- // Setup never probes or connects any IDE — everything is on the commands.
1018
- await reconcile({ probeIde: false });
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
+ };
1019
994
 
1020
- const sendFeedback = async (input, result) => {
1021
- if (feedback !== 'message' || typeof ctx.session?.prompt !== 'function') return false;
1022
- try {
1023
- await ctx.session.prompt({
1024
- ...input.prompt,
1025
- sessionID: input.sessionID,
1026
- text: idePromptText(result),
1027
- metadata: idePromptMetadata(result),
1028
- delivery: input.delivery,
1029
- });
1030
- return true;
1031
- } catch {
1032
- // feedback is best effort
1033
- return false;
1034
- }
1035
- };
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
+ };
1036
1035
 
1037
- // The shipped skills are static knowledge (`skills/<id>/SKILL.md`) that write
1038
- // a project's own rules into its `AGENTS.md`: `idea-standalone` fixes how
1039
- // resident apps are started, `idea-run-config-manager` fixes which repeated
1040
- // flows are solidified into configurations.
1041
- //
1042
- // They are registered **once, when the plugin loads** — with no condition on
1043
- // the project config and none on the IDE's tool catalog. That is a deliberate
1044
- // reversal of the earlier design, which offered a skill only after the tools
1045
- // it drives were actually served. The gate looked right in isolation and was
1046
- // wrong in use: the client snapshots the skill list when the session starts,
1047
- // so anything registered later (by `/open-in-ide` finishing, or by a catalog
1048
- // refresh) stays invisible until the page is reloaded — the user reads that
1049
- // as "the skill is missing" while the registry already has it.
1050
- //
1051
- // What the old gate protected against is handled where it belongs: the skill
1052
- // text itself says what to do when a tool is absent ("hand it back to the
1053
- // user", "run /open-in-ide"). A missing tool is a fact the model can read and
1054
- // act on; an invisible skill is not.
1055
- //
1056
- // The frontmatter `requires:` key is gone with the gate. It was this
1057
- // package's own metadata, never read by OpenCode, and keeping it after the
1058
- // gate disappeared would look like it still decided something.
1059
- //
1060
- // Deliberately NOT wrapped in a catch: these are core features of the plugin,
1061
- // so a rejected definition must fail loudly. Swallowing it would ship a
1062
- // silently incomplete plugin — which is exactly how 0.0.6 lost
1063
- // `/open-in-ide` without anyone noticing until the command was gone.
1064
- const registerSkills = async () => {
1065
- if (typeof ctx.skill?.transform !== 'function') return;
1066
- skillRegistration = await ctx.skill.transform((editor) => {
1067
- for (const { skill } of readSkills()) editor.add(skill);
1068
- });
1069
- };
1070
- await registerSkills();
1036
+ // Setup never probes or connects any IDE — everything is on the commands.
1037
+ await reconcile({probeIde: false});
1071
1038
 
1072
- /**
1073
- * 问用户"这个项目用哪个 IDE",由模型转达并回写。
1074
- *
1075
- * 插件自己不能弹选择框,也不能挂起等待;唯一可靠的通道是让模型去问、再把答案用
1076
- * `configure_ide_project` 结构化地写回来。所以这里发的**不是通知而是任务**:文本
1077
- * 里必须写明"调用了工具才算完成",否则模型很容易只回一句"好的,用 PyCharm"。
1078
- *
1079
- * @param {{ sessionID?: string, prompt?: unknown, delivery?: unknown }} input
1080
- * @param {{ label: string, isRoot: boolean }} target
1081
- */
1082
- const askIdeChoice = async (input, target) => {
1083
- if (typeof ctx.session?.prompt !== 'function') return;
1084
- const where = target.isRoot ? `根项目(${projectPath})` : `子项目 ${target.label}`;
1085
- const call = target.isRoot
1086
- ? 'configure_ide_project({ ide: "<idea|pycharm>" })'
1087
- : `configure_ide_project({ path: "${target.label}", ide: "<idea|pycharm>" })`;
1088
- // 插件自己摸底,而不是让模型去"找 IDE" —— 它找到的办法通常是 `ps aux | grep`(只能
1089
- // 看到**正在运行**的进程,装了没开就漏),又慢又不准。这里是按应用目录读一次得到的。
1090
- const installed = detectInstalledIdes();
1091
- const missing = SUPPORTED_IDES.filter((app) => !installed.includes(app));
1092
- const detectLines = installed.length
1093
- ? [
1094
- `插件已探测本机安装情况:装有 ${installed.map((app) => IDE_LABELS[app] ?? app).join('、')}` +
1095
- (missing.length
1096
- ? `;没找到 ${missing.map((app) => IDE_LABELS[app] ?? app).join('、')}(用户若要用它,需要自己先装)`
1097
- : '') +
1098
- '。',
1099
- '**不要再自己去跑命令或翻进程** —— 这是按应用目录得到的结论,进程里没有不代表没装。',
1100
- '',
1101
- ]
1102
- : [];
1103
- const text = [
1104
- `${NOTICE_PREFIX}需要确定 ${where} 用哪个 IDE 打开。`,
1105
- '',
1106
- ...detectLines,
1107
- '请向用户提问,并**在得到答复后调用 `configure_ide_project` 工具**写入配置。可选值只有两个:',
1108
- '- `idea` → IntelliJ IDEA',
1109
- '- `pycharm` → PyCharm',
1110
- '',
1111
- '要选它**真正被打开**的那个 IDE(例如 Python 子项目通常是 PyCharm;选错了,IDE 会把请求解析到',
1112
- '包含它的另一个已打开项目上,符号检索与检查会按那个项目的语言支持走)。',
1113
- '',
1114
- `调用形式:${call}`,
1115
- '',
1116
- '不要替用户决定,也不要只回答"已配置" —— 没有真正调用工具就等于没配置。',
1117
- ].join('\n');
1118
- try {
1119
- await ctx.session.prompt({
1120
- ...input.prompt,
1121
- sessionID: input.sessionID,
1122
- text,
1123
- metadata: {
1124
- displayText: `请选择 ${where} 使用的 IDE:IntelliJ IDEA 还是 PyCharm?`,
1125
- comments: [],
1126
- },
1127
- delivery: input.delivery,
1128
- });
1129
- } catch {
1130
- // best effort
1131
- }
1132
- };
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
+ };
1133
1055
 
1134
- /**
1135
- * `configure_ide_project` 的执行体:校验后写入配置并立即接入。
1136
- *
1137
- * 这是模型把用户的选择写回插件的**唯一**通道,所以它自己也做全套校验:模型可能
1138
- * 传错路径、传一个不是 IDE 的值,插件不能因为"是模型给的"就放行。
1139
- *
1140
- * @param {{ path?: unknown, ide?: unknown }} args
1141
- */
1142
- const configureIdeProject = async (args) => {
1143
- const ide = typeof args?.ide === 'string' ? args.ide.toLowerCase() : '';
1144
- if (!SUPPORTED_IDES.includes(ide)) {
1145
- return { content: `不支持的 IDE \`${String(args?.ide)}\`。可选:${SUPPORTED_IDES.join(' / ')}。` };
1146
- }
1147
- const rawPath = typeof args?.path === 'string' ? args.path.trim() : '';
1148
- let current = await readConfig();
1149
- if (rawPath === '') {
1150
- current = current
1151
- ? { root: { ...current.root, app: ide }, children: current.children }
1152
- : { root: { path: projectPath, app: ide }, children: [] };
1153
- await writeConfig(current);
1154
- await runOpenInIdea();
1155
- return { content: `已把根项目配置为 ${IDE_LABELS[ide]} 并接入。` };
1156
- }
1157
- if (!current) {
1158
- return { content: '当前项目还没有 IDE 配置。请先执行 /open-in-ide。' };
1159
- }
1160
- const absPath = resolve(projectPath, rawPath);
1161
- if (!existsSync(absPath)) {
1162
- return { content: `目录不存在:${absPath}。` };
1163
- }
1164
- const verdict = validateSubproject(current, absPath);
1165
- if (!verdict.ok) {
1166
- return { content: `无法登记子项目:${verdict.message}` };
1167
- }
1168
- current = withSubproject(current, absPath, ide);
1169
- await writeConfig(current);
1170
- await runOpenInIdea();
1171
- return { content: `已登记子项目 \`${rawPath}\`(${IDE_LABELS[ide]})并接入。` };
1172
- };
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();
1173
1090
 
1174
- /** `configure_ide_project` 的工具定义。 */
1175
- const configureIdeTool = () => ({
1176
- name: 'configure_ide_project',
1177
- description:
1178
- '把用户选定的 IDE 写入本项目的 IDE 配置(即"这个项目用哪个 IDE 打开")。' +
1179
- '仅当用户明确回答了"用 IntelliJ IDEA 还是 PyCharm"之后调用,不要在用户没答复前猜。' +
1180
- 'path 省略表示配置 root 项目本身;ide 只取 idea / pycharm。',
1181
- input: {
1182
- type: 'object',
1183
- properties: {
1184
- path: {
1185
- type: 'string',
1186
- description: '子项目相对 root 的路径(例如 modelserver);省略表示 root 项目本身。',
1187
- },
1188
- ide: {
1189
- type: 'string',
1190
- enum: [...SUPPORTED_IDES],
1191
- description: '该项目的 IDE:idea = IntelliJ IDEA,pycharm = PyCharm。',
1192
- },
1193
- },
1194
- required: ['ide'],
1195
- },
1196
- execute: async (args) => configureIdeProject(args),
1197
- });
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
+ };
1198
1152
 
1199
- // `/open-in-ide` 接入 root 与其全部子项目;`/add-sub-module-in-ide <路径>` 登记
1200
- // 一个子项目(IDE 由模型问用户后写回);`/delete-in-ide` 删除整个配置。
1201
- //
1202
- // 三个名字都带 `-ide` 而不是 `-idea`:这里说的是"接入 IDE",与具体产品无关 —— root 可能是
1203
- // IDEA、子项目可能是 PyCharm,和工具前缀(`ide_*` / `<目录名>_*`)是同一套口径。
1204
- if (typeof ctx.command?.transform === 'function') {
1205
- commandRegistration = await ctx.command.transform((editor) => {
1206
- editor.add({
1207
- name: 'open-in-ide',
1208
- description: '接入当前项目:root 及其全部已登记子项目,各自用配置好的 IDE 打开',
1209
- execute: async (input) => {
1210
- const parsed = parseIdeArgument(input?.prompt?.text);
1211
- if (!parsed.ok) {
1212
- await sendFeedback(input, { status: 'bad-argument', message: parsed.message });
1213
- 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(' / ')}。`};
1214
1165
  }
1166
+ const rawPath = typeof args?.path === 'string' ? args.path.trim() : '';
1215
1167
  let current = await readConfig();
1216
- if (!current) {
1217
- // 第一次:需要知道 root 用哪个 IDE。无参时让模型问用户;带参则直通。
1218
- if (!parsed.app) {
1219
- await askIdeChoice(input, { isRoot: true, label: '.' });
1220
- return;
1221
- }
1222
- current = { root: { path: projectPath, app: parsed.app }, children: [] };
1223
- await writeConfig(current);
1224
- } else if (parsed.app && parsed.app !== current.root.app) {
1225
- // 直通逃生口:显式带参时改绑 root 的 IDE(模型不配合时用户仍能自救)。
1226
- current = { root: { ...current.root, app: parsed.app }, children: current.children };
1227
- await writeConfig(current);
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]} 并接入。`};
1228
1175
  }
1229
- /** @type {boolean} */
1230
- let unavailableNoticeSent = false;
1231
- const result = await runOpenInIdea({
1232
- onUnavailable: async (notice) => {
1233
- unavailableNoticeSent = await sendFeedback(input, notice);
1234
- return unavailableNoticeSent;
1235
- },
1236
- });
1237
- // The unavailable notice is the final result for this command. If
1238
- // it was already delivered, do not show the same warning twice.
1239
- const unavailableAlreadySent = unavailableNoticeSent || result.unavailableNoticeSent;
1240
- if (!unavailableAlreadySent || result.status !== 'mcp-unavailable') {
1241
- await sendFeedback(input, result);
1242
- }
1243
- },
1244
- });
1245
- editor.add({
1246
- name: 'add-sub-module-in-ide',
1247
- description: '登记一个子项目(参数:子项目相对 root 的路径);随后由 AI 询问用哪个 IDE',
1248
- execute: async (input) => {
1249
- const parsed = parseSubprojectArgument(input?.prompt?.text);
1250
- if (!parsed.ok) {
1251
- await sendFeedback(input, { status: 'bad-argument', message: parsed.message });
1252
- return;
1253
- }
1254
- const current = await readConfig();
1255
1176
  if (!current) {
1256
- await sendFeedback(input, { status: 'not-configured' });
1257
- return;
1177
+ return {content: '当前项目还没有 IDE 配置。请先执行 /open-in-ide。'};
1258
1178
  }
1259
- const absPath = resolve(projectPath, parsed.path);
1179
+ const absPath = resolve(projectPath, rawPath);
1260
1180
  if (!existsSync(absPath)) {
1261
- await sendFeedback(input, {
1262
- status: 'invalid-subproject',
1263
- message: `目录不存在:${absPath}`,
1264
- });
1265
- return;
1181
+ return {content: `目录不存在:${absPath}。`};
1266
1182
  }
1267
1183
  const verdict = validateSubproject(current, absPath);
1268
1184
  if (!verdict.ok) {
1269
- await sendFeedback(input, { status: 'invalid-subproject', message: verdict.message });
1270
- return;
1185
+ return {content: `无法登记子项目:${verdict.message}`};
1271
1186
  }
1272
- await askIdeChoice(input, { isRoot: false, label: absPath.slice(projectPath.length + 1) });
1273
- },
1274
- });
1275
- editor.add({
1276
- name: 'delete-in-ide',
1277
- description: '删除本项目的 IDE 配置:注销全部 IDE MCP 并清除 root 与子项目的登记',
1278
- execute: async (input) => {
1279
- await writeConfig(null);
1280
- // 注销全部 server。走串行化的 reconcile,所以在飞的重建(另一个
1281
- // `/open-in-ide`、请求期自愈)不会在配置已删之后再注册回来。
1282
- await reconcile({ probeIde: false });
1283
- await sendFeedback(input, { status: 'deleted' });
1284
- },
1285
- });
1286
- });
1287
- }
1187
+ current = withSubproject(current, absPath, ide);
1188
+ await writeConfig(current);
1189
+ await runOpenInIdea();
1190
+ return {content: `已登记子项目 \`${rawPath}\`(${IDE_LABELS[ide]})并接入。`};
1191
+ };
1288
1192
 
1289
- // `configure_ide_project`:模型把用户选定的 IDE 写回配置的唯一通道。
1290
- //
1291
- // 常驻注册,不做"用时注册、写完撤销":一个工具的 schema 只有几百 token,省不下
1292
- // 什么,却要换来一整套状态机(什么时候注册、模型一直不调用怎么办、用户中途说别
1293
- // 的怎么办)。它该不该被调用由 description 约束。
1294
- if (typeof ctx.tool?.transform === 'function') {
1295
- configToolRegistration = await ctx.tool.transform((editor) => {
1296
- editor.add(configureIdeTool());
1297
- });
1298
- }
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
+ });
1299
1217
 
1300
- // Hide the tools the model must not see, and point it at the rest. This hook
1301
- // runs for every agent-loop step (including tool-driven continuations), so
1302
- // it stays a cheap, I/O-free function of the request: it checks the cached
1303
- // config and this request's own tool snapshot, and takes no action.
1304
- //
1305
- // `event.tools` is this request's tool snapshot, and OpenCode's own registry
1306
- // adds IDE tools when the servers connect and drops them when they stop
1307
- // (`stopServer` publishes `mcp.tools.changed`, which the registry turns into
1308
- // a reload). So the snapshot is also the honest answer to "is the IDE
1309
- // serving?".
1310
- if (typeof ctx.session?.hook === 'function') {
1311
- sessionRegistration = await ctx.session.hook('context', (event) => {
1312
- const tools = event?.tools;
1313
- const serving =
1314
- tools !== undefined && Object.keys(tools).some((name) => isIdeToolName(name));
1315
- if (serving) {
1316
- wasServing = true;
1317
- removeIdeToolDefinitions(tools, HIDDEN_IDE_TOOL_SUFFIXES, prefixes());
1318
- } else if (wasServing && tools !== undefined && config) {
1319
- // This session HAD the tools and this request does not. Latch a
1320
- // rebuild here, where "is the IDE serving THIS request?" is a fact: a
1321
- // failed reconnect can tear the registration down without anything else
1322
- // noticing. `wasServing` keeps this from firing for a project that
1323
- // never connected in this session — the `prompt` hook covers that case
1324
- // (a fresh instance has no registration at all) and setup deliberately
1325
- // does not connect on its own.
1326
- needsRebuild = true;
1327
- wasServing = false;
1328
- // Do not wait for the next user message. The latch is read by the
1329
- // `prompt` hook, which has ALREADY run for this request — so without
1330
- // this the session would stay tool-less for the rest of the turn and
1331
- // need one more message before anything happened.
1332
- void reviveIde();
1333
- }
1334
- if (config || serving) {
1335
- appendIdeRecoveryGuidance(event?.system, { marked: Boolean(config), serving });
1218
+ // `/open-in-ide` 接入 root 与其全部子项目;`/add-sub-module-in-ide <路径>` 登记
1219
+ // 一个子项目(IDE 由模型问用户后写回);`/delete-in-ide` 删除整个配置。
1220
+ //
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
+ });
1336
1306
  }
1337
- if (!injectGuidance) return;
1338
- // Guidance promises tools the model can call, so it only goes out when
1339
- // they are actually in this request.
1340
- if (!serving) return;
1341
- // 逐项目注入:每个项目那一节只写它自己的前缀,模型不需要自己换算。
1342
- // 配套插件 `idea-mcp-enhance` 的两组工具(run-lifecycle / deployment)也按每个项目分别判断,
1343
- // 所以一个项目装了 enhance、另一个没装时,各节的内容本来就不一样。
1344
- appendIdeGuidance(event?.system, currentProjects(), { tools });
1345
- });
1346
1307
 
1347
- // 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
1348
- // **本次请求**里就带上 IDE 工具,没有"先回一句正在重连、下一条才行"
1349
- // 的空窗。
1350
- //
1351
- // 两种"需要重连"的形状都从这里进:
1352
- // * `needsRebuild` — 之前的请求已经证明工具没了;
1353
- // * `registrations.length === 0` — 本插件实例压根没有注册任何 IDE server,这
1354
- // 正是 OpenCode 回收 Location 服务(LayerMap idleTimeToLive = 60 分钟)
1355
- // 或进程重启后新实例的样子。setup 故意不连接,没有这一步会话就会永久
1356
- // 没有工具,只能靠用户手敲 `/open-in-ide`。
1357
- //
1358
- // 只探测、不启动 IDE:走 `reconcile({ probeIde: true })`,而不是会 `open -a`
1359
- // 的 `reviveIde()` —— 一条用户消息不该把 IDE 拉起来。
1360
- promptRegistration = await ctx.session.hook('prompt', async (event) => {
1361
- if (disposed || !config) return;
1362
- const fresh = registrations.length === 0;
1363
- if (!needsRebuild && !fresh) return;
1364
- // Never react to the plugin's own notices, or recovery would feed on
1365
- // itself. Match the notice TEXT, never `metadata.displayText`: OpenCode
1366
- // also sets `displayText` on ordinary user messages (it is the message
1367
- // text itself), so a displayText guard silently skips every real message
1368
- // — verified live, and it made this rebuild never run.
1369
- const text = event?.prompt?.text;
1370
- if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
1371
- try {
1372
- await reconcile({ probeIde: true });
1373
- } catch {
1374
- // 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
+ });
1375
1317
  }
1376
- if (registrations.length > 0) needsRebuild = false;
1377
- });
1378
1318
 
1379
- // The IDE's tool-catalog policy, in two halves that must stay in sync:
1380
- // an argument this plugin always overwrites must not also be advertised,
1381
- // or the model sets it, reasons about it, and is wrong. The schema half
1382
- // runs first, so a request never sees a knob that is already decided; see
1383
- // `mcp/idea-tool-defaults.js` for the measurements and for why this is a
1384
- // narrow exception to "never touch tool definitions".
1385
- //
1386
- // Both halves are deliberately unconditional on the project config: they are
1387
- // properties of the tool call, not of whether this project is IDE-connected.
1388
- if (typeof ctx.tool?.transform === 'function') {
1389
- toolTransformRegistration = await ctx.tool.transform((editor) => {
1390
- hideForcedArguments(editor, prefixes());
1391
- });
1392
- }
1393
- if (typeof ctx.tool?.hook === 'function') {
1394
- toolBeforeRegistration = await ctx.tool.hook('execute.before', (event) => {
1395
- if (typeof event?.tool !== 'string') return;
1396
- applyIdeToolDefaults(event.tool, event.input, prefixes());
1397
- });
1398
- }
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
+ });
1399
1365
 
1400
- // The only self-initiated action: when an IDE tool call fails in a way
1401
- // that means the IDE MCP is not usable, run the open flow again. The
1402
- // failure is classified — a plain business error (a bad path, a missing
1403
- // file) never triggers a reconnect, only "the MCP is not available" or
1404
- // "the project is not open in the IDE" do.
1405
- if (typeof ctx.tool?.hook === 'function') {
1406
- toolRegistration = await ctx.tool.hook('execute.after', async (event) => {
1407
- if (event?.status !== 'error') return;
1408
- if (typeof event?.tool !== 'string' || !isIdeToolName(event.tool)) return;
1409
- if (!config) return;
1410
- const kind = classifyIdeFailure(event.error, serverNames());
1411
- if (kind === 'none') return;
1412
- if (kind === 'project') {
1413
- // The IDE is up and answering; only the PROJECT is missing. That is
1414
- // never a connection problem, so this must never fall through to
1415
- // the reconnect below — on a platform with no launcher there is
1416
- // nothing to call, and falling through would report "reconnected"
1417
- // and send the model round after round after a call that can only
1418
- // fail. Leave the IDE's own message, which names the real cause.
1419
- if (!launchEnabled) return;
1420
- // 只有这个工具所属的那个项目的 IDE 需要重开(多项目下别去动别的项目)。
1421
- const project = projectForTool(event.tool);
1422
- const app = project ? appNameFor(project.app) : undefined;
1423
- if (!project || !app) return;
1424
- await openInIde(project.path, { app });
1425
- rewriteToolError(
1426
- event,
1427
- `该项目已在 ${IDE_LABELS[project.app] ?? 'IDE'} 中重新打开,请重试刚才的操作。`,
1428
- );
1429
- return;
1430
- }
1431
- const result = await runOpenInIdea();
1432
- if (result.port === undefined) return;
1433
- rewriteToolError(event, 'IDE MCP 已重新连接,请重试刚才的操作。');
1434
- });
1435
- }
1436
- }
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
+ });
1437
1397
 
1438
- return async () => {
1439
- disposed = true;
1440
- if (sessionRegistration) {
1441
- try {
1442
- await sessionRegistration.dispose();
1443
- } catch {
1444
- // best effort
1445
- }
1446
- }
1447
- if (toolRegistration) {
1448
- try {
1449
- await toolRegistration.dispose();
1450
- } catch {
1451
- // best effort
1452
- }
1453
- }
1454
- if (toolBeforeRegistration) {
1455
- try {
1456
- await toolBeforeRegistration.dispose();
1457
- } catch {
1458
- // best effort
1459
- }
1460
- }
1461
- if (toolTransformRegistration) {
1462
- try {
1463
- await toolTransformRegistration.dispose();
1464
- } catch {
1465
- // best effort
1466
- }
1467
- }
1468
- if (configToolRegistration) {
1469
- try {
1470
- await configToolRegistration.dispose();
1471
- } catch {
1472
- // best effort
1473
- }
1474
- }
1475
- if (promptRegistration) {
1476
- try {
1477
- await promptRegistration.dispose();
1478
- } catch {
1479
- // best effort
1480
- }
1481
- }
1482
- if (commandRegistration) {
1483
- try {
1484
- await commandRegistration.dispose();
1485
- } catch {
1486
- // best effort
1487
- }
1488
- }
1489
- if (skillRegistration) {
1490
- try {
1491
- await skillRegistration.dispose();
1492
- } catch {
1493
- // 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
+ }
1494
1455
  }
1495
- }
1496
- await deactivateIde();
1497
- };
1498
- },
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
+ },
1499
1518
  };