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