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/README.md +341 -170
- package/package.json +9 -1
- package/skills/idea-standalone/SKILL.md +39 -89
- package/skills/idea-standalone/assets/AGENTS.template.md +66 -0
- package/src/ide-guidance.js +141 -94
- package/src/ide-guidance.template.md +118 -0
- package/src/ide-launcher.js +87 -27
- package/src/ide-projects.js +262 -0
- package/src/mcp/enhance.js +71 -0
- package/src/mcp/idea-tool-defaults.js +170 -0
- package/src/mcp/idea.js +8 -74
- package/src/plugin.js +861 -343
- package/src/skill/loader.js +45 -8
- package/src/template.js +94 -0
- package/skills/idea-run-config/SKILL.md +0 -118
package/src/plugin.js
CHANGED
|
@@ -1,42 +1,48 @@
|
|
|
1
1
|
// opencode-idea — OpenCode plugin entry.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// IDE, and loads the IDE MCP + guidance on demand.
|
|
7
|
+
// * root → `ide` 与 IDE 产品无关,root 用谁打开都叫它
|
|
8
|
+
// * 子项目 → 目录名 `modelserver` → `modelserver_*`
|
|
19
9
|
//
|
|
20
|
-
//
|
|
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
|
-
//
|
|
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
|
|
31
|
-
// no
|
|
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
|
|
39
|
-
// * the `execute.after` hook, when an
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
-
*
|
|
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: `
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
121
|
-
*
|
|
122
|
-
* - Database
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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
|
|
166
|
+
export const HIDDEN_IDE_TOOL_SUFFIXES = [
|
|
134
167
|
// VCS
|
|
135
|
-
'
|
|
136
|
-
'
|
|
168
|
+
'git_status',
|
|
169
|
+
'get_repositories',
|
|
137
170
|
// Router
|
|
138
|
-
'
|
|
171
|
+
'execute_tool',
|
|
172
|
+
// IDE terminal: no advantage over the native shell (see the note above).
|
|
173
|
+
'execute_terminal_command',
|
|
139
174
|
// Debugger
|
|
140
|
-
'
|
|
141
|
-
'
|
|
142
|
-
'
|
|
143
|
-
'
|
|
144
|
-
'
|
|
145
|
-
'
|
|
146
|
-
'
|
|
147
|
-
'
|
|
148
|
-
'
|
|
149
|
-
'
|
|
150
|
-
'
|
|
151
|
-
'
|
|
152
|
-
'
|
|
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
|
-
'
|
|
155
|
-
'
|
|
156
|
-
'
|
|
157
|
-
'
|
|
158
|
-
'
|
|
159
|
-
'
|
|
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
|
-
'
|
|
162
|
-
'
|
|
163
|
-
'
|
|
164
|
-
'
|
|
196
|
+
'generate_inspection_kts_api',
|
|
197
|
+
'generate_inspection_kts_examples',
|
|
198
|
+
'generate_psi_tree',
|
|
199
|
+
'run_inspection_kts',
|
|
165
200
|
// Python Environment MCP
|
|
166
|
-
'
|
|
167
|
-
'
|
|
168
|
-
// Database
|
|
169
|
-
//
|
|
170
|
-
'
|
|
171
|
-
'
|
|
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
|
|
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[]}
|
|
235
|
+
* @param {readonly string[]} suffixes
|
|
236
|
+
* @param {readonly string[]} prefixes 当前所有项目的工具前缀(含下划线)。
|
|
179
237
|
*/
|
|
180
|
-
function
|
|
238
|
+
function removeIdeToolDefinitions(tools, suffixes, prefixes) {
|
|
181
239
|
if (!tools || typeof tools !== 'object') return 0;
|
|
182
240
|
let removed = 0;
|
|
183
|
-
for (const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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
|
|
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 "${
|
|
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
|
-
* | '
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 已连接,但原生
|
|
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
|
-
?
|
|
341
|
-
:
|
|
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
|
|
417
|
+
return `无法拉起 ${nameOf(result?.project)}(未找到应用或启动失败)。请确认它已安装 —— 没装的话先装上,再重试 /open-in-idea。`;
|
|
344
418
|
case 'disabled':
|
|
345
|
-
return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开
|
|
419
|
+
return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
|
|
346
420
|
case 'manual-launch':
|
|
347
|
-
return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开
|
|
348
|
-
case '
|
|
349
|
-
return '
|
|
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 '
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
457
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
578
|
+
// not a root that simply never connected.
|
|
484
579
|
let wasServing = false;
|
|
485
580
|
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
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 =
|
|
494
|
-
/** @type {
|
|
495
|
-
let
|
|
588
|
+
const markKey = markKeyFor(projectPath);
|
|
589
|
+
/** @type {import('./ide-projects.js').IdeConfig | null | undefined} */
|
|
590
|
+
let config;
|
|
496
591
|
|
|
497
|
-
/**
|
|
498
|
-
const
|
|
499
|
-
if (
|
|
592
|
+
/** 读一次持久化配置(最多一次 I/O),之后走内存缓存。 */
|
|
593
|
+
const readConfig = async () => {
|
|
594
|
+
if (config !== undefined) return config;
|
|
500
595
|
if (!storage || typeof storage.get !== 'function') {
|
|
501
|
-
|
|
502
|
-
return
|
|
596
|
+
config = null;
|
|
597
|
+
return config;
|
|
503
598
|
}
|
|
504
599
|
try {
|
|
505
|
-
|
|
600
|
+
config = parseMark(await storage.get(markKey), projectPath) ?? null;
|
|
506
601
|
} catch {
|
|
507
|
-
// storage is best effort
|
|
508
|
-
|
|
602
|
+
// storage is best effort;读不到就当作未配置
|
|
603
|
+
config = null;
|
|
509
604
|
}
|
|
510
|
-
return
|
|
605
|
+
return config;
|
|
511
606
|
};
|
|
512
607
|
|
|
513
608
|
/**
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
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
|
|
617
|
+
const writeConfig = async (next) => {
|
|
520
618
|
try {
|
|
521
|
-
if (
|
|
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
|
-
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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.
|
|
601
|
-
//
|
|
602
|
-
//
|
|
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 (
|
|
605
|
-
return activateIde(
|
|
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
|
-
*
|
|
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
|
-
* `
|
|
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,
|
|
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
|
|
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
|
|
684
|
-
|
|
685
|
-
|
|
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 (
|
|
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)
|
|
905
|
+
const delivered = await onUnavailable({ status: 'mcp-unavailable', opened, project });
|
|
906
|
+
if (delivered) state.noticeSent = true;
|
|
692
907
|
};
|
|
693
908
|
const run = (async () => {
|
|
694
|
-
|
|
909
|
+
// 已经开着并且就打开着这个项目:什么都不用做。
|
|
910
|
+
const serving = await portForProject(project).catch(() => undefined);
|
|
695
911
|
if (serving !== undefined) {
|
|
696
|
-
guard.reset();
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
//
|
|
709
|
-
|
|
710
|
-
if (!
|
|
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
|
|
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:
|
|
925
|
+
return { status: 'mcp-unavailable', opened: false, project, unavailableNoticeSent: state.noticeSent };
|
|
722
926
|
}
|
|
723
|
-
guard.markSpawn();
|
|
724
|
-
const launched = await openInIde(
|
|
725
|
-
if (!launched) return { status: 'launch-failed' };
|
|
726
|
-
|
|
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
|
-
|
|
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:
|
|
937
|
+
return { status: 'mcp-unavailable', opened: true, project, unavailableNoticeSent: state.noticeSent };
|
|
737
938
|
})();
|
|
738
|
-
|
|
939
|
+
state.inFlight = run;
|
|
739
940
|
void run
|
|
740
941
|
.catch(() => {})
|
|
741
942
|
.then(() => {
|
|
742
|
-
if (
|
|
943
|
+
if (state.inFlight === run) state.inFlight = undefined;
|
|
743
944
|
});
|
|
744
945
|
return run;
|
|
745
946
|
};
|
|
746
947
|
|
|
747
948
|
/**
|
|
748
|
-
*
|
|
749
|
-
*
|
|
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
|
|
756
|
-
|
|
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
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
:
|
|
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
|
-
//
|
|
787
|
-
//
|
|
788
|
-
|
|
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
|
-
// `/
|
|
795
|
-
if (
|
|
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
|
|
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
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
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
|
|
844
|
-
if (
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
const
|
|
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
|
|
1119
|
+
for (const skill of wanted) editor.add(skill);
|
|
850
1120
|
});
|
|
1121
|
+
registeredSkillIds = wanted.map((skill) => skill.id);
|
|
851
1122
|
};
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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: '
|
|
1280
|
+
description: '接入当前项目:root 及其全部已登记子项目,各自用配置好的 IDE 打开',
|
|
878
1281
|
execute: async (input) => {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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: '
|
|
902
|
-
description: '
|
|
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
|
|
905
|
-
//
|
|
906
|
-
//
|
|
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: '
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
1385
|
+
const serving =
|
|
1386
|
+
tools !== undefined && Object.keys(tools).some((name) => isIdeToolName(name));
|
|
930
1387
|
if (serving) {
|
|
931
1388
|
wasServing = true;
|
|
932
|
-
|
|
933
|
-
} else if (wasServing && tools !== undefined &&
|
|
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 (
|
|
950
|
-
appendIdeRecoveryGuidance(event?.system, { marked:
|
|
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
|
-
|
|
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
|
-
// **本次请求**里就带上
|
|
1429
|
+
// **本次请求**里就带上 IDE 工具,没有"先回一句正在重连、下一条才行"
|
|
961
1430
|
// 的空窗。
|
|
962
1431
|
//
|
|
963
1432
|
// 两种"需要重连"的形状都从这里进:
|
|
964
1433
|
// * `needsRebuild` — 之前的请求已经证明工具没了;
|
|
965
|
-
// * `registrations.length === 0` —
|
|
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()` —— 一条用户消息不该把
|
|
1440
|
+
// 的 `reviveIde()` —— 一条用户消息不该把 IDE 拉起来。
|
|
972
1441
|
promptRegistration = await ctx.session.hook('prompt', async (event) => {
|
|
973
|
-
if (disposed ||
|
|
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
|
|
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
|
|
1000
|
-
if (!
|
|
1001
|
-
const kind =
|
|
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 (!
|
|
1011
|
-
|
|
1012
|
-
|
|
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();
|