opencode-idea 0.0.16 → 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-run-config` skill (marked projects only), which
13
- // teaches the model how to manage `.run/*.run.xml` run configurations — a
14
- // capability the IDE MCP server does not expose.
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,31 +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';
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';
54
59
  import { currentProjectPath } from './project.js';
55
60
  import {
61
+ appNameFor,
56
62
  autoLaunchSupported,
57
63
  createLaunchGuard,
64
+ detectInstalledIdes,
58
65
  openInIde,
59
- resolveIdeApp,
60
66
  } from './ide-launcher.js';
61
67
  import {
62
68
  appendIdeGuidance,
63
69
  appendIdeRecoveryGuidance,
64
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';
65
85
  import { readSkills } from './skill/loader.js';
66
86
 
67
87
  const DEFAULT_LAUNCH_COOLDOWN_MS = 120000;
68
88
  const DEFAULT_LAUNCH_MAX_ATTEMPTS = 5;
69
- // MCP availability is a fast preflight. If it is not ready, fail fast and let
70
- // the user enable MCP + Brave Mode in IDEA before retrying the command.
71
- 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;
72
94
  // A cold IDE can take tens of seconds before its bundled MCP server listens.
73
95
  // After `open -a`, poll for the endpoint instead of reporting failure once.
74
96
  const DEFAULT_MCP_START_TIMEOUT_MS = 60000;
@@ -83,30 +105,30 @@ const TOOL_REFRESH_POLL_MS = 100;
83
105
  // beyond any normal IDE tool and turns that hang into a normal error the failure
84
106
  // classifier can act on.
85
107
  const DEFAULT_EXECUTION_TIMEOUT_MS = 600000;
86
- // Every IDE tool comes from the MCP server the plugin registers as `idea`, and
87
- // OpenCode namespaces a server's tools with its name — so `idea_*` IS the whole
88
- // set, the same predicate the request hooks use. "Ready" means at least one of
89
- // them is listed: OpenCode publishes an MCP catalog wholesale (one reload), so
90
- // one tool means the batch landed.
91
- //
92
- // This used to be a hard-coded pair (`idea_read_file` + `idea_apply_patch`),
93
- // which is not a property the plugin may assume: the IDE can rename a tool, and
94
- // the user can switch one off on the IDE's Exposed Tools page. Either one made
95
- // the wait unsatisfiable, so every `/open-in-idea` and every auto-rebuild burned
96
- // the full `toolRefreshTimeoutMs` and still reported `tools-loading` — telling
97
- // the user to re-run a command that could never succeed.
98
- const IDEA_TOOL_PREFIX = 'idea_';
99
108
 
100
109
  /**
101
- * 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` 按当前前缀集合完成。
102
125
  *
103
126
  * Grouped by the IDE's Exposed Tools page so the list stays auditable. Anything
104
127
  * listed here is stripped from every model request, even while the IDE is up.
105
128
  *
106
- * - VCS: `idea_git_status` returns porcelain codes only and
107
- * `idea_get_repositories` is covered by the native git tool, which also
108
- * handles diff/log/blame.
109
- * - 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
110
132
  * mode is off, so every routed tool is exposed directly with its full schema
111
133
  * and this dispatcher is redundant. Revisit if router-only mode is enabled.
112
134
  * - Debugger (`xdebug_*`): the Debugger MCP toolset. Rarely needed, and a real
@@ -117,73 +139,112 @@ const IDEA_TOOL_PREFIX = 'idea_';
117
139
  * - Inspection KTS MCP: inspection.kts authoring helpers (PSI tree, API docs,
118
140
  * examples, run). Also IntelliJ Platform plugin-development only.
119
141
  * - Python Environment MCP: Python interpreter detection and configuration.
120
- * Hidden; project commands run through the IDE terminal instead, which is
121
- * already launched with the IDE's real environment.
122
- * - Database data sources: connections are configured by the user in the IDE,
123
- * so the AI only reads and queries them; create/edit stay hidden.
124
- *
125
- * NOT hidden: `idea_execute_terminal_command`. Project commands must run with
126
- * the SDKs the user configured in the IDE (JDK, Node, Python, ...), so the model
127
- * uses the IDE's integrated terminal for them. `executeInShell: true` runs the
128
- * command in the user's real shell (`zsh`/`bash`), which inherits the IDE
129
- * environment — including version managers such as fnm / SDKMAN whose selection
130
- * lives in shell init and cannot be reconstructed from a handful of variables.
131
- * Requires Brave Mode in the IDE; without it the IDE asks for confirmation.
142
+ * Hidden; a project's interpreter is chosen in the IDE's own settings, and the
143
+ * model has no business reconfiguring it.
144
+ * - Database: the connection tools (`create` / `edit`) stay hidden because the
145
+ * user configures data sources in the IDE. The query tools are hidden too —
146
+ * the IDE is not this plugin's idea of a database client, and a model that
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.
155
+ * - IDE terminal: `execute_terminal_command` does NOT inherit the project's
156
+ * environment. It was exposed on the assumption that it ran with the SDKs the
157
+ * user configured in the IDE (JDK, Node, Python, ...), but measured against a
158
+ * real IDE it brings no environment a native `shell` call does not already
159
+ * have — so it is a strictly worse shell, and hiding it removes an entire
160
+ * class of "why is it using an old JDK" confusion. Which tool to use instead
161
+ * is a routing decision, and it is made in `ide-guidance.js`: commands that
162
+ * need the project's environment go through a RUN CONFIGURATION (the IDE
163
+ * launches those with exactly the configured SDK), everything else goes
164
+ * through the native shell.
132
165
  */
