opencode-idea 0.0.17 → 0.0.18

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
@@ -1,42 +1,48 @@
1
1
  // opencode-idea — OpenCode plugin entry.
2
2
  //
3
- // For a project the user opted into IDEA this plugin:
4
- // 1. registers the IDE MCP server with OpenCode as the IDE's Streamable-HTTP
5
- // endpoint, scoped to the project through the `IJ_MCP_SERVER_PROJECT_PATH`
6
- // header; injects direct-call guidance for IDEA MCP tools; and updates only
7
- // IDEA MCP tool descriptions;
8
- // 2. registers the `/open-in-idea` command, which opens the current project
9
- // in IntelliJ IDEA (reusing a running instance), loads the IDE
10
- // capabilities immediately, and marks the project as an IDEA project;
11
- // `/close-in-idea` clears that mark;
12
- // 3. registers the `idea-standalone` skill (marked projects only), which
13
- // pins the project's local-run discipline: every app starts from an IDEA
14
- // run configuration, and starting or restarting one is the user's call.
3
+ // 一个 OpenCode 会话(root 项目)可以接入**多个** JetBrains 项目:root 本身,加上
4
+ // root 下任意层级的子目录。每个项目可以被不同的 IDE 打开(root 用 IDEA、modelserver
5
+ // 用 PyCharm 是常态),所以每个项目注册一个独立的 MCP server,名字 = 工具前缀:
15
6
  //
16
- // A project that was never marked is left untouched. Everything is manual:
17
- // setup does NOT probe or connect the IDE. `/open-in-idea` probes, opens the
18
- // IDE, and loads the IDE MCP + guidance on demand.
7
+ // * root → `ide` 与 IDE 产品无关,root 用谁打开都叫它
8
+ // * 子项目 → 目录名 `modelserver` → `modelserver_*`
19
9
  //
20
- // `/open-in-idea` opens the project in the IDE, waits until its MCP endpoint is
21
- // ready, registers `idea` (remote, project-scoped) and marks the project in
22
- // `ctx.storage`; `/close-in-idea` unregisters the server and clears the mark.
23
- // A live endpoint alone does NOT mean our project is open there (the running IDE
24
- // may have a different project open), so the open flow asks the IDE directly —
25
- // one project-scoped MCP call — and runs `open -a` only when the project is not
26
- // open yet.
10
+ // 数据模型与校验在 `ide-projects.js`;这里只做编排:探测、注册、提示词、命令、工具。
27
11
  //
28
- // Recovery is request-driven: there is NO background polling. The mark gates it,
12
+ // For a root project the user opted in, this plugin:
13
+ // 1. registers one IDE MCP server per project (see `activate`), each scoped to
14
+ // its own project through the `IJ_MCP_SERVER_PROJECT_PATH` header; injects
15
+ // per-project direct-call guidance; and hides the IDE tools that must not
16
+ // reach the model (for every prefix);
17
+ // 2. registers `/open-in-idea` (接入 root 与其全部子项目), `/delete-in-ide`
18
+ // (删掉整个配置) and `/add-sub-module-in-ide <路径>` (登记一个子项目),
19
+ // plus the `configure_ide_project` tool the MODEL calls to write the IDE a
20
+ // user picked into the config;
21
+ // 3. registers the `idea-standalone` skill (marked projects only).
22
+ //
23
+ // A root that was never configured is left untouched. Everything is manual:
24
+ // setup does NOT probe or connect any IDE. The commands probe, open the IDE, and
25
+ // load the MCP servers + guidance on demand.
26
+ //
27
+ // `/open-in-idea` opens each configured project in ITS OWN IDE, waits until its
28
+ // MCP endpoint is ready, registers it under its prefix, and keeps the config in
29
+ // `ctx.storage`. A live endpoint alone does NOT mean our project is open there
30
+ // (the running IDE may have a different project open), so the open flow asks the
31
+ // IDE directly — one project-scoped MCP call — and runs `open -a` only when the
32
+ // project is not open yet.
33
+ //
34
+ // Recovery is request-driven: there is NO background polling. The config gates it,
29
35
  // and it fires from three places, each observing a real fact:
30
- // * the `prompt` hook, when the project is marked but this plugin instance has
31
- // no `idea` server at all. That is exactly what a fresh instance looks like
36
+ // * the `prompt` hook, when the project is configured but this plugin instance
37
+ // has no IDE server at all. That is exactly what a fresh instance looks like
32
38
  // after OpenCode evicted the project's Location services (the Location's
33
39
  // LayerMap uses `idleTimeToLive: "60 minutes"`) or after the process
34
40
  // restarted — setup deliberately does not connect, so without this the
35
41
  // session would stay tool-less until the user ran `/open-in-idea` by hand.
36
42
  // `prompt` is awaited before the request is built, so the message that
37
43
  // triggers it already carries the tools;
38
- // * the `context` hook, when a request that HAD `idea_*` tools no longer does;
39
- // * the `execute.after` hook, when an `idea_*` call fails in a way that means
44
+ // * the `context` hook, when a request that HAD IDE tools no longer does;
45
+ // * the `execute.after` hook, when an IDE tool call fails in a way that means
40
46
  // the IDE MCP is not usable (the server dropped, or the project is not open
41
47
  // in the IDE). A plain business error is classified apart and left alone.
42
48
  //
@@ -44,33 +50,47 @@
44
50
  // `{ id, setup }` object is the whole plugin contract. Exporting it directly
45
51
  // keeps this package dependency-free at runtime.
46
52
 
47
- import {
48
- DEFAULT_PORTS,
49
- IDEA_SERVER_NAME,
50
- callTool,
51
- findIdePort,
52
- serverConfig,
53
- } from './mcp/idea.js';
54
- import { servingRunLifecycleTools } from './mcp/enhance.js';
55
- import { applyIdeaToolDefaults, hideForcedArguments } from './mcp/idea-tool-defaults.js';
53
+ import { existsSync } from 'node:fs';
54
+ import { resolve } from 'node:path';
55
+
56
+ import { callTool, serverConfig } from './mcp/idea.js';
57
+ import { runLifecycleCapabilities } from './mcp/enhance.js';
58
+ import { applyIdeToolDefaults, hideForcedArguments } from './mcp/idea-tool-defaults.js';
56
59
  import { currentProjectPath } from './project.js';
57
60
  import {
61
+ appNameFor,
58
62
  autoLaunchSupported,
59
63
  createLaunchGuard,
64
+ detectInstalledIdes,
60
65
  openInIde,
61
- resolveIdeApp,
62
66
  } from './ide-launcher.js';
63
67
  import {
64
68
  appendIdeGuidance,
65
69
  appendIdeRecoveryGuidance,
66
70
  } from './ide-guidance.js';
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,
84
+ } from './ide-projects.js';
67
85
  import { readSkills } from './skill/loader.js';
68
86
 
69
87
  const DEFAULT_LAUNCH_COOLDOWN_MS = 120000;
70
88
  const DEFAULT_LAUNCH_MAX_ATTEMPTS = 5;
71
- // MCP availability is a fast preflight. If it is not ready, fail fast and let
72
- // the user enable MCP + Brave Mode in IDEA before retrying the command.
73
- const DEFAULT_MCP_PROBE_TIMEOUT_MS = 1000;
89
+ // 单次「这个 IDE 里打开着这个项目吗」探测的超时。它比单纯的端口存活探测重(IDE 要
90
+ // 先解析出目标项目),给 1s 的话 IDE 忙(索引中)时会被误判成"不认得",进而触发一次多余
91
+ // 的 `open -a`;给太长又会让用户等。5s 是这两者之间的取舍。注意 IDE 没开时 connect 会
92
+ // 立刻失败,根本走不到这个上限。
93
+ const DEFAULT_MCP_PROBE_TIMEOUT_MS = 5000;
74
94
  // A cold IDE can take tens of seconds before its bundled MCP server listens.
75
95
  // After `open -a`, poll for the endpoint instead of reporting failure once.
76
96
  const DEFAULT_MCP_START_TIMEOUT_MS = 60000;
@@ -85,30 +105,30 @@ const TOOL_REFRESH_POLL_MS = 100;
85
105
  // beyond any normal IDE tool and turns that hang into a normal error the failure
86
106
  // classifier can act on.
87
107
  const DEFAULT_EXECUTION_TIMEOUT_MS = 600000;
88
- // Every IDE tool comes from the MCP server the plugin registers as `idea`, and
89
- // OpenCode namespaces a server's tools with its name — so `idea_*` IS the whole
90
- // set, the same predicate the request hooks use. "Ready" means at least one of
91
- // them is listed: OpenCode publishes an MCP catalog wholesale (one reload), so
92
- // one tool means the batch landed.
93
- //
94
- // This used to be a hard-coded pair (`idea_read_file` + `idea_apply_patch`),
95
- // which is not a property the plugin may assume: the IDE can rename a tool, and
96
- // the user can switch one off on the IDE's Exposed Tools page. Either one made
97
- // the wait unsatisfiable, so every `/open-in-idea` and every auto-rebuild burned
98
- // the full `toolRefreshTimeoutMs` and still reported `tools-loading` — telling
99
- // the user to re-run a command that could never succeed.
100
- const IDEA_TOOL_PREFIX = 'idea_';
101
108
 