133
- export const HIDDEN_IDEA_TOOLS = [
166
+ export const HIDDEN_IDE_TOOL_SUFFIXES = [
134
167
  // VCS
135
- 'idea_git_status',
136
- 'idea_get_repositories',
168
+ 'git_status',
169
+ 'get_repositories',
137
170
  // Router
138
- 'idea_execute_tool',
171
+ 'execute_tool',
172
+ // IDE terminal: no advantage over the native shell (see the note above).
173
+ 'execute_terminal_command',
139
174
  // Debugger
140
- 'idea_xdebug_control_session',
141
- 'idea_xdebug_evaluate_expression',
142
- 'idea_xdebug_get_debugger_status',
143
- 'idea_xdebug_get_frame_values',
144
- 'idea_xdebug_get_stack',
145
- 'idea_xdebug_get_threads',
146
- 'idea_xdebug_get_value_by_path',
147
- 'idea_xdebug_list_breakpoints',
148
- 'idea_xdebug_remove_breakpoint',
149
- 'idea_xdebug_run_to_line',
150
- 'idea_xdebug_set_breakpoint',
151
- 'idea_xdebug_set_variable',
152
- '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',
153
188
  // Dev Kit MCP
154
- 'idea_collect_split_mode_compatibility_issues',
155
- 'idea_create_ij_module',
156
- 'idea_find_lock_requirements_usages',
157
- 'idea_find_threading_requirements_usages',
158
- 'idea_recognize_ij_module_kind',
159
- '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',
160
195
  // Inspection KTS MCP
161
- 'idea_generate_inspection_kts_api',
162
- 'idea_generate_inspection_kts_examples',
163
- 'idea_generate_psi_tree',
164
- 'idea_run_inspection_kts',
196
+ 'generate_inspection_kts_api',
197
+ 'generate_inspection_kts_examples',
198
+ 'generate_psi_tree',
199
+ 'run_inspection_kts',
165
200
  // Python Environment MCP
166
- 'idea_configure_python_interpreter',
167
- 'idea_get_python_environment',
168
- // Database data sources: the user configures them in the IDE, the AI only
169
- // reads and queries them.
170
- 'idea_create_database_connection',
171
- 'idea_edit_database_connection',
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',
172
226
  ];
173
227
 
174
228
  /**
175
- * 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
+ * 否则子项目那几份会原样送到模型面前。
176
233
  *
177
234
  * @param {Record<string, unknown> | undefined} tools
178
- * @param {readonly string[]} names
235
+ * @param {readonly string[]} suffixes
236
+ * @param {readonly string[]} prefixes 当前所有项目的工具前缀(含下划线)。
179
237
  */
180
- function removeIdeaToolDefinitions(tools, names) {
238
+ function removeIdeToolDefinitions(tools, suffixes, prefixes) {
181
239
  if (!tools || typeof tools !== 'object') return 0;
182
240
  let removed = 0;
183
- for (const name of names) {
184
- if (!name.startsWith('idea_') || !(name in tools)) continue;
185
- delete tools[name];
186
- 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
+ }
187
248
  }
188
249
  return removed;
189
250
  }
@@ -194,7 +255,7 @@ function toolIds(tools) {
194
255
  }
195
256
 
196
257
  /**
197
- * Classify an `idea_*` tool failure for the reconnect decision.
258
+ * Classify an IDE tool failure for the reconnect decision.
198
259
  *
199
260
  * It must never mistake a plain business error (a bad path, a missing file) for
200
261
  * a broken connection, so only two shapes trigger action:
@@ -210,9 +271,10 @@ function toolIds(tools) {
210
271
  * Everything else is `none`.
211
272
  *
212
273
  * @param {{ message?: unknown } | undefined} error
274
+ * @param {readonly string[]} serverNames 当前注册的 server 名(`ide` 与各子项目前缀)。
213
275
  * @returns {'project' | 'mcp' | 'none'}
214
276
  */
215
- function classifyIdeaFailure(error) {
277
+ function classifyIdeFailure(error, serverNames) {
216
278
  const message = typeof error?.message === 'string' ? error.message : '';
217
279
  // "Project not open in the IDE" comes in two shapes, both meaning the same
218
280
  // thing and both fixed by opening the project again:
@@ -246,8 +308,11 @@ function classifyIdeaFailure(error) {
246
308
  // so the text is matched on its stable prefix, and the underlying `code` is
247
309
  // accepted as a structural signal too (a wording change must not silently
248
310
  // disable the heal).
311
+ //
312
+ // serverNames 是当前注册的全部 server 名(`ide` / 各子项目前缀):错误里点名的是
313
+ // 哪一个 server,取决于失败的是哪个项目的工具,所以只能逐个匹配。
249
314
  if (
250
- message.includes(`MCP server "${IDEA_SERVER_NAME}" is not available`) ||
315
+ serverNames.some((name) => message.includes(`MCP server "${name}" is not available`)) ||
251
316
  message.includes('MCP server is not connected') ||
252
317
  message.includes('Error POSTing to endpoint') ||
253
318
  message.includes('Unable to connect')
@@ -304,22 +369,23 @@ function rewriteToolError(event, message) {
304
369
  * | 'launch-failed'
305
370
  * | 'disabled'
306
371
  * | 'manual-launch'
307
- * | 'closed',
372
+ * | 'deleted'
373
+ * | 'bad-argument'
374
+ * | 'not-configured'
375
+ * | 'invalid-subproject'
376
+ * | 'needs-ide',
308
377
  * // `manual-launch` = this platform has no launcher at all (the user must
309
378
  * // open the IDE by hand); `disabled` = the user turned launching off. Both
310
379
  * // mean "no app to open", so only `autoLaunchSupported()` tells them apart.
311
380
  * port?: number,
312
381
  * opened?: boolean,
313
- * unavailableNoticeSent?: boolean
382
+ * unavailableNoticeSent?: boolean,
383
+ * project?: { label?: string, app?: string, isRoot?: boolean },
384
+ * projects?: ReadonlyArray<{ label?: string, app?: string }>,
385
+ * message?: string
314
386
  * }} IdeCommandResult
315
387
  */
316
388
 
317
- /**
318
- * @param {unknown} value
319
- * @param {unknown} fallback
320
- */
321
- const asArray = (value, fallback) => (Array.isArray(value) && value.length > 0 ? value : fallback);
322
-
323
389
  /**
324
390
  * @param {unknown} value
325
391
  * @param {number} fallback
@@ -327,28 +393,44 @@ const asArray = (value, fallback) => (Array.isArray(value) && value.length > 0 ?
327
393
  const asPositiveNumber = (value, fallback) =>
328
394
  typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
329
395
 
330
- /** User-facing status text for a `/open-in-idea` result. */
396
+ /** 面向用户的状态文案(三个命令与 `configure_ide_project` 共用)。 */
331
397
  /** @param {IdeCommandResult} result */
332
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);
333
402
  switch (result?.status) {
334
- case 'connected':
335
- 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
+ }
336
407
  case 'tools-loading':
337
- return 'IDE MCP 已连接,但原生 IDEA 工具列表尚未完成注册。请稍后再次执行 /open-in-idea。';
338
- 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);
339
412
  return result?.opened
340
- ? '已请求打开/激活 IntelliJ IDEA 并等待 MCP 服务启动,但未检测到 MCP 服务。请在 IDEA 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-idea。'
341
- : '未检测到 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
+ }
342
416
  case 'launch-failed':
343
- return '无法拉起 IntelliJ IDEA(未找到应用或启动失败)。请确认已安装 IDEA,或手动打开后重试 /open-in-idea。';
417
+ return `无法拉起 ${nameOf(result?.project)}(未找到应用或启动失败)。请确认它已安装 —— 没装的话先装上,再重试 /open-in-idea。`;
344
418
  case 'disabled':
345
- 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。';
346
420
  case 'manual-launch':
347
- return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。';
348
- case 'closed':
349
- 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 会把选择写入配置。';
350
432
  default:
351
- return '已处理 /open-in-idea。';
433
+ return '已处理。';
352
434
  }
353
435
  }
354
436
 
@@ -376,10 +458,10 @@ export default {
376
458
  * @param {{
377
459
  * location?: unknown,
378
460
  * options?: {
379
- * ports?: number[],
380
- * openInIde?: boolean | string,
461
+ * openInIde?: boolean,
381
462
  * launchCooldownMs?: number,
382
463
  * launchMaxAttempts?: number,
464
+ * injectGuidance?: boolean,
383
465
  * mcpProbeTimeoutMs?: number,
384
466
  * mcpStartTimeoutMs?: number,
385
467
  * toolRefreshTimeoutMs?: number,
@@ -415,10 +497,19 @@ export default {
415
497
  * tool?: {
416
498
  * list?: () => Promise<ReadonlyArray<{ id?: string }>>,
417
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 }>,
418
507
  * hook?: (name: string, cb: (event: {
419
508
  * tool?: string,
420
509
  * sessionID?: string,
510
+ * input?: Record<string, unknown>,
421
511
  * status?: string,
512
+ * result?: unknown,
422
513
  * error?: unknown,
423
514
  * }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
424
515
  * },
@@ -440,9 +531,10 @@ export default {
440
531
  if (!projectPath) return;
441
532
 
442
533
  const options = ctx.options || {};
443
- const ports = asArray(options.ports, DEFAULT_PORTS);
444
534
  const injectGuidance = options.injectGuidance !== false;
445
- const ideApp = resolveIdeApp(options.openInIde);
535
+ // 自动拉起 IDE 的总开关。用哪个 IDE 由**每个项目自己的配置**决定
536
+ // (`project.app`),这个选项不再指定应用名,只决定"允不允许拉起"。
537
+ const launchEnabled = options.openInIde !== false;
446
538
  const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
447
539
  const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
448
540
  const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
@@ -453,10 +545,8 @@ export default {
453
545
  // model only acknowledges ("收到"); the user sees the clean notice via
454
546
  // metadata.displayText. false is silent.
455
547
  const feedback = options.feedback === false ? false : 'message';
456
- const guard = createLaunchGuard({
457
- cooldownMs: asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS),
458
- maxAttempts: asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS),
459
- });
548
+ const launchCooldownMs = asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS);
549
+ const launchMaxAttempts = asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS);
460
550
 
461
551
  /** @type {Array<{ dispose: () => Promise<void> | void }>} */
462
552
  let registrations = [];
@@ -464,78 +554,116 @@ export default {
464
554
  let sessionRegistration;
465
555
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
466
556
  let toolRegistration;
557
+ let toolBeforeRegistration;
558
+ let toolTransformRegistration;
559
+ /** `configure_ide_project` 的注册。 */
560
+ let configToolRegistration;
467
561
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
468
562
  let promptRegistration;
469
563
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
470
564
  let commandRegistration;
471
565
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
472
566
  let skillRegistration;
473
- let skillsRegistered = false;
567
+ /** Ids of the skills currently registered, so a sync can tell "unchanged". */
568
+ let registeredSkillIds = [];
474
569
  let disposed = false;
475
570
 
476
- // 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
477
572
  // only by a successful rebuild. It is set from the `context` hook (a request
478
573
  // that HAD the tools no longer does); the `prompt` hook also heals without
479
574
  // this latch when the instance has no registration at all.
480
575
  let needsRebuild = false;
481
- // 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
482
577
  // later request without them is a DROP of this session's own connection —
483
- // not a project that simply never connected.
578
+ // not a root that simply never connected.
484
579
  let wasServing = false;
485
580
 
486
- // Project-level mark: has the user opted this project into IDEA via
487
- // `/open-in-idea`? One plugin instance serves one project (OpenCode
488
- // activates plugins per Location), so a single boolean is the whole cache.
489
- // The durable copy lives in `ctx.storage` under `project/<projectPath>`.
490
- // The commands are the only writers: they persist first, then invalidate
491
- // 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
+ // 请求钩子就会把"还没读"当成"未配置",漏掉一次本该发生的自愈。
492
587
  const storage = ctx.storage;
493
- const markKey = `project/${projectPath}`;
494
- /** @type {boolean | undefined} undefined = a storage read is due */
495
- let markCache;
588
+ const markKey = markKeyFor(projectPath);
589
+ /** @type {import('./ide-projects.js').IdeConfig | null | undefined} */
590
+ let config;
496
591
 
497
- /** Read the durable mark into the cache; at most one storage read per invalidation. */
498
- const readMark = async () => {
499
- if (markCache !== undefined) return markCache;
592
+ /** 读一次持久化配置(最多一次 I/O),之后走内存缓存。 */
593
+ const readConfig = async () => {
594
+ if (config !== undefined) return config;
500
595
  if (!storage || typeof storage.get !== 'function') {
501
- markCache = false;
502
- return markCache;
596
+ config = null;
597
+ return config;
503
598
  }
504
599
  try {
505
- markCache = (await storage.get(markKey)) === true;
600
+ config = parseMark(await storage.get(markKey), projectPath) ?? null;
506
601
  } catch {
507
- // storage is best effort; fall back to unmarked
508
- markCache = false;
602
+ // storage is best effort;读不到就当作未配置
603
+ config = null;
509
604
  }
510
- return markCache;
605
+ return config;
511
606
  };
512
607
 
513
608
  /**
514
- * Persist the mark, then invalidate the cache so the next reader re-reads
515
- * storage. Best effort: an in-memory invalidation still applies if the write
516
- * fails.
517
- * @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
518
616
  */
519
- const setMark = async (marked) => {
617
+ const writeConfig = async (next) => {
520
618
  try {
521
- if (marked) await storage?.set?.(markKey, true);
619
+ if (next) await storage?.set?.(markKey, serializeMark(next));
522
620
  else await storage?.remove?.(markKey);
523
621
  } catch {
524
622
  // best effort
525
623
  }
526
- // Keep the in-memory cache in sync with the write (write-through), so any
527
- // later synchronous reader sees the new value immediately.
528
- markCache = marked;
529
- // The skill lifecycle follows the mark: marking the project registers the
530
- // IDEA skills, clearing the mark removes them again. Same semantics as the
531
- // reconnect behavior, so the two can never disagree.
532
- if (marked) await ensureSkills();
533
- else await removeSkills();
624
+ config = next;
625
+ // skill 生命周期跟随配置:配置在就注册 IDEA skill,删掉配置就移除。
626
+ await syncSkills();
627
+ };
628
+
629
+ /** 当前接入的全部项目(root 在前),每个带工具前缀与展示标签。 */
630
+ const currentProjects = () =>
631
+ config
632
+ ? listProjects(config).map((project) => ({
633
+ ...project,
634
+ label: project.isRoot ? '.' : project.path.slice(projectPath.length + 1),
635
+ }))
636
+ : [];
637
+
638
+ /** 当前所有项目的工具前缀(含下划线);未配置时为空。 */
639
+ const prefixes = () => (config ? toolPrefixes(config) : []);
640
+
641
+ /** 当前注册的全部 server 名(= 前缀去掉下划线)。 */
642
+ const serverNames = () => (config ? listProjects(config).map((project) => project.server) : []);
643
+
644
+ /** 这个工具名是否属于某个 IDE server(插件自己的工具不算)。 */
645
+ const isIdeToolName = (name) => {
646
+ if (PLUGIN_TOOL_NAMES.has(name)) return false;
647
+ return prefixes().some((prefix) => name.startsWith(prefix));
534
648
  };
535
649
 
536
- // Resolve the mark once, so the request hooks can read it synchronously.
537
- await readMark();
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();
538
665
 
666
+ /** 注销当前注册的全部 IDE server。 */
539
667
  const deactivateIde = async () => {
540
668
  const current = registrations.slice();
541
669
  registrations.length = 0;
@@ -549,13 +677,13 @@ export default {
549
677
  }
550
678
  };
551
679
 
552
- const waitForIdeaTools = async () => {
680
+ const waitForIdeTools = async () => {
553
681
  if (typeof ctx.tool?.list !== 'function') return false;
554
682
  const deadline = Date.now() + toolRefreshTimeoutMs;
555
683
  while (Date.now() < deadline) {
556
684
  try {
557
685
  const ids = toolIds(await ctx.tool.list());
558
- if (ids.some((id) => id.startsWith(IDEA_TOOL_PREFIX))) return true;
686
+ if (ids.some((id) => isIdeToolName(id))) return true;
559
687
  } catch {
560
688
  // The MCP catalog may still be settling; retry within the bounded wait.
561
689
  }
@@ -564,6 +692,14 @@ export default {
564
692
  return false;
565
693
  };
566
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
+
567
703
  const refreshIdeTools = async () => {
568
704
  if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
569
705
  // Reconcile the MCP servers above, then replay the tool registry so the
@@ -571,13 +707,30 @@ export default {
571
707
  // Without this, a session that already captured a snapshot keeps the old
572
708
  // tool list until the session is reopened.
573
709
  if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
574
- return waitForIdeaTools();
710
+ const ready = await waitForIdeTools();
711
+ // The catalog decides which of the shipped skills may be offered, so this
712
+ // is the first moment that question has an answer; see `syncSkills`.
713
+ await syncSkills();
714
+ return ready;
575
715
  };
576
716
 
577
- const activateIde = async (port) => {
578
- 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) => {
579
727
  const mcpRegistration = await ctx.mcp.transform((editor) => {
580
- 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
+ }
581
734
  });
582
735
  registrations = [mcpRegistration];
583
736
  return refreshIdeTools();
@@ -586,23 +739,32 @@ export default {
586
739
  const reconcileOnce = async ({ probeIde = true } = {}) => {
587
740
  if (disposed) return false;
588
741
 
589
- // Manual: only `/open-in-idea` probes the IDE. Setup calls this with
590
- // `probeIde: false` so it never connects until the command asks.
591
- const port = probeIde ? await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }) : undefined;
592
- const isJetBrains = markCache === true || port !== undefined;
593
- if (!isJetBrains) {
742
+ // 未配置:没有任何项目要接入。
743
+ if (!config) {
744
+ await deactivateIde();
745
+ return false;
746
+ }
747
+ // Manual: only the commands probe the IDE. Setup calls this with
748
+ // `probeIde: false` so it never connects until a command asks.
749
+ if (!probeIde) {
594
750
  await deactivateIde();
595
751
  return false;
596
752
  }
597
753
 
598
- // Always tear the server down and register it again. OpenCode's reconcile
754
+ const bindings = [];
755
+ for (const project of currentProjects()) {
756
+ const port = await portForProject(project);
757
+ if (port !== undefined) bindings.push({ project, port });
758
+ }
759
+
760
+ // Always tear the servers down and register them again. OpenCode's reconcile
599
761
  // SKIPS a re-`set` whose config is unchanged, so re-registering without a
600
- // teardown would leave a dropped server in place forever. `/open-in-idea`
601
- // and the request hooks are rare; rebuilding costs one restart, which is
602
- // 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.
603
765
  await deactivateIde();
604
- if (port === undefined) return false;
605
- return activateIde(port);
766
+ if (bindings.length === 0) return false;
767
+ return activateIde(bindings);
606
768
  };
607
769
 
608
770
  // Serialize reconciles so overlapping callers can never register/activate
@@ -624,26 +786,7 @@ export default {
624
786
  const reconcile = (options) => enqueue(() => reconcileOnce(options));
625
787
 
626
788
  /**
627
- * `open -a` only requests activation/opening. A cold IDE needs time before
628
- * its bundled MCP server listens, so poll until the endpoint appears. This
629
- * is the step that makes a single `/open-in-idea` enough on a cold start.
630
- *
631
- * @returns {Promise<number | undefined>}
632
- */
633
- const waitForIdeServer = async () => {
634
- const deadline = Date.now() + mcpStartTimeoutMs;
635
- while (Date.now() < deadline) {
636
- const port = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
637
- if (port !== undefined) return port;
638
- const remaining = deadline - Date.now();
639
- if (remaining <= 0) break;
640
- await new Promise((resolve) => setTimeout(resolve, Math.min(MCP_START_POLL_MS, remaining)));
641
- }
642
- return undefined;
643
- };
644
-
645
- /**
646
- * Is `projectPath` actually OPEN in the IDE that serves `port`?
789
+ * Is `path` actually OPEN in the IDE that serves `port`?
647
790
  *
648
791
  * A live endpoint only means the IDE is RUNNING — it may have a different
649
792
  * project open (or sit on the Welcome screen), while everything we do is
@@ -651,121 +794,191 @@ export default {
651
794
  * call: the server resolves the project from `IJ_MCP_SERVER_PROJECT_PATH`
652
795
  * BEFORE running the tool, and for a project that is not open it fails with
653
796
  * "Unable to determine the target project ..." — the same shape
654
- * `classifyIdeaFailure` maps to `project`. Any other outcome (a real result,
797
+ * `classifyIdeFailure` maps to `project`. Any other outcome (a real result,
655
798
  * or an unrelated tool error) means the project WAS resolved, i.e. open.
656
799
  *
657
800
  * A transport failure (`undefined`) counts as "not confirmed open", so the
658
801
  * caller still asks the IDE to open the project: `open -a` is idempotent,
659
802
  * and the only cost is a redundant focus.
660
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
+ *
661
822
  * @param {number} port
823
+ * @param {string} path
662
824
  */
663
- const isProjectOpen = async (port) => {
664
- 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);
665
829
  if (!result) return false;
666
830
  if (result.isError !== true) return true;
667
831
  const text = (result.content ?? [])
668
832
  .filter((part) => part?.type === 'text' && typeof part.text === 'string')
669
833
  .map((part) => part.text)
670
834
  .join('\n');
671
- 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;
672
889
  };
673
890
 
674
- // Re-entrant: repeat invocations share one open request so the IDE is not
675
- // spawned twice while the application is starting.
676
- /** @type {Promise<IdeCommandResult> | undefined} */
677
- let launchInFlight;
678
- let launchNoticeSent = false;
679
891
  /**
892
+ * 确保 `project` 的 IDE 正开着、并且打开着这个项目;返回它的端口。
893
+ *
894
+ * @param {{ path: string, server: string, app: string, label: string, isRoot: boolean }} project
680
895
  * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
681
896
  * @returns {Promise<IdeCommandResult>}
682
897
  */
683
- const ensureIde = ({ onUnavailable } = {}) => {
684
- if (launchInFlight) return launchInFlight;
685
- launchNoticeSent = false;
898
+ const ensureProject = (project, { onUnavailable } = {}) => {
899
+ const state = launchStateFor(project.path);
900
+ if (state.inFlight) return state.inFlight;
686
901
  const reportUnavailable = async (opened) => {
687
- if (launchNoticeSent || typeof onUnavailable !== 'function') return;
902
+ if (state.noticeSent || typeof onUnavailable !== 'function') return;
688
903
  // Fast-fail: notify as soon as the endpoint is known to be missing so
689
904
  // the user can enable MCP + Brave Mode and retry the command.
690
- const delivered = await onUnavailable({ status: 'mcp-unavailable', opened });
691
- if (delivered) launchNoticeSent = true;
905
+ const delivered = await onUnavailable({ status: 'mcp-unavailable', opened, project });
906
+ if (delivered) state.noticeSent = true;
692
907
  };
693
908
  const run = (async () => {
694
- const serving = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
909
+ // 已经开着并且就打开着这个项目:什么都不用做。
910
+ const serving = await portForProject(project).catch(() => undefined);
695
911
  if (serving !== undefined) {
696
- guard.reset();
697
- launchNoticeSent = false;
698
- // The endpoint being up proves only that the IDE is RUNNING — it may
699
- // have a DIFFERENT project open. Ask the IDE whether THIS project is
700
- // open, and only request an open when it is not: for an already-open
701
- // project the IDE runs a short reopen-then-dispose cycle on a
702
- // redundant open event, so skipping it is a real saving.
703
- if (ideApp && !(await isProjectOpen(serving))) {
704
- await openInIde(projectPath, { app: ideApp });
705
- }
706
- return { status: 'connected', port: serving, unavailableNoticeSent: false };
912
+ state.guard.reset();
913
+ state.noticeSent = false;
914
+ return { status: 'connected', port: serving, project, unavailableNoticeSent: false };
707
915
  }
708
- // No app to open. Either the user turned launching off, or this platform
709
- // has no launcher — say which, so the notice is actionable.
710
- if (!ideApp) {
711
- return { status: autoLaunchSupported() ? 'disabled' : 'manual-launch' };
712
- }
713
- if (guard.exhausted()) {
714
- await reportUnavailable(false);
715
- 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 };
716
920
  }
717
- if (guard.coolingDown()) {
921
+ if (state.guard.exhausted() || state.guard.coolingDown()) {
718
922
  // There is no reason to wait here: MCP is disabled/not ready, and
719
- // the user must enable it in IDEA before retrying the command.
923
+ // the user must enable it in the IDE before retrying the command.
720
924
  await reportUnavailable(false);
721
- return { status: 'mcp-unavailable', opened: false, unavailableNoticeSent: launchNoticeSent };
925
+ return { status: 'mcp-unavailable', opened: false, project, unavailableNoticeSent: state.noticeSent };
722
926
  }
723
- guard.markSpawn();
724
- const launched = await openInIde(projectPath, { app: ideApp });
725
- if (!launched) return { status: 'launch-failed' };
726
- // `open -a` only requests activation/opening. A cold IDE needs time to
727
- // start its bundled MCP server, so wait for the endpoint instead of
728
- // reporting failure immediately.
729
- 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);
730
931
  if (started !== undefined) {
731
- guard.reset();
732
- launchNoticeSent = false;
733
- return { status: 'connected', port: started, unavailableNoticeSent: false };
932
+ state.guard.reset();
933
+ state.noticeSent = false;
934
+ return { status: 'connected', port: started, project, unavailableNoticeSent: false };
734
935
  }
735
936
  await reportUnavailable(true);
736
- return { status: 'mcp-unavailable', opened: true, unavailableNoticeSent: launchNoticeSent };
937
+ return { status: 'mcp-unavailable', opened: true, project, unavailableNoticeSent: state.noticeSent };
737
938
  })();
738
- launchInFlight = run;
939
+ state.inFlight = run;
739
940
  void run
740
941
  .catch(() => {})
741
942
  .then(() => {
742
- if (launchInFlight === run) launchInFlight = undefined;
943
+ if (state.inFlight === run) state.inFlight = undefined;
743
944
  });
744
945
  return run;
745
946
  };
746
947
 
747
948
  /**
748
- * The whole `/open-in-idea` flow, shared by the command and the auto-recovery
749
- * 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",而是"连一组",彼此并行。
750
954
  *
751
955
  * @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
752
956
  * @returns {Promise<IdeCommandResult>}
753
957
  */
754
958
  const runOpenInIdea = async ({ onUnavailable } = {}) => {
755
- const result = await ensureIde({ onUnavailable });
756
- 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
+ }
757
970
  let toolsReady = false;
758
971
  try {
759
- // Do not repeat the expensive probe when the preflight already found no
760
- // MCP endpoint. The command will retry after the user enables MCP +
761
- // Brave Mode.
762
- toolsReady = (await reconcile({ probeIde: port !== undefined })) === true;
972
+ // 重新探测一次并整体注册:ensureProject 只保证 IDE 就绪,注册要按"当前真正
973
+ // 打开着哪些项目"重来一遍,这样 `/open-in-idea` 每次都是同一套结果。
974
+ toolsReady = (await reconcile({ probeIde: true })) === true;
763
975
  } catch {
764
976
  // capability load is best effort; feedback still matters
765
977
  }
766
- return result.status === 'connected' && !toolsReady
767
- ? { ...result, status: 'tools-loading' }
768
- : 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 };
769
982
  };
770
983
 
771
984
  /**
@@ -783,16 +996,15 @@ export default {
783
996
  /** @type {Promise<void> | undefined} */
784
997
  let reviveInFlight;
785
998
  const reviveIde = () => {
786
- // The mark gates EVERY reconnect, not just the ones started from a user
787
- // message: a background attempt must never resurrect the IDE for a project
788
- // the user closed with `/close-in-idea`.
789
- if (disposed || markCache !== true) return Promise.resolve();
999
+ // 配置 gate 住每一次重连,包括后台发起的那些:后台尝试绝不能把用户已经用
1000
+ // `/delete-in-ide` 删掉的项目重新拉起来。
1001
+ if (disposed || !config) return Promise.resolve();
790
1002
  if (reviveInFlight) return reviveInFlight;
791
1003
  const run = (async () => {
792
1004
  try {
793
1005
  const result = await runOpenInIdea();
794
- // `/close-in-idea` can land while this was in flight; it wins.
795
- if (markCache !== true) {
1006
+ // `/delete-in-ide` 可能在这期间落地;它赢。
1007
+ if (!config) {
796
1008
  await deactivateIde();
797
1009
  return;
798
1010
  }
@@ -810,7 +1022,7 @@ export default {
810
1022
  return run;
811
1023
  };
812
1024
 
813
- // 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.
814
1026
  await reconcile({ probeIde: false });
815
1027
 
816
1028
  const sendFeedback = async (input, result) => {
@@ -830,57 +1042,262 @@ export default {
830
1042
  }
831
1043
  };
832
1044
 
1045
+ /**
1046
+ * The IDEA tools this project's IDE is serving right now.
1047
+ *
1048
+ * Empty is the honest answer for "does the IDE serve X?" before anything has
1049
+ * connected — and it fails that way on error too, because the alternative
1050
+ * (assuming a tool exists) is the one mistake that cannot be walked back
1051
+ * after the model has seen it.
1052
+ *
1053
+ * @returns {Promise<Set<string>>}
1054
+ */
1055
+ const servedIdeaToolNames = async () => {
1056
+ if (typeof ctx.tool?.list !== 'function') return new Set();
1057
+ try {
1058
+ return new Set(toolIds(await ctx.tool.list()));
1059
+ } catch {
1060
+ return new Set();
1061
+ }
1062
+ };
1063
+
1064
+ /** Same ids in the same order — `readSkills()` sorts, so the order is stable. */
1065
+ const isSameSkillSet = (skills, ids) =>
1066
+ skills.length === ids.length && skills.every((skill, index) => skill.id === ids[index]);
1067
+
833
1068
  // The shipped skills are static knowledge (`skills/<id>/SKILL.md`), e.g. the
834
- // run-configuration workflow: where `.run/*.run.xml` live, how to bootstrap a
835
- // schema from a real example, and how to verify a write. Registered only for
836
- // a project that has been marked as an IDEA project, so unrelated projects
837
- // never see an IDEA-specific skill.
1069
+ // standalone-mode discipline (every app starts from an IDEA run
1070
+ // configuration, and starting one is the user's call, so the skill has to be
1071
+ // there to write the project's own rules). They pass two independent gates,
1072
+ // because a skill can be wrong twice over:
1073
+ //
1074
+ // * the project config — an unrelated project never sees an IDE skill;
1075
+ // * the tools the IDE actually serves — a skill that teaches a call the
1076
+ // model cannot make is not knowledge, it is a failed call waiting to
1077
+ // happen. `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.
1082
+ //
1083
+ // The catalog is unknown at setup: the config is read from storage offline,
1084
+ // before anything has connected to the IDE. So this is a reconcile rather
1085
+ // than a one-shot — every catalog refresh re-runs it, and it offers exactly
1086
+ // what is justified right now. Until the IDE has answered, the gated skills
1087
+ // simply do not exist.
838
1088
  //
839
1089
  // Deliberately NOT wrapped in a catch: these are core features of the plugin,
840
1090
  // so a rejected definition must fail loudly. Swallowing it would ship a
841
1091
  // silently incomplete plugin — which is exactly how 0.0.6 lost
842
1092
  // `/open-in-idea` without anyone noticing until the command was gone.
843
- const ensureSkills = async () => {
844
- if (skillsRegistered || typeof ctx.skill?.transform !== 'function') return;
845
- skillsRegistered = true;
846
- // Read the documents before the transform: callbacks must stay synchronous.
847
- const skills = readSkills();
1093
+ const syncSkillsOnce = async () => {
1094
+ if (typeof ctx.skill?.transform !== 'function') return;
1095
+
1096
+ const served = await servedIdeaToolNames();
1097
+ const wanted = (config ? readSkills() : [])
1098
+ .filter(({ requires }) =>
1099
+ requires.every((suffix) => prefixes().some((prefix) => served.has(`${prefix}${suffix}`))),
1100
+ )
1101
+ .map(({ skill }) => skill);
1102
+ if (isSameSkillSet(wanted, registeredSkillIds)) return;
1103
+
1104
+ // Tear down and register again, the same way the MCP server is
1105
+ // re-registered: a transform that only ever adds would leave a stale skill
1106
+ // behind, and one that only removes could never bring it back.
1107
+ registeredSkillIds = [];
1108
+ const previous = skillRegistration;
1109
+ skillRegistration = undefined;
1110
+ if (previous) {
1111
+ try {
1112
+ await previous.dispose();
1113
+ } catch {
1114
+ // best effort
1115
+ }
1116
+ }
1117
+ if (wanted.length === 0) return;
848
1118
  skillRegistration = await ctx.skill.transform((editor) => {
849
- for (const skill of skills) editor.add(skill);
1119
+ for (const skill of wanted) editor.add(skill);
850
1120
  });
1121
+ registeredSkillIds = wanted.map((skill) => skill.id);
851
1122
  };
852
- /** Remove the IDEA skills again when the project is unmarked. */
853
- const removeSkills = async () => {
854
- if (!skillsRegistered || !skillRegistration) return;
855
- skillsRegistered = false;
856
- const registration = skillRegistration;
857
- skillRegistration = undefined;
1123
+
1124
+ // Serialized like the reconciler, and for the same reason: one call disposes
1125
+ // a registration before it makes the next, so two overlapping calls would
1126
+ // leak one of them. That overlap is easy to reach — `/delete-in-ide` can land
1127
+ // while a reconnect is still in flight. With the chain in place, callers may
1128
+ // fire this without awaiting.
1129
+ let skillSyncChain = Promise.resolve();
1130
+ const syncSkills = () => {
1131
+ const run = () => syncSkillsOnce();
1132
+ const next = skillSyncChain.then(run, run);
1133
+ skillSyncChain = next.then(
1134
+ () => {},
1135
+ () => {},
1136
+ );
1137
+ return next;
1138
+ };
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).
1142
+ await syncSkills();
1143
+
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');
858
1190
  try {
859
- await registration.dispose();
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
+ });
860
1201
  } catch {
861
1202
  // best effort
862
1203
  }
863
1204
  };
864
- // A project marked in an earlier run gets its skills at setup; a project
865
- // marked for the first time gets them when `/open-in-idea` runs (both paths
866
- // go through `setMark` / this setup hook).
867
- if (markCache === true) await ensureSkills();
868
-
869
- // `/open-in-idea`: manual, re-entrant trigger that also loads the IDE
870
- // capabilities immediately, and marks the project so later messages keep the
871
- // IDE alive. `/close-in-idea` removes that mark. Both persist through
872
- // `ctx.storage` and invalidate the in-memory cache.
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"。
873
1276
  if (typeof ctx.command?.transform === 'function') {
874
1277
  commandRegistration = await ctx.command.transform((editor) => {
875
1278
  editor.add({
876
1279
  name: 'open-in-idea',
877
- description: '用 IntelliJ IDEA 打开当前项目,并即时加载 IDE MCP 与项目 SDK 环境',
1280
+ description: '接入当前项目:root 及其全部已登记子项目,各自用配置好的 IDE 打开',
878
1281
  execute: async (input) => {
879
- // Mark the project before running: the mark means "this is an IDEA
880
- // project", so it is persisted as soon as the user asks, whether or
881
- // not the IDE happens to be reachable right now. `setMark` also
882
- // registers the IDEA skills.
883
- 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
+ }
884
1301
  /** @type {boolean} */
885
1302
  let unavailableNoticeSent = false;
886
1303
  const result = await runOpenInIdea({
@@ -898,39 +1315,79 @@ export default {
898
1315
  },
899
1316
  });
900
1317
  editor.add({
901
- name: 'close-in-idea',
902
- 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 与子项目的登记',
903
1350
  execute: async (input) => {
904
- await setMark(false);
905
- // Unregister the IDE MCP server. Reuses the serialized reconcile, so
906
- // a concurrent rebuild (an in-flight `/open-in-idea`, or a
907
- // request-time heal) cannot re-register the server after the mark is
908
- // gone.
1351
+ await writeConfig(null);
1352
+ // 注销全部 server。走串行化的 reconcile,所以在飞的重建(另一个
1353
+ // `/open-in-idea`、请求期自愈)不会在配置已删之后再注册回来。
909
1354
  await reconcile({ probeIde: false });
910
- await sendFeedback(input, { status: 'closed' });
1355
+ await sendFeedback(input, { status: 'deleted' });
911
1356
  },
912
1357
  });
913
1358
  });
914
1359
  }
915
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
+
916
1372
  // Hide the tools the model must not see, and point it at the rest. This hook
917
1373
  // runs for every agent-loop step (including tool-driven continuations), so
918
1374
  // it stays a cheap, I/O-free function of the request: it checks the cached
919
- // 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.
920
1376
  //
921
1377
  // `event.tools` is this request's tool snapshot, and OpenCode's own registry
922
- // 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
923
1379
  // (`stopServer` publishes `mcp.tools.changed`, which the registry turns into
924
1380
  // a reload). So the snapshot is also the honest answer to "is the IDE
925
1381
  // serving?".
926
1382
  if (typeof ctx.session?.hook === 'function') {
927
1383
  sessionRegistration = await ctx.session.hook('context', (event) => {
928
1384
  const tools = event?.tools;
929
- 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));
930
1387
  if (serving) {
931
1388
  wasServing = true;
932
- removeIdeaToolDefinitions(tools, HIDDEN_IDEA_TOOLS);
933
- } else if (wasServing && tools !== undefined && markCache === true) {
1389
+ removeIdeToolDefinitions(tools, HIDDEN_IDE_TOOL_SUFFIXES, prefixes());
1390
+ } else if (wasServing && tools !== undefined && config) {
934
1391
  // This session HAD the tools and this request does not. Latch a
935
1392
  // rebuild here, where "is the IDE serving THIS request?" is a fact: a
936
1393
  // failed reconnect can tear the registration down without anything else
@@ -946,31 +1403,43 @@ export default {
946
1403
  // need one more message before anything happened.
947
1404
  void reviveIde();
948
1405
  }
949
- if (markCache === true || serving) {
950
- appendIdeRecoveryGuidance(event?.system, { marked: markCache === true, serving });
1406
+ if (config || serving) {
1407
+ appendIdeRecoveryGuidance(event?.system, { marked: Boolean(config), serving });
951
1408
  }
1409
+ // A skill follows its tools in both directions, and this is the one place
1410
+ // that sees the answer change WITHOUT a reconnect: a request that has
1411
+ // lost the enhance tools must not keep the skill that drives them. Fired,
1412
+ // not awaited — the hook is synchronous, and the chain in `syncSkills`
1413
+ // makes that safe. `refreshIdeTools` covers the other direction.
1414
+ //
1415
+ // Skipped when there is nothing it could change, so a project that never
1416
+ // touched the IDE does not pay a catalog read on every request.
1417
+ if (config || registeredSkillIds.length > 0) void syncSkills();
952
1418
  if (!injectGuidance) return;
953
1419
  // Guidance promises tools the model can call, so it only goes out when
954
1420
  // they are actually in this request.
955
1421
  if (!serving) return;
956
- appendIdeGuidance(event?.system, projectPath);
1422
+ // 逐项目注入:每个项目那一节只写它自己的前缀,模型不需要自己换算。
1423
+ // run-lifecycle 工具(来自配套插件 `idea-mcp-enhance`)也按每个项目分别判断,
1424
+ // 所以一个项目装了 enhance、另一个没装时,两节的内容本来就不一样。
1425
+ appendIdeGuidance(event?.system, currentProjects(), { tools });
957
1426
  });
958
1427
 
959
1428
  // 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
960
- // **本次请求**里就带上 `idea_*` 工具,没有"先回一句正在重连、下一条才行"
1429
+ // **本次请求**里就带上 IDE 工具,没有"先回一句正在重连、下一条才行"
961
1430
  // 的空窗。
962
1431
  //
963
1432
  // 两种"需要重连"的形状都从这里进:
964
1433
  // * `needsRebuild` — 之前的请求已经证明工具没了;
965
- // * `registrations.length === 0` — 本插件实例压根没有 `idea` 服务器,这
1434
+ // * `registrations.length === 0` — 本插件实例压根没有注册任何 IDE server,这
966
1435
  // 正是 OpenCode 回收 Location 服务(LayerMap idleTimeToLive = 60 分钟)
967
1436
  // 或进程重启后新实例的样子。setup 故意不连接,没有这一步会话就会永久
968
1437
  // 没有工具,只能靠用户手敲 `/open-in-idea`。
969
1438
  //
970
1439
  // 只探测、不启动 IDE:走 `reconcile({ probeIde: true })`,而不是会 `open -a`
971
- // 的 `reviveIde()` —— 一条用户消息不该把 IDEA 拉起来。
1440
+ // 的 `reviveIde()` —— 一条用户消息不该把 IDE 拉起来。
972
1441
  promptRegistration = await ctx.session.hook('prompt', async (event) => {
973
- if (disposed || markCache !== true) return;
1442
+ if (disposed || !config) return;
974
1443
  const fresh = registrations.length === 0;
975
1444
  if (!needsRebuild && !fresh) return;
976
1445
  // Never react to the plugin's own notices, or recovery would feed on
@@ -988,7 +1457,28 @@ export default {
988
1457
  if (registrations.length > 0) needsRebuild = false;
989
1458
  });
990
1459
 
991
- // The only self-initiated action: when an `idea_*` call fails in a way
1460
+ // The IDE's tool-catalog policy, in two halves that must stay in sync:
1461
+ // an argument this plugin always overwrites must not also be advertised,
1462
+ // or the model sets it, reasons about it, and is wrong. The schema half
1463
+ // runs first, so a request never sees a knob that is already decided; see
1464
+ // `mcp/idea-tool-defaults.js` for the measurements and for why this is a
1465
+ // narrow exception to "never touch tool definitions".
1466
+ //
1467
+ // Both halves are deliberately unconditional on the project config: they are
1468
+ // properties of the tool call, not of whether this project is IDE-connected.
1469
+ if (typeof ctx.tool?.transform === 'function') {
1470
+ toolTransformRegistration = await ctx.tool.transform((editor) => {
1471
+ hideForcedArguments(editor, prefixes());
1472
+ });
1473
+ }
1474
+ if (typeof ctx.tool?.hook === 'function') {
1475
+ toolBeforeRegistration = await ctx.tool.hook('execute.before', (event) => {
1476
+ if (typeof event?.tool !== 'string') return;
1477
+ applyIdeToolDefaults(event.tool, event.input, prefixes());
1478
+ });
1479
+ }
1480
+
1481
+ // The only self-initiated action: when an IDE tool call fails in a way
992
1482
  // that means the IDE MCP is not usable, run the open flow again. The
993
1483
  // failure is classified — a plain business error (a bad path, a missing
994
1484
  // file) never triggers a reconnect, only "the MCP is not available" or
@@ -996,9 +1486,9 @@ export default {
996
1486
  if (typeof ctx.tool?.hook === 'function') {
997
1487
  toolRegistration = await ctx.tool.hook('execute.after', async (event) => {
998
1488
  if (event?.status !== 'error') return;
999
- if (typeof event?.tool !== 'string' || !event.tool.startsWith('idea_')) return;
1000
- if (!(await readMark())) return;
1001
- 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());
1002
1492
  if (kind === 'none') return;
1003
1493
  if (kind === 'project') {
1004
1494
  // The IDE is up and answering; only the PROJECT is missing. That is
@@ -1007,9 +1497,16 @@ export default {
1007
1497
  // nothing to call, and falling through would report "reconnected"
1008
1498
  // and send the model round after round after a call that can only
1009
1499
  // fail. Leave the IDE's own message, which names the real cause.
1010
- if (!ideApp) return;
1011
- await openInIde(projectPath, { app: ideApp });
1012
- 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
+ );
1013
1510
  return;
1014
1511
  }
1015
1512
  const result = await runOpenInIdea();
@@ -1035,6 +1532,27 @@ export default {
1035
1532
  // best effort
1036
1533
  }
1037
1534
  }
1535
+ if (toolBeforeRegistration) {
1536
+ try {
1537
+ await toolBeforeRegistration.dispose();
1538
+ } catch {
1539
+ // best effort
1540
+ }
1541
+ }
1542
+ if (toolTransformRegistration) {
1543
+ try {
1544
+ await toolTransformRegistration.dispose();
1545
+ } catch {
1546
+ // best effort
1547
+ }
1548
+ }
1549
+ if (configToolRegistration) {
1550
+ try {
1551
+ await configToolRegistration.dispose();
1552
+ } catch {
1553
+ // best effort
1554
+ }
1555
+ }
1038
1556
  if (promptRegistration) {
1039
1557
  try {
1040
1558
  await promptRegistration.dispose();