102
109
  /**
103
- * IDEA MCP tools that must never reach the model.
110
+ * 插件自己注册的工具名(不属于任何 IDE server)。
111
+ *
112
+ * 目前只有 `configure_ide_project`:模型把用户选定的 IDE 写回配置的通道,见
113
+ * `configureIdeTool`。单独列出来是给前缀判断**排除**用的 —— 前缀由配置决定、
114
+ * 动态可变(`ide_` / `<目录名>_`),万一某个目录名恰好拼出同名工具,它也不能被
115
+ * 当成 IDE 工具隐藏掉或写进提示词。
116
+ */
117
+ const PLUGIN_TOOL_NAMES = new Set(['configure_ide_project']);
118
+
119
+ /**
120
+ * IDE MCP tools that must never reach the model, listed by **suffix**.
121
+ *
122
+ * 存后缀而不是完整工具名:每个项目注册一个 server,同一个工具会以 `ide_git_status` /
123
+ * `modelserver_git_status` 等多份出现,写死任何一个前缀都只能清掉一个项目。展开由
124
+ * `removeIdeToolDefinitions` 按当前前缀集合完成。
104
125
  *
105
126
  * Grouped by the IDE's Exposed Tools page so the list stays auditable. Anything
106
127
  * listed here is stripped from every model request, even while the IDE is up.
107
128
  *
108
- * - VCS: `idea_git_status` returns porcelain codes only and
109
- * `idea_get_repositories` is covered by the native git tool, which also
110
- * handles diff/log/blame.
111
- * - Router: `idea_execute_tool` is the IDE's router-mode dispatcher. Router-only
129
+ * - VCS: `git_status` returns porcelain codes only and `get_repositories` is
130
+ * covered by the native git tool, which also handles diff/log/blame.
131
+ * - Router: `execute_tool` is the IDE's router-mode dispatcher. Router-only
112
132
  * mode is off, so every routed tool is exposed directly with its full schema
113
133
  * and this dispatcher is redundant. Revisit if router-only mode is enabled.
114
134
  * - Debugger (`xdebug_*`): the Debugger MCP toolset. Rarely needed, and a real
@@ -125,14 +145,14 @@ const IDEA_TOOL_PREFIX = 'idea_';
125
145
  * user configures data sources in the IDE. The query tools are hidden too —
126
146
  * the IDE is not this plugin's idea of a database client, and a model that
127
147
  * wants to inspect a database should be told to use a real one.
128
- * - Plain file access: `idea_search_text` / `idea_search_regex` /
129
- * `idea_search_file` / `idea_apply_patch` / `idea_create_new_file` each have a
130
- * native tool that does the job (`grep` / `grep -E` / `glob` / `edit` /
131
- * `write`); routing them through the IDE only adds a hop. `idea_read_file` is
132
- * NOT hidden — it is the only way to read a file inside a jar (`<jar>!/<entry>`)
133
- * or the JDK's `jrt://` image, so the guidance keeps it for exactly that and
134
- * sends ordinary reads to the native tool.
135
- * - IDE terminal: `idea_execute_terminal_command` does NOT inherit the project's
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.
155
+ * - IDE terminal: `execute_terminal_command` does NOT inherit the project's
136
156
  * environment. It was exposed on the assumption that it ran with the SDKs the
137
157
  * user configured in the IDE (JDK, Node, Python, ...), but measured against a
138
158
  * real IDE it brings no environment a native `shell` call does not already
@@ -143,81 +163,88 @@ const IDEA_TOOL_PREFIX = 'idea_';
143
163
  * launches those with exactly the configured SDK), everything else goes
144
164
  * through the native shell.
145
165
  */
146
- export const HIDDEN_IDEA_TOOLS = [
166
+ export const HIDDEN_IDE_TOOL_SUFFIXES = [
147
167
  // VCS
148
- 'idea_git_status',
149
- 'idea_get_repositories',
168
+ 'git_status',
169
+ 'get_repositories',
150
170
  // Router
151
- 'idea_execute_tool',
171
+ 'execute_tool',
152
172
  // IDE terminal: no advantage over the native shell (see the note above).
153
- 'idea_execute_terminal_command',
173
+ 'execute_terminal_command',
154
174
  // Debugger
155
- 'idea_xdebug_control_session',
156
- 'idea_xdebug_evaluate_expression',
157
- 'idea_xdebug_get_debugger_status',
158
- 'idea_xdebug_get_frame_values',
159
- 'idea_xdebug_get_stack',
160
- 'idea_xdebug_get_threads',
161
- 'idea_xdebug_get_value_by_path',
162
- 'idea_xdebug_list_breakpoints',
163
- 'idea_xdebug_remove_breakpoint',
164
- 'idea_xdebug_run_to_line',
165
- 'idea_xdebug_set_breakpoint',
166
- 'idea_xdebug_set_variable',
167
- 'idea_xdebug_start_debugger_session',
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',
168
188
  // Dev Kit MCP
169
- 'idea_collect_split_mode_compatibility_issues',
170
- 'idea_create_ij_module',
171
- 'idea_find_lock_requirements_usages',
172
- 'idea_find_threading_requirements_usages',
173
- 'idea_recognize_ij_module_kind',
174
- 'idea_recognize_split_mode_api_kind',
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',
175
195
  // Inspection KTS MCP
176
- 'idea_generate_inspection_kts_api',
177
- 'idea_generate_inspection_kts_examples',
178
- 'idea_generate_psi_tree',
179
- 'idea_run_inspection_kts',
196
+ 'generate_inspection_kts_api',
197
+ 'generate_inspection_kts_examples',
198
+ 'generate_psi_tree',
199
+ 'run_inspection_kts',
180
200
  // Python Environment MCP
181
- 'idea_configure_python_interpreter',
182
- 'idea_get_python_environment',
201
+ 'configure_python_interpreter',
202
+ 'get_python_environment',
183
203
  // Database: connections are the user's, and the IDE is not this plugin's
184
204
  // database client.
185
- 'idea_create_database_connection',
186
- 'idea_edit_database_connection',
187
- 'idea_list_database_connections',
188
- 'idea_test_database_connection',
189
- 'idea_list_database_schemas',
190
- 'idea_list_schema_object_kinds',
191
- 'idea_list_schema_objects',
192
- 'idea_introspect_schema',
193
- 'idea_get_database_object_description',
194
- 'idea_execute_sql_query',
195
- 'idea_fetch_query_result',
196
- 'idea_preview_table_data',
197
- 'idea_list_recent_sql_queries',
198
- 'idea_cancel_sql_query',
199
- // Plain file access with a native equivalent (`idea_read_file` stays: only it
200
- // can read inside a jar or the jrt:// image).
201
- 'idea_search_text',
202
- 'idea_search_regex',
203
- 'idea_search_file',
204
- 'idea_apply_patch',
205
- 'idea_create_new_file',
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',
206
226
  ];
207
227
 
208
228
  /**
209
- * Remove the given IDEA MCP definitions from one request's tool record.
229
+ * Remove the hidden IDE tools from one request's tool record, for every prefix.
230
+ *
231
+ * 入参是**后缀**列表,这里按当前前缀集合展开成完整工具名 —— 每个项目一份都要删,
232
+ * 否则子项目那几份会原样送到模型面前。
210
233
  *
211
234
  * @param {Record<string, unknown> | undefined} tools
212
- * @param {readonly string[]} names
235
+ * @param {readonly string[]} suffixes
236
+ * @param {readonly string[]} prefixes 当前所有项目的工具前缀(含下划线)。
213
237
  */
214
- function removeIdeaToolDefinitions(tools, names) {
238
+ function removeIdeToolDefinitions(tools, suffixes, prefixes) {
215
239
  if (!tools || typeof tools !== 'object') return 0;
216
240
  let removed = 0;
217
- for (const name of names) {
218
- if (!name.startsWith('idea_') || !(name in tools)) continue;
219
- delete tools[name];
220
- removed += 1;
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;
247
+ }
221
248
  }
222
249
  return removed;
223
250
  }
@@ -228,7 +255,7 @@ function toolIds(tools) {
228
255
  }
229
256
 
230
257
  /**
231
- * Classify an `idea_*` tool failure for the reconnect decision.
258
+ * Classify an IDE tool failure for the reconnect decision.
232
259
  *
233
260
  * It must never mistake a plain business error (a bad path, a missing file) for
234
261
  * a broken connection, so only two shapes trigger action:
@@ -244,9 +271,10 @@ function toolIds(tools) {
244
271
  * Everything else is `none`.
245
272
  *
246
273
  * @param {{ message?: unknown } | undefined} error
274
+ * @param {readonly string[]} serverNames 当前注册的 server 名(`ide` 与各子项目前缀)。
247
275
  * @returns {'project' | 'mcp' | 'none'}
248
276
  */
249
- function classifyIdeaFailure(error) {
277
+ function classifyIdeFailure(error, serverNames) {
250
278
  const message = typeof error?.message === 'string' ? error.message : '';
251
279
  // "Project not open in the IDE" comes in two shapes, both meaning the same
252
280
  // thing and both fixed by opening the project again:
@@ -280,8 +308,11 @@ function classifyIdeaFailure(error) {
280
308
  // so the text is matched on its stable prefix, and the underlying `code` is
281
309
  // accepted as a structural signal too (a wording change must not silently
282
310
  // disable the heal).
311
+ //
312
+ // serverNames 是当前注册的全部 server 名(`ide` / 各子项目前缀):错误里点名的是
313
+ // 哪一个 server,取决于失败的是哪个项目的工具,所以只能逐个匹配。
283
314
  if (
284
- message.includes(`MCP server "${IDEA_SERVER_NAME}" is not available`) ||
315
+ serverNames.some((name) => message.includes(`MCP server "${name}" is not available`)) ||
285
316
  message.includes('MCP server is not connected') ||
286
317
  message.includes('Error POSTing to endpoint') ||
287
318
  message.includes('Unable to connect')
@@ -338,22 +369,23 @@ function rewriteToolError(event, message) {
338
369
  * | 'launch-failed'
339
370
  * | 'disabled'
340
371
  * | 'manual-launch'
341
- * | 'closed',
372
+ * | 'deleted'
373
+ * | 'bad-argument'
374
+ * | 'not-configured'
375
+ * | 'invalid-subproject'
376
+ * | 'needs-ide',
342
377
  * // `manual-launch` = this platform has no launcher at all (the user must
343
378
  * // open the IDE by hand); `disabled` = the user turned launching off. Both
344
379
  * // mean "no app to open", so only `autoLaunchSupported()` tells them apart.
345
380
  * port?: number,
346
381
  * opened?: boolean,
347
- * unavailableNoticeSent?: boolean
382
+ * unavailableNoticeSent?: boolean,
383
+ * project?: { label?: string, app?: string, isRoot?: boolean },
384
+ * projects?: ReadonlyArray<{ label?: string, app?: string }>,
385
+ * message?: string
348
386
  * }} IdeCommandResult
349
387
  */
350
388
 
351
- /**
352
- * @param {unknown} value
353
- * @param {unknown} fallback
354
- */
355
- const asArray = (value, fallback) => (Array.isArray(value) && value.length > 0 ? value : fallback);
356
-
357
389
  /**
358
390
  * @param {unknown} value
359
391
  * @param {number} fallback
@@ -361,28 +393,44 @@ const asArray = (value, fallback) => (Array.isArray(value) && value.length > 0 ?
361
393
  const asPositiveNumber = (value, fallback) =>
362
394
  typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
363
395
 
364
- /** User-facing status text for a `/open-in-idea` result. */
396
+ /** 面向用户的状态文案(三个命令与 `configure_ide_project` 共用)。 */
365
397
  /** @param {IdeCommandResult} result */
366
398
  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);
367
402
  switch (result?.status) {
368
- case 'connected':
369
- return 'IDE MCP 已连接;IDEA 原生工具已注册并可用于后续请求。';
403
+ case 'connected': {
404
+ const list = (result.projects ?? []).map((project) => `${project.label}(${appOf(project)})`).join('、');
405
+ return list ? `IDE MCP 已连接并接入:${list}。` : 'IDE MCP 已连接。';
406
+ }
370
407
  case 'tools-loading':
371
- return 'IDE MCP 已连接,但原生 IDEA 工具列表尚未完成注册。请稍后再次执行 /open-in-idea。';
372
- case 'mcp-unavailable':
408
+ return 'IDE MCP 已连接,但原生 IDE 工具列表尚未完成注册。请稍后再次执行 /open-in-idea。';
409
+ case 'mcp-unavailable': {
410
+ const where = nameOf(result?.project);
411
+ const ide = appOf(result?.project);
373
412
  return result?.opened
374
- ? '已请求打开/激活 IntelliJ IDEA 并等待 MCP 服务启动,但未检测到 MCP 服务。请在 IDEA 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-idea。'
375
- : '未检测到 IntelliJ IDEA 的 MCP 服务。请确认 IDEA 已打开,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。';
413
+ ? `已请求打开/激活 ${where} 并等待 MCP 服务启动,但未检测到 MCP 服务。请在 ${ide} 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-idea。`
414
+ : `未检测到 ${where} 的 MCP 服务。请确认已打开,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。`;
415
+ }
376
416
  case 'launch-failed':
377
- return '无法拉起 IntelliJ IDEA(未找到应用或启动失败)。请确认已安装 IDEA,或手动打开后重试 /open-in-idea。';
417
+ return `无法拉起 ${nameOf(result?.project)}(未找到应用或启动失败)。请确认它已安装 —— 没装的话先装上,再重试 /open-in-idea。`;
378
418
  case 'disabled':
379
- return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
419
+ return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
380
420
  case 'manual-launch':
381
- return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。';
382
- case 'closed':
383
- return '已关闭当前项目的 IDEA 接入:已注销 IDE MCP 并清除项目标记。如需重新接入,请再次执行 /open-in-idea。';
421
+ return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。';
422
+ case 'deleted':
423
+ return '已删除本项目的 IDE 配置:已注销全部 IDE MCP 并清除配置。如需重新接入,请执行 /open-in-idea。';
424
+ case 'bad-argument':
425
+ return `命令参数有误:${result?.message ?? ''}`;
426
+ case 'not-configured':
427
+ return '当前项目还没有 IDE 配置。请先执行 /open-in-idea 接入根项目,再登记子项目。';
428
+ case 'invalid-subproject':
429
+ return `无法登记子项目:${result?.message ?? ''}`;
430
+ case 'needs-ide':
431
+ return '需要确定用哪个 IDE 打开。请回答 AI 的提问(IntelliJ IDEA 或 PyCharm),AI 会把选择写入配置。';
384
432
  default:
385
- return '已处理 /open-in-idea。';
433
+ return '已处理。';
386
434
  }
387
435
  }
388
436
 
@@ -410,10 +458,10 @@ export default {
410
458
  * @param {{
411
459
  * location?: unknown,
412
460
  * options?: {
413
- * ports?: number[],
414
- * openInIde?: boolean | string,
461
+ * openInIde?: boolean,
415
462
  * launchCooldownMs?: number,
416
463
  * launchMaxAttempts?: number,
464
+ * injectGuidance?: boolean,
417
465
  * mcpProbeTimeoutMs?: number,
418
466
  * mcpStartTimeoutMs?: number,
419
467
  * toolRefreshTimeoutMs?: number,
@@ -483,9 +531,10 @@ export default {
483
531
  if (!projectPath) return;
484
532
 
485
533
  const options = ctx.options || {};
486
- const ports = asArray(options.ports, DEFAULT_PORTS);
487
534
  const injectGuidance = options.injectGuidance !== false;
488
- const ideApp = resolveIdeApp(options.openInIde);
535
+ // 自动拉起 IDE 的总开关。用哪个 IDE 由**每个项目自己的配置**决定
536
+ // (`project.app`),这个选项不再指定应用名,只决定"允不允许拉起"。
537
+ const launchEnabled = options.openInIde !== false;
489
538
  const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
490
539
  const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
491
540
  const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
@@ -496,10 +545,8 @@ export default {
496
545
  // model only acknowledges ("收到"); the user sees the clean notice via
497
546
  // metadata.displayText. false is silent.
498
547
  const feedback = options.feedback === false ? false : 'message';
499
- const guard = createLaunchGuard({
500
- cooldownMs: asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS),
501
- maxAttempts: asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS),
502
- });
548
+ const launchCooldownMs = asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS);
549
+ const launchMaxAttempts = asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS);
503
550
 
504
551
  /** @type {Array<{ dispose: () => Promise<void> | void }>} */
505
552
  let registrations = [];
@@ -509,6 +556,8 @@ export default {
509
556
  let toolRegistration;
510
557
  let toolBeforeRegistration;
511
558
  let toolTransformRegistration;
559
+ /** `configure_ide_project` 的注册。 */
560
+ let configToolRegistration;
512
561
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
513
562
  let promptRegistration;
514
563
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
@@ -519,68 +568,102 @@ export default {
519
568
  let registeredSkillIds = [];
520
569
  let disposed = false;
521
570
 
522
- // Latches when a request showed the `idea_*` tools are gone. It is cleared
571
+ // Latches when a request showed the IDE tools are gone. It is cleared
523
572
  // only by a successful rebuild. It is set from the `context` hook (a request
524
573
  // that HAD the tools no longer does); the `prompt` hook also heals without
525
574
  // this latch when the instance has no registration at all.
526
575
  let needsRebuild = false;
527
- // Set once a request in this session actually carried `idea_*` tools, so a
576
+ // Set once a request in this session actually carried IDE tools, so a
528
577
  // later request without them is a DROP of this session's own connection —
529
- // not a project that simply never connected.
578
+ // not a root that simply never connected.
530
579
  let wasServing = false;
531
580
 
532
- // Project-level mark: has the user opted this project into IDEA via
533
- // `/open-in-idea`? One plugin instance serves one project (OpenCode
534
- // activates plugins per Location), so a single boolean is the whole cache.
535
- // The durable copy lives in `ctx.storage` under `project/<projectPath>`.
536
- // The commands are the only writers: they persist first, then invalidate
537
- // this cache so the next read comes back from storage.
581
+ // 本会话的 IDE 配置:root 用哪个 IDE,以及登记了哪些子项目、各自用哪个 IDE。
582
+ // 一个插件实例服务一个 root(OpenCode 按 Location 激活插件),所以这就是全部
583
+ // 状态。持久化在 `ctx.storage` 的 `project/<rootPath>` 下。
584
+ //
585
+ // `undefined` = 还没读过;`null` = 读过、但没有配置。两者必须分开:混成一种,
586
+ // 请求钩子就会把"还没读"当成"未配置",漏掉一次本该发生的自愈。
538
587
  const storage = ctx.storage;
539
- const markKey = `project/${projectPath}`;
540
- /** @type {boolean | undefined} undefined = a storage read is due */
541
- let markCache;
588
+ const markKey = markKeyFor(projectPath);
589
+ /** @type {import('./ide-projects.js').IdeConfig | null | undefined} */
590
+ let config;
542
591
 
543
- /** Read the durable mark into the cache; at most one storage read per invalidation. */
544
- const readMark = async () => {
545
- if (markCache !== undefined) return markCache;
592
+ /** 读一次持久化配置(最多一次 I/O),之后走内存缓存。 */
593
+ const readConfig = async () => {
594
+ if (config !== undefined) return config;
546
595
  if (!storage || typeof storage.get !== 'function') {
547
- markCache = false;
548
- return markCache;
596
+ config = null;
597
+ return config;
549
598
  }
550
599
  try {
551
- markCache = (await storage.get(markKey)) === true;
600
+ config = parseMark(await storage.get(markKey), projectPath) ?? null;
552
601
  } catch {
553
- // storage is best effort; fall back to unmarked
554
- markCache = false;
602
+ // storage is best effort;读不到就当作未配置
603
+ config = null;
555
604
  }
556
- return markCache;
605
+ return config;
557
606
  };
558
607
 
559
608
  /**
560
- * Persist the mark, then invalidate the cache so the next reader re-reads
561
- * storage. Best effort: an in-memory invalidation still applies if the write
562
- * fails.
563
- * @param {boolean} marked
609
+ * 写入配置(`null` = 删除),并同步内存缓存。
610
+ *
611
+ * 先写 storage 再更新缓存(write-through):写失败也要更新缓存,否则用户刚做的
612
+ * 配置在本进程里不可见,行为会自相矛盾。命令与 `configure_ide_project` 是唯一的
613
+ * 写入者。
614
+ *
615
+ * @param {import('./ide-projects.js').IdeConfig | null} next
564
616
  */
565
- const setMark = async (marked) => {
617
+ const writeConfig = async (next) => {
566
618
  try {
567
- if (marked) await storage?.set?.(markKey, true);
619
+ if (next) await storage?.set?.(markKey, serializeMark(next));
568
620
  else await storage?.remove?.(markKey);
569
621
  } catch {
570
622
  // best effort
571
623
  }
572
- // Keep the in-memory cache in sync with the write (write-through), so any
573
- // later synchronous reader sees the new value immediately.
574
- markCache = marked;
575
- // The skill lifecycle follows the mark: marking the project registers the
576
- // IDEA skills, clearing the mark removes them again. Same semantics as the
577
- // reconnect behavior, so the two can never disagree.
624
+ config = next;
625
+ // skill 生命周期跟随配置:配置在就注册 IDEA skill,删掉配置就移除。
578
626
  await syncSkills();
579
627
  };
580
628
 
581
- // Resolve the mark once, so the request hooks can read it synchronously.
582
- await readMark();
629
+ /** 当前接入的全部项目(root 在前),每个带工具前缀与展示标签。 */
630
+ const currentProjects = () =>
631
+ config
632
+ ? listProjects(config).map((project) => ({
633
+ ...project,
634
+ label: project.isRoot ? '.' : project.path.slice(projectPath.length + 1),
635
+ }))
636
+ : [];
583
637
 
638
+ /** 当前所有项目的工具前缀(含下划线);未配置时为空。 */
639
+ const prefixes = () => (config ? toolPrefixes(config) : []);
640
+
641
+ /** 当前注册的全部 server 名(= 前缀去掉下划线)。 */
642
+ const serverNames = () => (config ? listProjects(config).map((project) => project.server) : []);
643
+
644
+ /** 这个工具名是否属于某个 IDE server(插件自己的工具不算)。 */
645
+ const isIdeToolName = (name) => {
646
+ if (PLUGIN_TOOL_NAMES.has(name)) return false;
647
+ return prefixes().some((prefix) => name.startsWith(prefix));
648
+ };
649
+
650
+ /**
651
+ * 这个 IDE 工具属于哪个项目?
652
+ *
653
+ * 按**最长前缀**匹配:`modelserver_` 必须先于 `model_` 命中,否则一个叫 `model`
654
+ * 的项目会把 `modelserver_*` 的工具认领走。
655
+ *
656
+ * @param {string} name
657
+ */
658
+ const projectForTool = (name) =>
659
+ currentProjects()
660
+ .filter((project) => name.startsWith(`${project.server}_`))
661
+ .sort((a, b) => b.server.length - a.server.length)[0];
662
+
663
+ // 先解析一次,请求钩子之后就能同步读到。
664
+ await readConfig();
665
+
666
+ /** 注销当前注册的全部 IDE server。 */
584
667
  const deactivateIde = async () => {
585
668
  const current = registrations.slice();
586
669
  registrations.length = 0;
@@ -594,13 +677,13 @@ export default {
594
677
  }
595
678
  };
596
679
 
597
- const waitForIdeaTools = async () => {
680
+ const waitForIdeTools = async () => {
598
681
  if (typeof ctx.tool?.list !== 'function') return false;
599
682
  const deadline = Date.now() + toolRefreshTimeoutMs;
600
683
  while (Date.now() < deadline) {
601
684
  try {
602
685
  const ids = toolIds(await ctx.tool.list());
603
- if (ids.some((id) => id.startsWith(IDEA_TOOL_PREFIX))) return true;
686
+ if (ids.some((id) => isIdeToolName(id))) return true;
604
687
  } catch {
605
688
  // The MCP catalog may still be settling; retry within the bounded wait.
606
689
  }
@@ -609,6 +692,14 @@ export default {
609
692
  return false;
610
693
  };
611
694
 
695
+ /**
696
+ * 一个项目的 MCP 端口,由它的 IDE 唯一决定(`IDE_PORTS`,固定值)。
697
+ *
698
+ * @param {{ path: string, app: string }} project
699
+ * @returns {number | undefined}
700
+ */
701
+ const portFor = (project) => IDE_PORTS[project.app];
702
+
612
703
  const refreshIdeTools = async () => {
613
704
  if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
614
705
  // Reconcile the MCP servers above, then replay the tool registry so the
@@ -616,17 +707,30 @@ export default {
616
707
  // Without this, a session that already captured a snapshot keeps the old
617
708
  // tool list until the session is reopened.
618
709
  if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
619
- const ready = await waitForIdeaTools();
710
+ const ready = await waitForIdeTools();
620
711
  // The catalog decides which of the shipped skills may be offered, so this
621
712
  // is the first moment that question has an answer; see `syncSkills`.
622
713
  await syncSkills();
623
714
  return ready;
624
715
  };
625
716
 
626
- const activateIde = async (port) => {
627
- const config = serverConfig(port, projectPath, { executionTimeoutMs });
717
+ /**
718
+ * 按配置给**每个**已打开的项目各注册一个 MCP server。
719
+ *
720
+ * server 名就是项目的工具前缀(root = `ide`,子项目 = 目录名),所以 `editor.set`
721
+ * 的 key 与 `serverConfig` 里带的项目路径必须成对出现 —— 一个 server 只服务一个
722
+ * 项目(路径走 header,注册后无法逐次改写)。
723
+ *
724
+ * @param {ReadonlyArray<{ project: { path: string, server: string }, port: number }>} bindings
725
+ */
726
+ const activateIde = async (bindings) => {
628
727
  const mcpRegistration = await ctx.mcp.transform((editor) => {
629
- editor.set(IDEA_SERVER_NAME, config);
728
+ for (const binding of bindings) {
729
+ editor.set(
730
+ binding.project.server,
731
+ serverConfig(binding.port, binding.project.path, { executionTimeoutMs }),
732
+ );
733
+ }
630
734
  });
631
735
  registrations = [mcpRegistration];
632
736
  return refreshIdeTools();
@@ -635,23 +739,32 @@ export default {
635
739
  const reconcileOnce = async ({ probeIde = true } = {}) => {
636
740
  if (disposed) return false;
637
741
 
638
- // Manual: only `/open-in-idea` probes the IDE. Setup calls this with
639
- // `probeIde: false` so it never connects until the command asks.
640
- const port = probeIde ? await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }) : undefined;
641
- const isJetBrains = markCache === true || port !== undefined;
642
- if (!isJetBrains) {
742
+ // 未配置:没有任何项目要接入。
743
+ if (!config) {
643
744
  await deactivateIde();
644
745
  return false;
645
746
  }
747
+ // Manual: only the commands probe the IDE. Setup calls this with
748
+ // `probeIde: false` so it never connects until a command asks.
749
+ if (!probeIde) {
750
+ await deactivateIde();
751
+ return false;
752
+ }
753
+
754
+ const bindings = [];
755
+ for (const project of currentProjects()) {
756
+ const port = await portForProject(project);
757
+ if (port !== undefined) bindings.push({ project, port });
758
+ }
646
759
 
647
- // Always tear the server down and register it again. OpenCode's reconcile
760
+ // Always tear the servers down and register them again. OpenCode's reconcile
648
761
  // SKIPS a re-`set` whose config is unchanged, so re-registering without a
649
- // teardown would leave a dropped server in place forever. `/open-in-idea`
650
- // and the request hooks are rare; rebuilding costs one restart, which is
651
- // the right trade for never leaving a dead registration behind.
762
+ // teardown would leave a dropped server in place forever. The commands and
763
+ // the request hooks are rare; rebuilding costs one restart, which is the
764
+ // right trade for never leaving a dead registration behind.
652
765
  await deactivateIde();
653
- if (port === undefined) return false;
654
- return activateIde(port);
766
+ if (bindings.length === 0) return false;
767
+ return activateIde(bindings);
655
768
  };
656
769
 
657
770
  // Serialize reconciles so overlapping callers can never register/activate
@@ -673,26 +786,7 @@ export default {
673
786
  const reconcile = (options) => enqueue(() => reconcileOnce(options));
674
787
 
675
788
  /**
676
- * `open -a` only requests activation/opening. A cold IDE needs time before
677
- * its bundled MCP server listens, so poll until the endpoint appears. This
678
- * is the step that makes a single `/open-in-idea` enough on a cold start.
679
- *
680
- * @returns {Promise<number | undefined>}
681
- */
682
- const waitForIdeServer = async () => {
683
- const deadline = Date.now() + mcpStartTimeoutMs;
684
- while (Date.now() < deadline) {
685
- const port = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
686
- if (port !== undefined) return port;
687
- const remaining = deadline - Date.now();
688
- if (remaining <= 0) break;
689
- await new Promise((resolve) => setTimeout(resolve, Math.min(MCP_START_POLL_MS, remaining)));
690
- }
691
- return undefined;
692
- };
693
-
694
- /**
695
- * Is `projectPath` actually OPEN in the IDE that serves `port`?
789
+ * Is `path` actually OPEN in the IDE that serves `port`?
696
790
  *
697
791
  * A live endpoint only means the IDE is RUNNING — it may have a different
698
792
  * project open (or sit on the Welcome screen), while everything we do is
@@ -700,121 +794,191 @@ export default {
700
794
  * call: the server resolves the project from `IJ_MCP_SERVER_PROJECT_PATH`
701
795
  * BEFORE running the tool, and for a project that is not open it fails with
702
796
  * "Unable to determine the target project ..." — the same shape
703
- * `classifyIdeaFailure` maps to `project`. Any other outcome (a real result,
797
+ * `classifyIdeFailure` maps to `project`. Any other outcome (a real result,
704
798
  * or an unrelated tool error) means the project WAS resolved, i.e. open.
705
799
  *
706
800
  * A transport failure (`undefined`) counts as "not confirmed open", so the
707
801
  * caller still asks the IDE to open the project: `open -a` is idempotent,
708
802
  * and the only cost is a redundant focus.
709
803
  *
804
+ * 这个判定现在只回答一个问题:"这个 IDE 里打开着这个项目吗?" —— 端口本身由
805
+ * `IDE_PORTS` 按 IDE 唯一确定,不再需要靠它去区分端口归属。
806
+ *
807
+ * ⚠️ 实测(2026-10-16,IntelliJ IDEA 2026.2 + PyCharm 2026.2):请求路径**落在某个已打开
808
+ * 项目的目录树内**时,IDE 会把它解析到那个项目 —— `get_project_modules` 正常返回它的模块,
809
+ * 而不是报错。只有路径完全不在任何已打开项目下(`/tmp`、不存在的路径)才报
810
+ * "Unable to determine the target project"。
811
+ *
812
+ * 因此「子项目与 root 用同一个 IDE」时必须知道:只要那个 IDE 打开了 root,子项目就会被判成
813
+ * "已打开" —— 它确实是 root 项目的一部分,工具能操作它的文件,只是语言支持按 **root 项目**
814
+ * 走(Python 子项目被 root 的 Web 模块解析,符号/检查会不准)。这是 IDE 的目录包含语义,
815
+ * 不是探测可以绕过的;要让子项目按自己的模块解析,得让它在那个 IDE 里**单独打开**一个项目
816
+ * —— 或者(正常做法)给它配它真正被打开的那个 IDE。
817
+ *
818
+ * A transport failure (`undefined`) counts as "not confirmed open", so the
819
+ * caller still asks the IDE to open the project: `open -a` is idempotent,
820
+ * and the only cost is a redundant focus.
821
+ *
710
822
  * @param {number} port
823
+ * @param {string} path
711
824
  */
712
- const isProjectOpen = async (port) => {
713
- const result = await callTool(port, projectPath, 'get_project_modules', {}).catch(() => undefined);
825
+ const isProjectOpen = async (port, path) => {
826
+ const result = await callTool(port, path, 'get_project_modules', {}, {
827
+ timeoutMs: mcpProbeTimeoutMs,
828
+ }).catch(() => undefined);
714
829
  if (!result) return false;
715
830
  if (result.isError !== true) return true;
716
831
  const text = (result.content ?? [])
717
832
  .filter((part) => part?.type === 'text' && typeof part.text === 'string')
718
833
  .map((part) => part.text)
719
834
  .join('\n');
720
- return classifyIdeaFailure({ message: text }) !== 'project';
835
+ return classifyIdeFailure({ message: text }, serverNames()) !== 'project';
836
+ };
837
+
838
+ /**
839
+ * 这个项目的 IDE 端口上,服务着它吗?是就返回端口,否则 `undefined`。
840
+ *
841
+ * @param {{ path: string, app: string }} project
842
+ */
843
+ const portForProject = async (project) => {
844
+ const port = portFor(project);
845
+ if (port === undefined) return undefined;
846
+ return (await isProjectOpen(port, project.path)) ? port : undefined;
847
+ };
848
+
849
+ /**
850
+ * `open -a` only requests activation/opening. A cold IDE needs time before
851
+ * its bundled MCP server listens, so poll until the endpoint appears AND
852
+ * answers for THIS project. This is the step that makes a single
853
+ * `/open-in-idea` enough on a cold start.
854
+ *
855
+ * @param {{ path: string, app: string }} project
856
+ * @returns {Promise<number | undefined>}
857
+ */
858
+ const waitForProjectServer = async (project) => {
859
+ const deadline = Date.now() + mcpStartTimeoutMs;
860
+ while (Date.now() < deadline) {
861
+ const port = await portForProject(project).catch(() => undefined);
862
+ if (port !== undefined) return port;
863
+ const remaining = deadline - Date.now();
864
+ if (remaining <= 0) break;
865
+ await new Promise((resolve) => setTimeout(resolve, Math.min(MCP_START_POLL_MS, remaining)));
866
+ }
867
+ return undefined;
868
+ };
869
+
870
+ /**
871
+ * 每个项目各自的启动状态:in-flight、拉起冷却、是否已发过通知。
872
+ *
873
+ * 按项目分开是必需的:IDEA 与 PyCharm 是两个独立进程,一个的冷却不能拖住另一个;
874
+ * 而同一个项目的重复调用仍然共享一次拉起(re-entrant)。
875
+ */
876
+ const launchStates = new Map();
877
+ const launchStateFor = (path) => {
878
+ let state = launchStates.get(path);
879
+ if (!state) {
880
+ state = {
881
+ /** @type {Promise<IdeCommandResult> | undefined} */
882
+ inFlight: undefined,
883
+ noticeSent: false,
884
+ guard: createLaunchGuard({ cooldownMs: launchCooldownMs, maxAttempts: launchMaxAttempts }),
885
+ };
886
+ launchStates.set(path, state);
887
+ }
888
+ return state;
721
889
  };
722
890
 
723
- // Re-entrant: repeat invocations share one open request so the IDE is not
724
- // spawned twice while the application is starting.
725
- /** @type {Promise<IdeCommandResult> | undefined} */
726
- let launchInFlight;
727
- let launchNoticeSent = false;
728
891
  /**
892
+ * 确保 `project` 的 IDE 正开着、并且打开着这个项目;返回它的端口。
893
+ *
894
+ * @param {{ path: string, server: string, app: string, label: string, isRoot: boolean }} project
729
895
  * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
730
896
  * @returns {Promise<IdeCommandResult>}
731
897
  */
732
- const ensureIde = ({ onUnavailable } = {}) => {
733
- if (launchInFlight) return launchInFlight;
734
- launchNoticeSent = false;
898
+ const ensureProject = (project, { onUnavailable } = {}) => {
899
+ const state = launchStateFor(project.path);
900
+ if (state.inFlight) return state.inFlight;
735
901
  const reportUnavailable = async (opened) => {
736
- if (launchNoticeSent || typeof onUnavailable !== 'function') return;
902
+ if (state.noticeSent || typeof onUnavailable !== 'function') return;
737
903
  // Fast-fail: notify as soon as the endpoint is known to be missing so
738
904
  // the user can enable MCP + Brave Mode and retry the command.
739
- const delivered = await onUnavailable({ status: 'mcp-unavailable', opened });
740
- if (delivered) launchNoticeSent = true;
905
+ const delivered = await onUnavailable({ status: 'mcp-unavailable', opened, project });
906
+ if (delivered) state.noticeSent = true;
741
907
  };
742
908
  const run = (async () => {
743
- const serving = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
909
+ // 已经开着并且就打开着这个项目:什么都不用做。
910
+ const serving = await portForProject(project).catch(() => undefined);
744
911
  if (serving !== undefined) {
745
- guard.reset();
746
- launchNoticeSent = false;
747
- // The endpoint being up proves only that the IDE is RUNNING — it may
748
- // have a DIFFERENT project open. Ask the IDE whether THIS project is
749
- // open, and only request an open when it is not: for an already-open
750
- // project the IDE runs a short reopen-then-dispose cycle on a
751
- // redundant open event, so skipping it is a real saving.
752
- if (ideApp && !(await isProjectOpen(serving))) {
753
- await openInIde(projectPath, { app: ideApp });
754
- }
755
- return { status: 'connected', port: serving, unavailableNoticeSent: false };
756
- }
757
- // No app to open. Either the user turned launching off, or this platform
758
- // has no launcher — say which, so the notice is actionable.
759
- if (!ideApp) {
760
- return { status: autoLaunchSupported() ? 'disabled' : 'manual-launch' };
912
+ state.guard.reset();
913
+ state.noticeSent = false;
914
+ return { status: 'connected', port: serving, project, unavailableNoticeSent: false };
761
915
  }
762
- if (guard.exhausted()) {
763
- await reportUnavailable(false);
764
- return { status: 'mcp-unavailable', opened: false, unavailableNoticeSent: launchNoticeSent };
916
+ // 需要一个 app 才能拉起。拿不到 app 的原因有两种,交给 autoLaunchSupported 区分。
917
+ const app = launchEnabled ? appNameFor(project.app) : undefined;
918
+ if (!app) {
919
+ return { status: autoLaunchSupported() ? 'disabled' : 'manual-launch', project };
765
920
  }
766
- if (guard.coolingDown()) {
921
+ if (state.guard.exhausted() || state.guard.coolingDown()) {
767
922
  // There is no reason to wait here: MCP is disabled/not ready, and
768
- // the user must enable it in IDEA before retrying the command.
923
+ // the user must enable it in the IDE before retrying the command.
769
924
  await reportUnavailable(false);
770
- return { status: 'mcp-unavailable', opened: false, unavailableNoticeSent: launchNoticeSent };
925
+ return { status: 'mcp-unavailable', opened: false, project, unavailableNoticeSent: state.noticeSent };
771
926
  }
772
- guard.markSpawn();
773
- const launched = await openInIde(projectPath, { app: ideApp });
774
- if (!launched) return { status: 'launch-failed' };
775
- // `open -a` only requests activation/opening. A cold IDE needs time to
776
- // start its bundled MCP server, so wait for the endpoint instead of
777
- // reporting failure immediately.
778
- const started = await waitForIdeServer();
927
+ state.guard.markSpawn();
928
+ const launched = await openInIde(project.path, { app });
929
+ if (!launched) return { status: 'launch-failed', project };
930
+ const started = await waitForProjectServer(project);
779
931
  if (started !== undefined) {
780
- guard.reset();
781
- launchNoticeSent = false;
782
- return { status: 'connected', port: started, unavailableNoticeSent: false };
932
+ state.guard.reset();
933
+ state.noticeSent = false;
934
+ return { status: 'connected', port: started, project, unavailableNoticeSent: false };
783
935
  }
784
936
  await reportUnavailable(true);
785
- return { status: 'mcp-unavailable', opened: true, unavailableNoticeSent: launchNoticeSent };
937
+ return { status: 'mcp-unavailable', opened: true, project, unavailableNoticeSent: state.noticeSent };
786
938
  })();
787
- launchInFlight = run;
939
+ state.inFlight = run;
788
940
  void run
789
941
  .catch(() => {})
790
942
  .then(() => {
791
- if (launchInFlight === run) launchInFlight = undefined;
943
+ if (state.inFlight === run) state.inFlight = undefined;
792
944
  });
793
945
  return run;
794
946
  };
795
947
 
796
948
  /**
797
- * The whole `/open-in-idea` flow, shared by the command and the auto-recovery
798
- * path: ensure an IDE MCP endpoint, then register it and load capabilities.
949
+ * `/open-in-idea` 的主流程:让每个已登记项目的 IDE 都就绪,然后把它们注册进
950
+ * OpenCode。
951
+ *
952
+ * 每个项目各自拉起**自己的** IDE —— root 在 IDEA、某子项目在 PyCharm 是常态,
953
+ * 所以这里不是"连一个 IDE",而是"连一组",彼此并行。
799
954
  *
800
955
  * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
801
956
  * @returns {Promise<IdeCommandResult>}
802
957
  */
803
958
  const runOpenInIdea = async ({ onUnavailable } = {}) => {
804
- const result = await ensureIde({ onUnavailable });
805
- const { port } = result;
959
+ const projects = currentProjects();
960
+ if (projects.length === 0) return { status: 'not-configured' };
961
+ const results = await Promise.all(
962
+ projects.map((project) => ensureProject(project, { onUnavailable })),
963
+ );
964
+ const connected = results.filter((result) => result.status === 'connected');
965
+ if (connected.length === 0) {
966
+ // 一个都没连上:注销残留注册,并挑一个代表性结果回去(文案已带上具体项目)。
967
+ await reconcile({ probeIde: false }).catch(() => {});
968
+ return results[0];
969
+ }
806
970
  let toolsReady = false;
807
971
  try {
808
- // Do not repeat the expensive probe when the preflight already found no
809
- // MCP endpoint. The command will retry after the user enables MCP +
810
- // Brave Mode.
811
- toolsReady = (await reconcile({ probeIde: port !== undefined })) === true;
972
+ // 重新探测一次并整体注册:ensureProject 只保证 IDE 就绪,注册要按"当前真正
973
+ // 打开着哪些项目"重来一遍,这样 `/open-in-idea` 每次都是同一套结果。
974
+ toolsReady = (await reconcile({ probeIde: true })) === true;
812
975
  } catch {
813
976
  // capability load is best effort; feedback still matters
814
977
  }
815
- return result.status === 'connected' && !toolsReady
816
- ? { ...result, status: 'tools-loading' }
817
- : result;
978
+ const connectedProjects = connected.map((result) => result.project);
979
+ return toolsReady
980
+ ? { status: 'connected', port: connected[0].port, projects: connectedProjects }
981
+ : { ...connected[0], status: 'tools-loading', projects: connectedProjects };
818
982
  };
819
983
 
820
984
  /**
@@ -832,16 +996,15 @@ export default {
832
996
  /** @type {Promise<void> | undefined} */
833
997
  let reviveInFlight;
834
998
  const reviveIde = () => {
835
- // The mark gates EVERY reconnect, not just the ones started from a user
836
- // message: a background attempt must never resurrect the IDE for a project
837
- // the user closed with `/close-in-idea`.
838
- if (disposed || markCache !== true) return Promise.resolve();
999
+ // 配置 gate 住每一次重连,包括后台发起的那些:后台尝试绝不能把用户已经用
1000
+ // `/delete-in-ide` 删掉的项目重新拉起来。
1001
+ if (disposed || !config) return Promise.resolve();
839
1002
  if (reviveInFlight) return reviveInFlight;
840
1003
  const run = (async () => {
841
1004
  try {
842
1005
  const result = await runOpenInIdea();
843
- // `/close-in-idea` can land while this was in flight; it wins.
844
- if (markCache !== true) {
1006
+ // `/delete-in-ide` 可能在这期间落地;它赢。
1007
+ if (!config) {
845
1008
  await deactivateIde();
846
1009
  return;
847
1010
  }
@@ -859,7 +1022,7 @@ export default {
859
1022
  return run;
860
1023
  };
861
1024
 
862
- // Setup never probes or connects the IDE — everything is on `/open-in-idea`.
1025
+ // Setup never probes or connects any IDE — everything is on the commands.
863
1026
  await reconcile({ probeIde: false });
864
1027
 
865
1028
  const sendFeedback = async (input, result) => {
@@ -908,15 +1071,16 @@ export default {
908
1071
  // there to write the project's own rules). They pass two independent gates,
909
1072
  // because a skill can be wrong twice over:
910
1073
  //
911
- // * the project mark — an unrelated project never sees an IDEA skill;
1074
+ // * the project config — an unrelated project never sees an IDE skill;
912
1075
  // * the tools the IDE actually serves — a skill that teaches a call the
913
1076
  // model cannot make is not knowledge, it is a failed call waiting to
914
- // happen. `idea-standalone` drives `idea_rerun_run_configuration`, which
915
- // only the companion plugin `idea-mcp-enhance` serves, so a stock IDE must
916
- // not see it at all. Each skill declares its own dependency in its
917
- // frontmatter (`requires:`), so this stays a data question.
1077
+ // happen. `idea-standalone` drives `rerun_run_configuration`, which only
1078
+ // the companion plugin `idea-mcp-enhance` serves, so a stock IDE must not
1079
+ // see it at all. Each skill declares its own dependency in its frontmatter
1080
+ // (`requires:`), as a SUFFIX — tool names carry a per-project prefix, so
1081
+ // a hard-coded `idea_...` could only ever match one project.
918
1082
  //
919
- // The catalog is unknown at setup: the mark is read from storage offline,
1083
+ // The catalog is unknown at setup: the config is read from storage offline,
920
1084
  // before anything has connected to the IDE. So this is a reconcile rather
921
1085
  // than a one-shot — every catalog refresh re-runs it, and it offers exactly
922
1086
  // what is justified right now. Until the IDE has answered, the gated skills
@@ -930,8 +1094,10 @@ export default {
930
1094
  if (typeof ctx.skill?.transform !== 'function') return;
931
1095
 
932
1096
  const served = await servedIdeaToolNames();
933
- const wanted = (markCache === true ? readSkills() : [])
934
- .filter(({ requires }) => requires.every((name) => served.has(name)))
1097
+ const wanted = (config ? readSkills() : [])
1098
+ .filter(({ requires }) =>
1099
+ requires.every((suffix) => prefixes().some((prefix) => served.has(`${prefix}${suffix}`))),
1100
+ )
935
1101
  .map(({ skill }) => skill);
936
1102
  if (isSameSkillSet(wanted, registeredSkillIds)) return;
937
1103
 
@@ -957,7 +1123,7 @@ export default {
957
1123
 
958
1124
  // Serialized like the reconciler, and for the same reason: one call disposes
959
1125
  // a registration before it makes the next, so two overlapping calls would
960
- // leak one of them. That overlap is easy to reach — `/close-in-idea` can land
1126
+ // leak one of them. That overlap is easy to reach — `/delete-in-ide` can land
961
1127
  // while a reconnect is still in flight. With the chain in place, callers may
962
1128
  // fire this without awaiting.
963
1129
  let skillSyncChain = Promise.resolve();
@@ -970,26 +1136,168 @@ export default {
970
1136
  );
971
1137
  return next;
972
1138
  };
973
- // A project marked in an earlier run gets its skills as soon as the IDE has
974
- // answered; a project marked for the first time gets them when
975
- // `/open-in-idea` runs (both paths go through `setMark` / this setup hook).
1139
+ // A project configured in an earlier run gets its skills as soon as the IDE
1140
+ // has answered; a project configured for the first time gets them when
1141
+ // `/open-in-idea` runs (both paths go through `writeConfig` / this setup hook).
976
1142
  await syncSkills();
977
1143
 
978
- // `/open-in-idea`: manual, re-entrant trigger that also loads the IDE
979
- // capabilities immediately, and marks the project so later messages keep the
980
- // IDE alive. `/close-in-idea` removes that mark. Both persist through
981
- // `ctx.storage` and invalidate the in-memory cache.
1144
+ /**
1145
+ * 问用户"这个项目用哪个 IDE",由模型转达并回写。
1146
+ *
1147
+ * 插件自己不能弹选择框,也不能挂起等待;唯一可靠的通道是让模型去问、再把答案用
1148
+ * `configure_ide_project` 结构化地写回来。所以这里发的**不是通知而是任务**:文本
1149
+ * 里必须写明"调用了工具才算完成",否则模型很容易只回一句"好的,用 PyCharm"。
1150
+ *
1151
+ * @param {{ sessionID?: string, prompt?: unknown, delivery?: unknown }} input
1152
+ * @param {{ label: string, isRoot: boolean }} target
1153
+ */
1154
+ const askIdeChoice = async (input, target) => {
1155
+ if (typeof ctx.session?.prompt !== 'function') return;
1156
+ const where = target.isRoot ? `根项目(${projectPath})` : `子项目 ${target.label}`;
1157
+ const call = target.isRoot
1158
+ ? 'configure_ide_project({ ide: "<idea|pycharm>" })'
1159
+ : `configure_ide_project({ path: "${target.label}", ide: "<idea|pycharm>" })`;
1160
+ // 插件自己摸底,而不是让模型去"找 IDE" —— 它找到的办法通常是 `ps aux | grep`(只能
1161
+ // 看到**正在运行**的进程,装了没开就漏),又慢又不准。这里是按应用目录读一次得到的。
1162
+ const installed = detectInstalledIdes();
1163
+ const missing = SUPPORTED_IDES.filter((app) => !installed.includes(app));
1164
+ const detectLines = installed.length
1165
+ ? [
1166
+ `插件已探测本机安装情况:装有 ${installed.map((app) => IDE_LABELS[app] ?? app).join('、')}` +
1167
+ (missing.length
1168
+ ? `;没找到 ${missing.map((app) => IDE_LABELS[app] ?? app).join('、')}(用户若要用它,需要自己先装)`
1169
+ : '') +
1170
+ '。',
1171
+ '**不要再自己去跑命令或翻进程** —— 这是按应用目录得到的结论,进程里没有不代表没装。',
1172
+ '',
1173
+ ]
1174
+ : [];
1175
+ const text = [
1176
+ `${NOTICE_PREFIX}需要确定 ${where} 用哪个 IDE 打开。`,
1177
+ '',
1178
+ ...detectLines,
1179
+ '请向用户提问,并**在得到答复后调用 `configure_ide_project` 工具**写入配置。可选值只有两个:',
1180
+ '- `idea` → IntelliJ IDEA',
1181
+ '- `pycharm` → PyCharm',
1182
+ '',
1183
+ '要选它**真正被打开**的那个 IDE(例如 Python 子项目通常是 PyCharm;选错了,IDE 会把请求解析到',
1184
+ '包含它的另一个已打开项目上,符号检索与检查会按那个项目的语言支持走)。',
1185
+ '',
1186
+ `调用形式:${call}`,
1187
+ '',
1188
+ '不要替用户决定,也不要只回答"已配置" —— 没有真正调用工具就等于没配置。',
1189
+ ].join('\n');
1190
+ try {
1191
+ await ctx.session.prompt({
1192
+ ...input.prompt,
1193
+ sessionID: input.sessionID,
1194
+ text,
1195
+ metadata: {
1196
+ displayText: `请选择 ${where} 使用的 IDE:IntelliJ IDEA 还是 PyCharm?`,
1197
+ comments: [],
1198
+ },
1199
+ delivery: input.delivery,
1200
+ });
1201
+ } catch {
1202
+ // best effort
1203
+ }
1204
+ };
1205
+
1206
+ /**
1207
+ * `configure_ide_project` 的执行体:校验后写入配置并立即接入。
1208
+ *
1209
+ * 这是模型把用户的选择写回插件的**唯一**通道,所以它自己也做全套校验:模型可能
1210
+ * 传错路径、传一个不是 IDE 的值,插件不能因为"是模型给的"就放行。
1211
+ *
1212
+ * @param {{ path?: unknown, ide?: unknown }} args
1213
+ */
1214
+ const configureIdeProject = async (args) => {
1215
+ const ide = typeof args?.ide === 'string' ? args.ide.toLowerCase() : '';
1216
+ if (!SUPPORTED_IDES.includes(ide)) {
1217
+ return { content: `不支持的 IDE \`${String(args?.ide)}\`。可选:${SUPPORTED_IDES.join(' / ')}。` };
1218
+ }
1219
+ const rawPath = typeof args?.path === 'string' ? args.path.trim() : '';
1220
+ let current = await readConfig();
1221
+ if (rawPath === '') {
1222
+ current = current
1223
+ ? { root: { ...current.root, app: ide }, children: current.children }
1224
+ : { root: { path: projectPath, app: ide }, children: [] };
1225
+ await writeConfig(current);
1226
+ await runOpenInIdea();
1227
+ return { content: `已把根项目配置为 ${IDE_LABELS[ide]} 并接入。` };
1228
+ }
1229
+ if (!current) {
1230
+ return { content: '当前项目还没有 IDE 配置。请先执行 /open-in-idea。' };
1231
+ }
1232
+ const absPath = resolve(projectPath, rawPath);
1233
+ if (!existsSync(absPath)) {
1234
+ return { content: `目录不存在:${absPath}。` };
1235
+ }
1236
+ const verdict = validateSubproject(current, absPath);
1237
+ if (!verdict.ok) {
1238
+ return { content: `无法登记子项目:${verdict.message}` };
1239
+ }
1240
+ current = withSubproject(current, absPath, ide);
1241
+ await writeConfig(current);
1242
+ await runOpenInIdea();
1243
+ return { content: `已登记子项目 \`${rawPath}\`(${IDE_LABELS[ide]})并接入。` };
1244
+ };
1245
+
1246
+ /** `configure_ide_project` 的工具定义。 */
1247
+ const configureIdeTool = () => ({
1248
+ name: 'configure_ide_project',
1249
+ description:
1250
+ '把用户选定的 IDE 写入本项目的 IDE 配置(即"这个项目用哪个 IDE 打开")。' +
1251
+ '仅当用户明确回答了"用 IntelliJ IDEA 还是 PyCharm"之后调用,不要在用户没答复前猜。' +
1252
+ 'path 省略表示配置 root 项目本身;ide 只取 idea / pycharm。',
1253
+ input: {
1254
+ type: 'object',
1255
+ properties: {
1256
+ path: {
1257
+ type: 'string',
1258
+ description: '子项目相对 root 的路径(例如 modelserver);省略表示 root 项目本身。',
1259
+ },
1260
+ ide: {
1261
+ type: 'string',
1262
+ enum: [...SUPPORTED_IDES],
1263
+ description: '该项目的 IDE:idea = IntelliJ IDEA,pycharm = PyCharm。',
1264
+ },
1265
+ },
1266
+ required: ['ide'],
1267
+ },
1268
+ execute: async (args) => configureIdeProject(args),
1269
+ });
1270
+
1271
+ // `/open-in-idea` 接入 root 与其全部子项目;`/add-sub-module-in-ide <路径>` 登记
1272
+ // 一个子项目(IDE 由模型问用户后写回);`/delete-in-ide` 删除整个配置。
1273
+ //
1274
+ // 命名说明:命令名里的 `idea` 是历史包袱(工具前缀早已改成与产品无关的 `ide_`),
1275
+ // 保留它是为了不改用户天天敲的名字;它的语义是"接入 IDE",不是"接入 IntelliJ"。
982
1276
  if (typeof ctx.command?.transform === 'function') {
983
1277
  commandRegistration = await ctx.command.transform((editor) => {
984
1278
  editor.add({
985
1279
  name: 'open-in-idea',
986
- description: '用 IntelliJ IDEA 打开当前项目,并即时加载 IDE MCP 与项目 SDK 环境',
1280
+ description: '接入当前项目:root 及其全部已登记子项目,各自用配置好的 IDE 打开',
987
1281
  execute: async (input) => {
988
- // Mark the project before running: the mark means "this is an IDEA
989
- // project", so it is persisted as soon as the user asks, whether or
990
- // not the IDE happens to be reachable right now. `setMark` also
991
- // registers the IDEA skills.
992
- await setMark(true);
1282
+ const parsed = parseIdeArgument(input?.prompt?.text);
1283
+ if (!parsed.ok) {
1284
+ await sendFeedback(input, { status: 'bad-argument', message: parsed.message });
1285
+ return;
1286
+ }
1287
+ let current = await readConfig();
1288
+ if (!current) {
1289
+ // 第一次:需要知道 root 用哪个 IDE。无参时让模型问用户;带参则直通。
1290
+ if (!parsed.app) {
1291
+ await askIdeChoice(input, { isRoot: true, label: '.' });
1292
+ return;
1293
+ }
1294
+ current = { root: { path: projectPath, app: parsed.app }, children: [] };
1295
+ await writeConfig(current);
1296
+ } else if (parsed.app && parsed.app !== current.root.app) {
1297
+ // 直通逃生口:显式带参时改绑 root 的 IDE(模型不配合时用户仍能自救)。
1298
+ current = { root: { ...current.root, app: parsed.app }, children: current.children };
1299
+ await writeConfig(current);
1300
+ }
993
1301
  /** @type {boolean} */
994
1302
  let unavailableNoticeSent = false;
995
1303
  const result = await runOpenInIdea({
@@ -1007,39 +1315,79 @@ export default {
1007
1315
  },
1008
1316
  });
1009
1317
  editor.add({
1010
- name: 'close-in-idea',
1011
- description: '取消当前项目的 IDEA 接入:注销 IDE MCP 并清除项目标记',
1318
+ name: 'add-sub-module-in-ide',
1319
+ description: '登记一个子项目(参数:子项目相对 root 的路径);随后由 AI 询问用哪个 IDE',
1320
+ execute: async (input) => {
1321
+ const parsed = parseSubprojectArgument(input?.prompt?.text);
1322
+ if (!parsed.ok) {
1323
+ await sendFeedback(input, { status: 'bad-argument', message: parsed.message });
1324
+ return;
1325
+ }
1326
+ const current = await readConfig();
1327
+ if (!current) {
1328
+ await sendFeedback(input, { status: 'not-configured' });
1329
+ return;
1330
+ }
1331
+ const absPath = resolve(projectPath, parsed.path);
1332
+ if (!existsSync(absPath)) {
1333
+ await sendFeedback(input, {
1334
+ status: 'invalid-subproject',
1335
+ message: `目录不存在:${absPath}`,
1336
+ });
1337
+ return;
1338
+ }
1339
+ const verdict = validateSubproject(current, absPath);
1340
+ if (!verdict.ok) {
1341
+ await sendFeedback(input, { status: 'invalid-subproject', message: verdict.message });
1342
+ return;
1343
+ }
1344
+ await askIdeChoice(input, { isRoot: false, label: absPath.slice(projectPath.length + 1) });
1345
+ },
1346
+ });
1347
+ editor.add({
1348
+ name: 'delete-in-ide',
1349
+ description: '删除本项目的 IDE 配置:注销全部 IDE MCP 并清除 root 与子项目的登记',
1012
1350
  execute: async (input) => {
1013
- await setMark(false);
1014
- // Unregister the IDE MCP server. Reuses the serialized reconcile, so
1015
- // a concurrent rebuild (an in-flight `/open-in-idea`, or a
1016
- // request-time heal) cannot re-register the server after the mark is
1017
- // gone.
1351
+ await writeConfig(null);
1352
+ // 注销全部 server。走串行化的 reconcile,所以在飞的重建(另一个
1353
+ // `/open-in-idea`、请求期自愈)不会在配置已删之后再注册回来。
1018
1354
  await reconcile({ probeIde: false });
1019
- await sendFeedback(input, { status: 'closed' });
1355
+ await sendFeedback(input, { status: 'deleted' });
1020
1356
  },
1021
1357
  });
1022
1358
  });
1023
1359
  }
1024
1360
 
1361
+ // `configure_ide_project`:模型把用户选定的 IDE 写回配置的唯一通道。
1362
+ //
1363
+ // 常驻注册,不做"用时注册、写完撤销":一个工具的 schema 只有几百 token,省不下
1364
+ // 什么,却要换来一整套状态机(什么时候注册、模型一直不调用怎么办、用户中途说别
1365
+ // 的怎么办)。它该不该被调用由 description 约束。
1366
+ if (typeof ctx.tool?.transform === 'function') {
1367
+ configToolRegistration = await ctx.tool.transform((editor) => {
1368
+ editor.add(configureIdeTool());
1369
+ });
1370
+ }
1371
+
1025
1372
  // Hide the tools the model must not see, and point it at the rest. This hook
1026
1373
  // runs for every agent-loop step (including tool-driven continuations), so
1027
1374
  // it stays a cheap, I/O-free function of the request: it checks the cached
1028
- // project mark and this request's own tool snapshot, and takes no action.
1375
+ // config and this request's own tool snapshot, and takes no action.
1029
1376
  //
1030
1377
  // `event.tools` is this request's tool snapshot, and OpenCode's own registry
1031
- // adds IDEA tools when the server connects and drops them when it stops
1378
+ // adds IDE tools when the servers connect and drops them when they stop
1032
1379
  // (`stopServer` publishes `mcp.tools.changed`, which the registry turns into
1033
1380
  // a reload). So the snapshot is also the honest answer to "is the IDE
1034
1381
  // serving?".
1035
1382
  if (typeof ctx.session?.hook === 'function') {
1036
1383
  sessionRegistration = await ctx.session.hook('context', (event) => {
1037
1384
  const tools = event?.tools;
1038
- const serving = tools !== undefined && Object.keys(tools).some((name) => name.startsWith(IDEA_TOOL_PREFIX));
1385
+ const serving =
1386
+ tools !== undefined && Object.keys(tools).some((name) => isIdeToolName(name));
1039
1387
  if (serving) {
1040
1388
  wasServing = true;
1041
- removeIdeaToolDefinitions(tools, HIDDEN_IDEA_TOOLS);
1042
- } else if (wasServing && tools !== undefined && markCache === true) {
1389
+ removeIdeToolDefinitions(tools, HIDDEN_IDE_TOOL_SUFFIXES, prefixes());
1390
+ } else if (wasServing && tools !== undefined && config) {
1043
1391
  // This session HAD the tools and this request does not. Latch a
1044
1392
  // rebuild here, where "is the IDE serving THIS request?" is a fact: a
1045
1393
  // failed reconnect can tear the registration down without anything else
@@ -1055,8 +1403,8 @@ export default {
1055
1403
  // need one more message before anything happened.
1056
1404
  void reviveIde();
1057
1405
  }
1058
- if (markCache === true || serving) {
1059
- appendIdeRecoveryGuidance(event?.system, { marked: markCache === true, serving });
1406
+ if (config || serving) {
1407
+ appendIdeRecoveryGuidance(event?.system, { marked: Boolean(config), serving });
1060
1408
  }
1061
1409
  // A skill follows its tools in both directions, and this is the one place
1062
1410
  // that sees the answer change WITHOUT a reconnect: a request that has
@@ -1066,34 +1414,32 @@ export default {
1066
1414
  //
1067
1415
  // Skipped when there is nothing it could change, so a project that never
1068
1416
  // touched the IDE does not pay a catalog read on every request.
1069
- if (markCache === true || registeredSkillIds.length > 0) void syncSkills();
1417
+ if (config || registeredSkillIds.length > 0) void syncSkills();
1070
1418
  if (!injectGuidance) return;
1071
1419
  // Guidance promises tools the model can call, so it only goes out when
1072
1420
  // they are actually in this request.
1073
1421
  if (!serving) return;
1074
- // Same rule for the run-lifecycle tools: they come from the companion
1075
- // IDEA plugin `idea-mcp-enhance`, so a stock IDE has none of them and
1076
- // naming them would promise calls that can only fail.
1077
- appendIdeGuidance(event?.system, projectPath, {
1078
- runLifecycleTools: servingRunLifecycleTools(tools),
1079
- });
1422
+ // 逐项目注入:每个项目那一节只写它自己的前缀,模型不需要自己换算。
1423
+ // run-lifecycle 工具(来自配套插件 `idea-mcp-enhance`)也按每个项目分别判断,
1424
+ // 所以一个项目装了 enhance、另一个没装时,两节的内容本来就不一样。
1425
+ appendIdeGuidance(event?.system, currentProjects(), { tools });
1080
1426
  });
1081
1427
 
1082
1428
  // 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
1083
- // **本次请求**里就带上 `idea_*` 工具,没有"先回一句正在重连、下一条才行"
1429
+ // **本次请求**里就带上 IDE 工具,没有"先回一句正在重连、下一条才行"
1084
1430
  // 的空窗。
1085
1431
  //
1086
1432
  // 两种"需要重连"的形状都从这里进:
1087
1433
  // * `needsRebuild` — 之前的请求已经证明工具没了;
1088
- // * `registrations.length === 0` — 本插件实例压根没有 `idea` 服务器,这
1434
+ // * `registrations.length === 0` — 本插件实例压根没有注册任何 IDE server,这
1089
1435
  // 正是 OpenCode 回收 Location 服务(LayerMap idleTimeToLive = 60 分钟)
1090
1436
  // 或进程重启后新实例的样子。setup 故意不连接,没有这一步会话就会永久
1091
1437
  // 没有工具,只能靠用户手敲 `/open-in-idea`。
1092
1438
  //
1093
1439
  // 只探测、不启动 IDE:走 `reconcile({ probeIde: true })`,而不是会 `open -a`
1094
- // 的 `reviveIde()` —— 一条用户消息不该把 IDEA 拉起来。
1440
+ // 的 `reviveIde()` —— 一条用户消息不该把 IDE 拉起来。
1095
1441
  promptRegistration = await ctx.session.hook('prompt', async (event) => {
1096
- if (disposed || markCache !== true) return;
1442
+ if (disposed || !config) return;
1097
1443
  const fresh = registrations.length === 0;
1098
1444
  if (!needsRebuild && !fresh) return;
1099
1445
  // Never react to the plugin's own notices, or recovery would feed on
@@ -1118,21 +1464,21 @@ export default {
1118
1464
  // `mcp/idea-tool-defaults.js` for the measurements and for why this is a
1119
1465
  // narrow exception to "never touch tool definitions".
1120
1466
  //
1121
- // Both halves are deliberately unconditional on the project mark: they are
1467
+ // Both halves are deliberately unconditional on the project config: they are
1122
1468
  // properties of the tool call, not of whether this project is IDE-connected.
1123
1469
  if (typeof ctx.tool?.transform === 'function') {
1124
1470
  toolTransformRegistration = await ctx.tool.transform((editor) => {
1125
- hideForcedArguments(editor);
1471
+ hideForcedArguments(editor, prefixes());
1126
1472
  });
1127
1473
  }
1128
1474
  if (typeof ctx.tool?.hook === 'function') {
1129
1475
  toolBeforeRegistration = await ctx.tool.hook('execute.before', (event) => {
1130
1476
  if (typeof event?.tool !== 'string') return;
1131
- applyIdeaToolDefaults(event.tool, event.input);
1477
+ applyIdeToolDefaults(event.tool, event.input, prefixes());
1132
1478
  });
1133
1479
  }
1134
1480
 
1135
- // The only self-initiated action: when an `idea_*` call fails in a way
1481
+ // The only self-initiated action: when an IDE tool call fails in a way
1136
1482
  // that means the IDE MCP is not usable, run the open flow again. The
1137
1483
  // failure is classified — a plain business error (a bad path, a missing
1138
1484
  // file) never triggers a reconnect, only "the MCP is not available" or
@@ -1140,9 +1486,9 @@ export default {
1140
1486
  if (typeof ctx.tool?.hook === 'function') {
1141
1487
  toolRegistration = await ctx.tool.hook('execute.after', async (event) => {
1142
1488
  if (event?.status !== 'error') return;
1143
- if (typeof event?.tool !== 'string' || !event.tool.startsWith('idea_')) return;
1144
- if (!(await readMark())) return;
1145
- const kind = classifyIdeaFailure(event.error);
1489
+ if (typeof event?.tool !== 'string' || !isIdeToolName(event.tool)) return;
1490
+ if (!config) return;
1491
+ const kind = classifyIdeFailure(event.error, serverNames());
1146
1492
  if (kind === 'none') return;
1147
1493
  if (kind === 'project') {
1148
1494
  // The IDE is up and answering; only the PROJECT is missing. That is
@@ -1151,9 +1497,16 @@ export default {
1151
1497
  // nothing to call, and falling through would report "reconnected"
1152
1498
  // and send the model round after round after a call that can only
1153
1499
  // fail. Leave the IDE's own message, which names the real cause.
1154
- if (!ideApp) return;
1155
- await openInIde(projectPath, { app: ideApp });
1156
- rewriteToolError(event, '该项目已在 IDEA 中重新打开,请重试刚才的操作。');
1500
+ if (!launchEnabled) return;
1501
+ // 只有这个工具所属的那个项目的 IDE 需要重开(多项目下别去动别的项目)。
1502
+ const project = projectForTool(event.tool);
1503
+ const app = project ? appNameFor(project.app) : undefined;
1504
+ if (!project || !app) return;
1505
+ await openInIde(project.path, { app });
1506
+ rewriteToolError(
1507
+ event,
1508
+ `该项目已在 ${IDE_LABELS[project.app] ?? 'IDE'} 中重新打开,请重试刚才的操作。`,
1509
+ );
1157
1510
  return;
1158
1511
  }
1159
1512
  const result = await runOpenInIdea();
@@ -1193,6 +1546,13 @@ export default {
1193
1546
  // best effort
1194
1547
  }
1195
1548
  }
1549
+ if (configToolRegistration) {
1550
+ try {
1551
+ await configToolRegistration.dispose();
1552
+ } catch {
1553
+ // best effort
1554
+ }
1555
+ }
1196
1556
  if (promptRegistration) {
1197
1557
  try {
1198
1558
  await promptRegistration.dispose();