opencode-idea 0.0.23 → 0.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +325 -285
- package/package.json +1 -1
- package/skills/idea-run-config-manager/SKILL.md +56 -44
- package/skills/idea-run-config-manager/assets/AGENTS.template.md +54 -63
- package/skills/idea-run-config-manager/assets/types/go-build.md +7 -6
- package/skills/idea-run-config-manager/assets/types/gradle.md +6 -6
- package/skills/idea-run-config-manager/assets/types/maven.md +7 -6
- package/skills/idea-run-config-manager/assets/types/node.md +10 -7
- package/skills/idea-run-config-manager/assets/types/npm.md +29 -27
- package/skills/idea-run-config-manager/assets/types/python.md +39 -31
- package/skills/idea-run-config-manager/assets/types/shell.md +84 -51
- package/skills/idea-standalone/SKILL.md +24 -18
- package/skills/idea-standalone/assets/AGENTS.template.md +96 -41
- package/src/ide-guidance.js +3 -0
- package/src/ide-guidance.template.md +141 -121
- package/src/mcp/enhance.js +13 -3
- package/src/mcp/idea.js +1 -1
- package/src/plugin.js +1214 -1195
package/src/plugin.js
CHANGED
|
@@ -51,38 +51,38 @@
|
|
|
51
51
|
// `{ id, setup }` object is the whole plugin contract. Exporting it directly
|
|
52
52
|
// keeps this package dependency-free at runtime.
|
|
53
53
|
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
54
|
+
import {existsSync} from 'node:fs';
|
|
55
|
+
import {resolve} from 'node:path';
|
|
56
56
|
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
57
|
+
import {callTool, serverConfig} from './mcp/idea.js';
|
|
58
|
+
import {applyIdeToolDefaults, hideForcedArguments} from './mcp/idea-tool-defaults.js';
|
|
59
|
+
import {currentProjectPath} from './project.js';
|
|
60
60
|
import {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
appNameFor,
|
|
62
|
+
autoLaunchSupported,
|
|
63
|
+
createLaunchGuard,
|
|
64
|
+
detectInstalledIdes,
|
|
65
|
+
openInIde,
|
|
66
66
|
} from './ide-launcher.js';
|
|
67
67
|
import {
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
appendIdeGuidance,
|
|
69
|
+
appendIdeRecoveryGuidance,
|
|
70
70
|
} from './ide-guidance.js';
|
|
71
71
|
import {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
72
|
+
IDE_LABELS,
|
|
73
|
+
IDE_PORTS,
|
|
74
|
+
SUPPORTED_IDES,
|
|
75
|
+
listProjects,
|
|
76
|
+
markKeyFor,
|
|
77
|
+
parseIdeArgument,
|
|
78
|
+
parseMark,
|
|
79
|
+
parseSubprojectArgument,
|
|
80
|
+
serializeMark,
|
|
81
|
+
toolPrefixes,
|
|
82
|
+
validateSubproject,
|
|
83
|
+
withSubproject,
|
|
84
84
|
} from './ide-projects.js';
|
|
85
|
-
import {
|
|
85
|
+
import {readSkills} from './skill/loader.js';
|
|
86
86
|
|
|
87
87
|
const DEFAULT_LAUNCH_COOLDOWN_MS = 120000;
|
|
88
88
|
const DEFAULT_LAUNCH_MAX_ATTEMPTS = 5;
|
|
@@ -145,13 +145,22 @@ const PLUGIN_TOOL_NAMES = new Set(['configure_ide_project']);
|
|
|
145
145
|
* user configures data sources in the IDE. The query tools are hidden too —
|
|
146
146
|
* the IDE is not this plugin's idea of a database client, and a model that
|
|
147
147
|
* wants to inspect a database should be told to use a real one.
|
|
148
|
-
* - Plain file
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
148
|
+
* - Plain file **search**: `search_text` / `search_regex` / `search_file` each
|
|
149
|
+
* have a native tool that does the job (`grep` / `grep -E` / `glob`);
|
|
150
|
+
* routing them through the IDE only adds a hop. `read_file` is NOT hidden —
|
|
151
|
+
* it is the only way to read a file inside a jar (`<jar>!/<entry>`) or the
|
|
152
|
+
* JDK's `jrt://` image, so the guidance keeps it for exactly that and sends
|
|
153
|
+
* ordinary reads to the native tool.
|
|
154
|
+
* - Plain file **write**: `apply_patch` / `create_new_file` are **kept**, and
|
|
155
|
+
* they are the ones the guidance sends writes to. The native `write` / `edit`
|
|
156
|
+
* write from **outside** the IDE, and the IDE keeps an in-memory copy of every
|
|
157
|
+
* file it has open — that copy is only re-read when the VFS refreshes, so
|
|
158
|
+
* between the write and the refresh the IDE still sees the OLD text. Measured
|
|
159
|
+
* against a real IDE: after an external write, `read_file` still returned the
|
|
160
|
+
* old content; after `apply_patch` it returned the new one immediately. That
|
|
161
|
+
* gap is not cosmetic — `reformat_file` formats the in-memory copy and
|
|
162
|
+
* **writes it back**, silently discarding whatever was just written. Writing
|
|
163
|
+
* through the IDE (`apply_patch`) keeps the two copies the same thing.
|
|
155
164
|
* - IDE terminal: `execute_terminal_command` does NOT inherit the project's
|
|
156
165
|
* environment. It was exposed on the assumption that it ran with the SDKs the
|
|
157
166
|
* user configured in the IDE (JDK, Node, Python, ...), but measured against a
|
|
@@ -164,65 +173,71 @@ const PLUGIN_TOOL_NAMES = new Set(['configure_ide_project']);
|
|
|
164
173
|
* through the native shell.
|
|
165
174
|
*/
|
|
166
175
|
export const HIDDEN_IDE_TOOL_SUFFIXES = [
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
176
|
+
// VCS
|
|
177
|
+
'git_status',
|
|
178
|
+
'get_repositories',
|
|
179
|
+
// Router
|
|
180
|
+
'execute_tool',
|
|
181
|
+
// IDE terminal: no advantage over the native shell (see the note above).
|
|
182
|
+
'execute_terminal_command',
|
|
183
|
+
// Debugger
|
|
184
|
+
'xdebug_control_session',
|
|
185
|
+
'xdebug_evaluate_expression',
|
|
186
|
+
'xdebug_get_debugger_status',
|
|
187
|
+
'xdebug_get_frame_values',
|
|
188
|
+
'xdebug_get_stack',
|
|
189
|
+
'xdebug_get_threads',
|
|
190
|
+
'xdebug_get_value_by_path',
|
|
191
|
+
'xdebug_list_breakpoints',
|
|
192
|
+
'xdebug_remove_breakpoint',
|
|
193
|
+
'xdebug_run_to_line',
|
|
194
|
+
'xdebug_set_breakpoint',
|
|
195
|
+
'xdebug_set_variable',
|
|
196
|
+
'xdebug_start_debugger_session',
|
|
197
|
+
// Dev Kit MCP
|
|
198
|
+
'collect_split_mode_compatibility_issues',
|
|
199
|
+
'create_ij_module',
|
|
200
|
+
'find_lock_requirements_usages',
|
|
201
|
+
'find_threading_requirements_usages',
|
|
202
|
+
'recognize_ij_module_kind',
|
|
203
|
+
'recognize_split_mode_api_kind',
|
|
204
|
+
// Inspection KTS MCP
|
|
205
|
+
'generate_inspection_kts_api',
|
|
206
|
+
'generate_inspection_kts_examples',
|
|
207
|
+
'generate_psi_tree',
|
|
208
|
+
'run_inspection_kts',
|
|
209
|
+
// Python Environment MCP
|
|
210
|
+
'configure_python_interpreter',
|
|
211
|
+
'get_python_environment',
|
|
212
|
+
// Database: connections are the user's, and the IDE is not this plugin's
|
|
213
|
+
// database client.
|
|
214
|
+
'create_database_connection',
|
|
215
|
+
'edit_database_connection',
|
|
216
|
+
'list_database_connections',
|
|
217
|
+
'test_database_connection',
|
|
218
|
+
'list_database_schemas',
|
|
219
|
+
'list_schema_object_kinds',
|
|
220
|
+
'list_schema_objects',
|
|
221
|
+
'introspect_schema',
|
|
222
|
+
'get_database_object_description',
|
|
223
|
+
'execute_sql_query',
|
|
224
|
+
'fetch_query_result',
|
|
225
|
+
'preview_table_data',
|
|
226
|
+
'list_recent_sql_queries',
|
|
227
|
+
'cancel_sql_query',
|
|
228
|
+
// Plain file SEARCH with a native equivalent (`read_file` stays: only it can
|
|
229
|
+
// read inside a jar or the jrt:// image). Plain file WRITE stays visible on
|
|
230
|
+
// purpose — see the note above: writing through the IDE is what keeps the
|
|
231
|
+
// IDE's in-memory copy and the file on disk the same thing.
|
|
232
|
+
'search_text',
|
|
233
|
+
'search_regex',
|
|
234
|
+
'search_file',
|
|
235
|
+
// The companion plugin's own write tool. The IDE's built-in `apply_patch` /
|
|
236
|
+
// `create_new_file` already cover writing through the IDE, so this one is
|
|
237
|
+
// hidden: two tools for the same job is a worse answer than one. The plugin
|
|
238
|
+
// has dropped it as well — this line only hides the copies still served by
|
|
239
|
+
// the plugin version installed before that.
|
|
240
|
+
'edit_file',
|
|
226
241
|
];
|
|
227
242
|
|
|
228
243
|
/**
|
|
@@ -236,22 +251,22 @@ export const HIDDEN_IDE_TOOL_SUFFIXES = [
|
|
|
236
251
|
* @param {readonly string[]} prefixes 当前所有项目的工具前缀(含下划线)。
|
|
237
252
|
*/
|
|
238
253
|
function removeIdeToolDefinitions(tools, suffixes, prefixes) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
254
|
+
if (!tools || typeof tools !== 'object') return 0;
|
|
255
|
+
let removed = 0;
|
|
256
|
+
for (const prefix of prefixes) {
|
|
257
|
+
for (const suffix of suffixes) {
|
|
258
|
+
const name = `${prefix}${suffix}`;
|
|
259
|
+
if (!(name in tools)) continue;
|
|
260
|
+
delete tools[name];
|
|
261
|
+
removed += 1;
|
|
262
|
+
}
|
|
247
263
|
}
|
|
248
|
-
|
|
249
|
-
return removed;
|
|
264
|
+
return removed;
|
|
250
265
|
}
|
|
251
266
|
|
|
252
267
|
/** @param {ReadonlyArray<{ id?: string }> | undefined} tools */
|
|
253
268
|
function toolIds(tools) {
|
|
254
|
-
|
|
269
|
+
return (tools ?? []).map((tool) => tool.id).filter((id) => typeof id === 'string').sort();
|
|
255
270
|
}
|
|
256
271
|
|
|
257
272
|
/**
|
|
@@ -275,53 +290,53 @@ function toolIds(tools) {
|
|
|
275
290
|
* @returns {'project' | 'mcp' | 'none'}
|
|
276
291
|
*/
|
|
277
292
|
function classifyIdeFailure(error, serverNames) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
293
|
+
const message = typeof error?.message === 'string' ? error.message : '';
|
|
294
|
+
// "Project not open in the IDE" comes in two shapes, both meaning the same
|
|
295
|
+
// thing and both fixed by opening the project again:
|
|
296
|
+
// - the server could not pick a project for the call: "Unable to determine
|
|
297
|
+
// the target project for the current MCP tool call." (the project path in
|
|
298
|
+
// the request header did not resolve — the IDE may still be opening it)
|
|
299
|
+
// - the project was resolved but is not open: "`projectPath`=...
|
|
300
|
+
// doesn't correspond to any open project."
|
|
301
|
+
if (
|
|
302
|
+
message.includes('Unable to determine the target project') ||
|
|
303
|
+
message.includes('correspond to any open project')
|
|
304
|
+
) {
|
|
305
|
+
return 'project';
|
|
306
|
+
}
|
|
307
|
+
// Only transport-level markers, which OpenCode's own MCP client emits. The
|
|
308
|
+
// message of an MCP *tool error* is the tool's own content (`tool/mcp.ts`
|
|
309
|
+
// turns `result.isError` into `ToolFailure({ message: <text> })`), so a
|
|
310
|
+
// generic string such as "Connection refused" from a SQL or run-config tool
|
|
311
|
+
// would otherwise be misread as a dropped connection and rebuild a healthy
|
|
312
|
+
// server.
|
|
313
|
+
//
|
|
314
|
+
// MEASURED (2026-09-30, OpenCode 2.0.19, IDE process killed): a dead remote
|
|
315
|
+
// MCP endpoint does NOT produce any of the shapes above. OpenCode's client
|
|
316
|
+
// reports its own connect failure instead:
|
|
317
|
+
//
|
|
318
|
+
// level=WARN message="mcp http request failed"
|
|
319
|
+
// errors="[{\"type\":\"TypeError\",
|
|
320
|
+
// \"message\":\"Unable to connect. Is the computer able to access the url?\",
|
|
321
|
+
// \"code\":\"ConnectionRefused\",\"errno\":0}]"
|
|
322
|
+
//
|
|
323
|
+
// so the text is matched on its stable prefix, and the underlying `code` is
|
|
324
|
+
// accepted as a structural signal too (a wording change must not silently
|
|
325
|
+
// disable the heal).
|
|
326
|
+
//
|
|
327
|
+
// serverNames 是当前注册的全部 server 名(`ide` / 各子项目前缀):错误里点名的是
|
|
328
|
+
// 哪一个 server,取决于失败的是哪个项目的工具,所以只能逐个匹配。
|
|
329
|
+
if (
|
|
330
|
+
serverNames.some((name) => message.includes(`MCP server "${name}" is not available`)) ||
|
|
331
|
+
message.includes('MCP server is not connected') ||
|
|
332
|
+
message.includes('Error POSTing to endpoint') ||
|
|
333
|
+
message.includes('Unable to connect')
|
|
334
|
+
) {
|
|
335
|
+
return 'mcp';
|
|
336
|
+
}
|
|
337
|
+
const causeCode = error?.error?.code ?? error?.cause?.code ?? error?.code;
|
|
338
|
+
if (causeCode === 'ConnectionRefused' || causeCode === 'ECONNREFUSED') return 'mcp';
|
|
339
|
+
return 'none';
|
|
325
340
|
}
|
|
326
341
|
|
|
327
342
|
/**
|
|
@@ -338,26 +353,26 @@ function classifyIdeFailure(error, serverNames) {
|
|
|
338
353
|
* @returns {boolean}
|
|
339
354
|
*/
|
|
340
355
|
function rewriteToolError(event, message) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
356
|
+
const current = event?.error;
|
|
357
|
+
if (!current || typeof current !== 'object') return false;
|
|
358
|
+
try {
|
|
359
|
+
const Ctor = current.constructor;
|
|
360
|
+
if (typeof Ctor === 'function') {
|
|
361
|
+
const next = {message};
|
|
362
|
+
if (current.error !== undefined) next.error = current.error;
|
|
363
|
+
if (current.metadata !== undefined) next.metadata = current.metadata;
|
|
364
|
+
event.error = new Ctor(next);
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// fall through to mutation
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
current.message = message;
|
|
372
|
+
return true;
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
351
375
|
}
|
|
352
|
-
} catch {
|
|
353
|
-
// fall through to mutation
|
|
354
|
-
}
|
|
355
|
-
try {
|
|
356
|
-
current.message = message;
|
|
357
|
-
return true;
|
|
358
|
-
} catch {
|
|
359
|
-
return false;
|
|
360
|
-
}
|
|
361
376
|
}
|
|
362
377
|
|
|
363
378
|
/**
|
|
@@ -391,47 +406,47 @@ function rewriteToolError(event, message) {
|
|
|
391
406
|
* @param {number} fallback
|
|
392
407
|
*/
|
|
393
408
|
const asPositiveNumber = (value, fallback) =>
|
|
394
|
-
|
|
409
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
395
410
|
|
|
396
411
|
/** 面向用户的状态文案(三个命令与 `configure_ide_project` 共用)。 */
|
|
397
412
|
/** @param {IdeCommandResult} result */
|
|
398
413
|
export function ideFeedback(result) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
414
|
+
const appOf = (project) => IDE_LABELS[project?.app] ?? 'IDE';
|
|
415
|
+
const nameOf = (project) =>
|
|
416
|
+
project?.isRoot === false ? `${appOf(project)}(子项目 ${project.label ?? ''})` : appOf(project);
|
|
417
|
+
switch (result?.status) {
|
|
418
|
+
case 'connected': {
|
|
419
|
+
const list = (result.projects ?? []).map((project) => `${project.label}(${appOf(project)})`).join('、');
|
|
420
|
+
return list ? `IDE MCP 已连接并接入:${list}。` : 'IDE MCP 已连接。';
|
|
421
|
+
}
|
|
422
|
+
case 'tools-loading':
|
|
423
|
+
return 'IDE MCP 已连接,但原生 IDE 工具列表尚未完成注册。请稍后再次执行 /open-in-ide。';
|
|
424
|
+
case 'mcp-unavailable': {
|
|
425
|
+
const where = nameOf(result?.project);
|
|
426
|
+
const ide = appOf(result?.project);
|
|
427
|
+
return result?.opened
|
|
428
|
+
? `已请求打开/激活 ${where} 并等待 MCP 服务启动,但未检测到 MCP 服务。请在 ${ide} 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-ide。`
|
|
429
|
+
: `未检测到 ${where} 的 MCP 服务。请确认已打开,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-ide。`;
|
|
430
|
+
}
|
|
431
|
+
case 'launch-failed':
|
|
432
|
+
return `无法拉起 ${nameOf(result?.project)}(未找到应用或启动失败)。请确认它已安装 —— 没装的话先装上,再重试 /open-in-ide。`;
|
|
433
|
+
case 'disabled':
|
|
434
|
+
return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-ide。';
|
|
435
|
+
case 'manual-launch':
|
|
436
|
+
return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-ide。';
|
|
437
|
+
case 'deleted':
|
|
438
|
+
return '已删除本项目的 IDE 配置:已注销全部 IDE MCP 并清除配置。如需重新接入,请执行 /open-in-ide。';
|
|
439
|
+
case 'bad-argument':
|
|
440
|
+
return `命令参数有误:${result?.message ?? ''}`;
|
|
441
|
+
case 'not-configured':
|
|
442
|
+
return '当前项目还没有 IDE 配置。请先执行 /open-in-ide 接入根项目,再登记子项目。';
|
|
443
|
+
case 'invalid-subproject':
|
|
444
|
+
return `无法登记子项目:${result?.message ?? ''}`;
|
|
445
|
+
case 'needs-ide':
|
|
446
|
+
return '需要确定用哪个 IDE 打开。请回答 AI 的提问(IntelliJ IDEA 或 PyCharm),AI 会把选择写入配置。';
|
|
447
|
+
default:
|
|
448
|
+
return '已处理。';
|
|
415
449
|
}
|
|
416
|
-
case 'launch-failed':
|
|
417
|
-
return `无法拉起 ${nameOf(result?.project)}(未找到应用或启动失败)。请确认它已安装 —— 没装的话先装上,再重试 /open-in-ide。`;
|
|
418
|
-
case 'disabled':
|
|
419
|
-
return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-ide。';
|
|
420
|
-
case 'manual-launch':
|
|
421
|
-
return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-ide。';
|
|
422
|
-
case 'deleted':
|
|
423
|
-
return '已删除本项目的 IDE 配置:已注销全部 IDE MCP 并清除配置。如需重新接入,请执行 /open-in-ide。';
|
|
424
|
-
case 'bad-argument':
|
|
425
|
-
return `命令参数有误:${result?.message ?? ''}`;
|
|
426
|
-
case 'not-configured':
|
|
427
|
-
return '当前项目还没有 IDE 配置。请先执行 /open-in-ide 接入根项目,再登记子项目。';
|
|
428
|
-
case 'invalid-subproject':
|
|
429
|
-
return `无法登记子项目:${result?.message ?? ''}`;
|
|
430
|
-
case 'needs-ide':
|
|
431
|
-
return '需要确定用哪个 IDE 打开。请回答 AI 的提问(IntelliJ IDEA 或 PyCharm),AI 会把选择写入配置。';
|
|
432
|
-
default:
|
|
433
|
-
return '已处理。';
|
|
434
|
-
}
|
|
435
450
|
}
|
|
436
451
|
|
|
437
452
|
/**
|
|
@@ -443,1057 +458,1061 @@ export function ideFeedback(result) {
|
|
|
443
458
|
export const NOTICE_PREFIX = '【插件通知】';
|
|
444
459
|
|
|
445
460
|
export function idePromptText(result) {
|
|
446
|
-
|
|
461
|
+
return [`${NOTICE_PREFIX}${ideFeedback(result)}`, '这是发给用户的通知,你无需处理。请只回复"收到"。'].join('\n\n');
|
|
447
462
|
}
|
|
448
463
|
|
|
449
464
|
/** `metadata` so the UI shows the clean notice while the model sees the instruction. */
|
|
450
465
|
export function idePromptMetadata(result) {
|
|
451
|
-
|
|
466
|
+
return {displayText: ideFeedback(result), comments: []};
|
|
452
467
|
}
|
|
453
468
|
|
|
454
469
|
export default {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* @param {{
|
|
459
|
-
* location?: unknown,
|
|
460
|
-
* options?: {
|
|
461
|
-
* openInIde?: boolean,
|
|
462
|
-
* launchCooldownMs?: number,
|
|
463
|
-
* launchMaxAttempts?: number,
|
|
464
|
-
* injectGuidance?: boolean,
|
|
465
|
-
* mcpProbeTimeoutMs?: number,
|
|
466
|
-
* mcpStartTimeoutMs?: number,
|
|
467
|
-
* toolRefreshTimeoutMs?: number,
|
|
468
|
-
* executionTimeoutMs?: number,
|
|
469
|
-
* feedback?: false | 'message',
|
|
470
|
-
* },
|
|
471
|
-
* command?: {
|
|
472
|
-
* transform: (cb: (editor: { add: (definition: {
|
|
473
|
-
* name: string,
|
|
474
|
-
* description?: string,
|
|
475
|
-
* execute: (input: { sessionID: string, prompt: { text: string }, delivery: unknown }) => Promise<void>,
|
|
476
|
-
* }) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
477
|
-
* },
|
|
478
|
-
* skill?: {
|
|
479
|
-
* transform: (cb: (editor: { add: (definition: {
|
|
480
|
-
* id: string,
|
|
481
|
-
* name: string,
|
|
482
|
-
* description?: string,
|
|
483
|
-
* autoinvoke?: boolean,
|
|
484
|
-
* path: string,
|
|
485
|
-
* content: string,
|
|
486
|
-
* }) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
487
|
-
* },
|
|
488
|
-
* session?: {
|
|
489
|
-
* prompt: (input: { sessionID: string, text: string, delivery: unknown }) => Promise<unknown>,
|
|
490
|
-
* hook: (name: string, cb: (event: {
|
|
491
|
-
* sessionID?: string,
|
|
492
|
-
* agent?: string,
|
|
493
|
-
* system?: unknown[],
|
|
494
|
-
* tools?: Record<string, unknown>,
|
|
495
|
-
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
496
|
-
* },
|
|
497
|
-
* tool?: {
|
|
498
|
-
* list?: () => Promise<ReadonlyArray<{ id?: string }>>,
|
|
499
|
-
* reload?: () => Promise<void>,
|
|
500
|
-
* transform?: (cb: (editor: {
|
|
501
|
-
* list: () => ReadonlyArray<{ id?: string, input?: unknown }>,
|
|
502
|
-
* get: (id: string) => ({ id?: string, input?: unknown } | undefined),
|
|
503
|
-
* add: (definition: unknown) => void,
|
|
504
|
-
* update: (id: string, update: (tool: { input?: unknown, description?: string }) => void) => void,
|
|
505
|
-
* remove: (id: string) => void,
|
|
506
|
-
* }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
507
|
-
* hook?: (name: string, cb: (event: {
|
|
508
|
-
* tool?: string,
|
|
509
|
-
* sessionID?: string,
|
|
510
|
-
* input?: Record<string, unknown>,
|
|
511
|
-
* status?: string,
|
|
512
|
-
* result?: unknown,
|
|
513
|
-
* error?: unknown,
|
|
514
|
-
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
515
|
-
* },
|
|
516
|
-
* mcp: {
|
|
517
|
-
* transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
518
|
-
* reload?: () => Promise<void>,
|
|
519
|
-
* list?: () => Promise<{ data?: ReadonlyArray<{ name?: string, status?: { status?: string } }> }>,
|
|
520
|
-
* },
|
|
521
|
-
* shell: { hook: (name: string, cb: (input: { env: Record<string, string | undefined> }) => void) => Promise<{ dispose: () => Promise<void> | void }> },
|
|
522
|
-
* storage?: {
|
|
523
|
-
* get: (key: string) => Promise<unknown>,
|
|
524
|
-
* set: (key: string, value: unknown) => Promise<void>,
|
|
525
|
-
* remove: (key: string) => Promise<void>,
|
|
526
|
-
* },
|
|
527
|
-
* }} ctx
|
|
528
|
-
*/
|
|
529
|
-
async setup(ctx) {
|
|
530
|
-
const projectPath = currentProjectPath(ctx.location);
|
|
531
|
-
if (!projectPath) return;
|
|
470
|
+
id: 'opencode-idea',
|
|
532
471
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
472
|
+
/**
|
|
473
|
+
* @param {{
|
|
474
|
+
* location?: unknown,
|
|
475
|
+
* options?: {
|
|
476
|
+
* openInIde?: boolean,
|
|
477
|
+
* launchCooldownMs?: number,
|
|
478
|
+
* launchMaxAttempts?: number,
|
|
479
|
+
* injectGuidance?: boolean,
|
|
480
|
+
* mcpProbeTimeoutMs?: number,
|
|
481
|
+
* mcpStartTimeoutMs?: number,
|
|
482
|
+
* toolRefreshTimeoutMs?: number,
|
|
483
|
+
* executionTimeoutMs?: number,
|
|
484
|
+
* feedback?: false | 'message',
|
|
485
|
+
* },
|
|
486
|
+
* command?: {
|
|
487
|
+
* transform: (cb: (editor: { add: (definition: {
|
|
488
|
+
* name: string,
|
|
489
|
+
* description?: string,
|
|
490
|
+
* execute: (input: { sessionID: string, prompt: { text: string }, delivery: unknown }) => Promise<void>,
|
|
491
|
+
* }) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
492
|
+
* },
|
|
493
|
+
* skill?: {
|
|
494
|
+
* transform: (cb: (editor: { add: (definition: {
|
|
495
|
+
* id: string,
|
|
496
|
+
* name: string,
|
|
497
|
+
* description?: string,
|
|
498
|
+
* autoinvoke?: boolean,
|
|
499
|
+
* path: string,
|
|
500
|
+
* content: string,
|
|
501
|
+
* }) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
502
|
+
* },
|
|
503
|
+
* session?: {
|
|
504
|
+
* prompt: (input: { sessionID: string, text: string, delivery: unknown }) => Promise<unknown>,
|
|
505
|
+
* hook: (name: string, cb: (event: {
|
|
506
|
+
* sessionID?: string,
|
|
507
|
+
* agent?: string,
|
|
508
|
+
* system?: unknown[],
|
|
509
|
+
* tools?: Record<string, unknown>,
|
|
510
|
+
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
511
|
+
* },
|
|
512
|
+
* tool?: {
|
|
513
|
+
* list?: () => Promise<ReadonlyArray<{ id?: string }>>,
|
|
514
|
+
* reload?: () => Promise<void>,
|
|
515
|
+
* transform?: (cb: (editor: {
|
|
516
|
+
* list: () => ReadonlyArray<{ id?: string, input?: unknown }>,
|
|
517
|
+
* get: (id: string) => ({ id?: string, input?: unknown } | undefined),
|
|
518
|
+
* add: (definition: unknown) => void,
|
|
519
|
+
* update: (id: string, update: (tool: { input?: unknown, description?: string }) => void) => void,
|
|
520
|
+
* remove: (id: string) => void,
|
|
521
|
+
* }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
522
|
+
* hook?: (name: string, cb: (event: {
|
|
523
|
+
* tool?: string,
|
|
524
|
+
* sessionID?: string,
|
|
525
|
+
* input?: Record<string, unknown>,
|
|
526
|
+
* status?: string,
|
|
527
|
+
* result?: unknown,
|
|
528
|
+
* error?: unknown,
|
|
529
|
+
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
530
|
+
* },
|
|
531
|
+
* mcp: {
|
|
532
|
+
* transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
533
|
+
* reload?: () => Promise<void>,
|
|
534
|
+
* list?: () => Promise<{ data?: ReadonlyArray<{ name?: string, status?: { status?: string } }> }>,
|
|
535
|
+
* },
|
|
536
|
+
* shell: { hook: (name: string, cb: (input: { env: Record<string, string | undefined> }) => void) => Promise<{ dispose: () => Promise<void> | void }> },
|
|
537
|
+
* storage?: {
|
|
538
|
+
* get: (key: string) => Promise<unknown>,
|
|
539
|
+
* set: (key: string, value: unknown) => Promise<void>,
|
|
540
|
+
* remove: (key: string) => Promise<void>,
|
|
541
|
+
* },
|
|
542
|
+
* }} ctx
|
|
543
|
+
*/
|
|
544
|
+
async setup(ctx) {
|
|
545
|
+
const projectPath = currentProjectPath(ctx.location);
|
|
546
|
+
if (!projectPath) return;
|
|
550
547
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
548
|
+
const options = ctx.options || {};
|
|
549
|
+
const injectGuidance = options.injectGuidance !== false;
|
|
550
|
+
// 自动拉起 IDE 的总开关。用哪个 IDE 由**每个项目自己的配置**决定
|
|
551
|
+
// (`project.app`),这个选项不再指定应用名,只决定"允不允许拉起"。
|
|
552
|
+
const launchEnabled = options.openInIde !== false;
|
|
553
|
+
const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
|
|
554
|
+
const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
|
|
555
|
+
const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
|
|
556
|
+
// Cap on a single IDE tool call, so a wedged server fails in finite time
|
|
557
|
+
// instead of OpenCode's 12h default. See DEFAULT_EXECUTION_TIMEOUT_MS.
|
|
558
|
+
const executionTimeoutMs = asPositiveNumber(options.executionTimeoutMs, DEFAULT_EXECUTION_TIMEOUT_MS);
|
|
559
|
+
// "message" (default) injects a clearly-labelled notification that the
|
|
560
|
+
// model only acknowledges ("收到"); the user sees the clean notice via
|
|
561
|
+
// metadata.displayText. false is silent.
|
|
562
|
+
const feedback = options.feedback === false ? false : 'message';
|
|
563
|
+
const launchCooldownMs = asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS);
|
|
564
|
+
const launchMaxAttempts = asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS);
|
|
568
565
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
566
|
+
/** @type {Array<{ dispose: () => Promise<void> | void }>} */
|
|
567
|
+
let registrations = [];
|
|
568
|
+
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
569
|
+
let sessionRegistration;
|
|
570
|
+
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
571
|
+
let toolRegistration;
|
|
572
|
+
let toolBeforeRegistration;
|
|
573
|
+
let toolTransformRegistration;
|
|
574
|
+
/** `configure_ide_project` 的注册。 */
|
|
575
|
+
let configToolRegistration;
|
|
576
|
+
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
577
|
+
let promptRegistration;
|
|
578
|
+
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
579
|
+
let commandRegistration;
|
|
580
|
+
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
581
|
+
let skillRegistration;
|
|
582
|
+
let disposed = false;
|
|
578
583
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
let config;
|
|
584
|
+
// Latches when a request showed the IDE tools are gone. It is cleared
|
|
585
|
+
// only by a successful rebuild. It is set from the `context` hook (a request
|
|
586
|
+
// that HAD the tools no longer does); the `prompt` hook also heals without
|
|
587
|
+
// this latch when the instance has no registration at all.
|
|
588
|
+
let needsRebuild = false;
|
|
589
|
+
// Set once a request in this session actually carried IDE tools, so a
|
|
590
|
+
// later request without them is a DROP of this session's own connection —
|
|
591
|
+
// not a root that simply never connected.
|
|
592
|
+
let wasServing = false;
|
|
589
593
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
// storage is best effort;读不到就当作未配置
|
|
601
|
-
config = null;
|
|
602
|
-
}
|
|
603
|
-
return config;
|
|
604
|
-
};
|
|
594
|
+
// 本会话的 IDE 配置:root 用哪个 IDE,以及登记了哪些子项目、各自用哪个 IDE。
|
|
595
|
+
// 一个插件实例服务一个 root(OpenCode 按 Location 激活插件),所以这就是全部
|
|
596
|
+
// 状态。持久化在 `ctx.storage` 的 `project/<rootPath>` 下。
|
|
597
|
+
//
|
|
598
|
+
// `undefined` = 还没读过;`null` = 读过、但没有配置。两者必须分开:混成一种,
|
|
599
|
+
// 请求钩子就会把"还没读"当成"未配置",漏掉一次本该发生的自愈。
|
|
600
|
+
const storage = ctx.storage;
|
|
601
|
+
const markKey = markKeyFor(projectPath);
|
|
602
|
+
/** @type {import('./ide-projects.js').IdeConfig | null | undefined} */
|
|
603
|
+
let config;
|
|
605
604
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
config = next;
|
|
623
|
-
};
|
|
605
|
+
/** 读一次持久化配置(最多一次 I/O),之后走内存缓存。 */
|
|
606
|
+
const readConfig = async () => {
|
|
607
|
+
if (config !== undefined) return config;
|
|
608
|
+
if (!storage || typeof storage.get !== 'function') {
|
|
609
|
+
config = null;
|
|
610
|
+
return config;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
config = parseMark(await storage.get(markKey), projectPath) ?? null;
|
|
614
|
+
} catch {
|
|
615
|
+
// storage is best effort;读不到就当作未配置
|
|
616
|
+
config = null;
|
|
617
|
+
}
|
|
618
|
+
return config;
|
|
619
|
+
};
|
|
624
620
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
621
|
+
/**
|
|
622
|
+
* 写入配置(`null` = 删除),并同步内存缓存。
|
|
623
|
+
*
|
|
624
|
+
* 先写 storage 再更新缓存(write-through):写失败也要更新缓存,否则用户刚做的
|
|
625
|
+
* 配置在本进程里不可见,行为会自相矛盾。命令与 `configure_ide_project` 是唯一的
|
|
626
|
+
* 写入者。
|
|
627
|
+
*
|
|
628
|
+
* @param {import('./ide-projects.js').IdeConfig | null} next
|
|
629
|
+
*/
|
|
630
|
+
const writeConfig = async (next) => {
|
|
631
|
+
try {
|
|
632
|
+
if (next) await storage?.set?.(markKey, serializeMark(next));
|
|
633
|
+
else await storage?.remove?.(markKey);
|
|
634
|
+
} catch {
|
|
635
|
+
// best effort
|
|
636
|
+
}
|
|
637
|
+
config = next;
|
|
638
|
+
};
|
|
633
639
|
|
|
634
|
-
|
|
635
|
-
|
|
640
|
+
/** 当前接入的全部项目(root 在前),每个带工具前缀与展示标签。 */
|
|
641
|
+
const currentProjects = () =>
|
|
642
|
+
config
|
|
643
|
+
? listProjects(config).map((project) => ({
|
|
644
|
+
...project,
|
|
645
|
+
label: project.isRoot ? '.' : project.path.slice(projectPath.length + 1),
|
|
646
|
+
}))
|
|
647
|
+
: [];
|
|
636
648
|
|
|
637
|
-
|
|
638
|
-
|
|
649
|
+
/** 当前所有项目的工具前缀(含下划线);未配置时为空。 */
|
|
650
|
+
const prefixes = () => (config ? toolPrefixes(config) : []);
|
|
639
651
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (PLUGIN_TOOL_NAMES.has(name)) return false;
|
|
643
|
-
return prefixes().some((prefix) => name.startsWith(prefix));
|
|
644
|
-
};
|
|
652
|
+
/** 当前注册的全部 server 名(= 前缀去掉下划线)。 */
|
|
653
|
+
const serverNames = () => (config ? listProjects(config).map((project) => project.server) : []);
|
|
645
654
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
*
|
|
652
|
-
* @param {string} name
|
|
653
|
-
*/
|
|
654
|
-
const projectForTool = (name) =>
|
|
655
|
-
currentProjects()
|
|
656
|
-
.filter((project) => name.startsWith(`${project.server}_`))
|
|
657
|
-
.sort((a, b) => b.server.length - a.server.length)[0];
|
|
655
|
+
/** 这个工具名是否属于某个 IDE server(插件自己的工具不算)。 */
|
|
656
|
+
const isIdeToolName = (name) => {
|
|
657
|
+
if (PLUGIN_TOOL_NAMES.has(name)) return false;
|
|
658
|
+
return prefixes().some((prefix) => name.startsWith(prefix));
|
|
659
|
+
};
|
|
658
660
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
+
/**
|
|
662
|
+
* 这个 IDE 工具属于哪个项目?
|
|
663
|
+
*
|
|
664
|
+
* 按**最长前缀**匹配:`modelserver_` 必须先于 `model_` 命中,否则一个叫 `model`
|
|
665
|
+
* 的项目会把 `modelserver_*` 的工具认领走。
|
|
666
|
+
*
|
|
667
|
+
* @param {string} name
|
|
668
|
+
*/
|
|
669
|
+
const projectForTool = (name) =>
|
|
670
|
+
currentProjects()
|
|
671
|
+
.filter((project) => name.startsWith(`${project.server}_`))
|
|
672
|
+
.sort((a, b) => b.server.length - a.server.length)[0];
|
|
661
673
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
const current = registrations.slice();
|
|
665
|
-
registrations.length = 0;
|
|
666
|
-
if (current.length === 0) return;
|
|
667
|
-
for (const registration of current) {
|
|
668
|
-
try {
|
|
669
|
-
await registration.dispose();
|
|
670
|
-
} catch {
|
|
671
|
-
// best effort
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
};
|
|
674
|
+
// 先解析一次,请求钩子之后就能同步读到。
|
|
675
|
+
await readConfig();
|
|
675
676
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
};
|
|
677
|
+
/** 注销当前注册的全部 IDE server。 */
|
|
678
|
+
const deactivateIde = async () => {
|
|
679
|
+
const current = registrations.slice();
|
|
680
|
+
registrations.length = 0;
|
|
681
|
+
if (current.length === 0) return;
|
|
682
|
+
for (const registration of current) {
|
|
683
|
+
try {
|
|
684
|
+
await registration.dispose();
|
|
685
|
+
} catch {
|
|
686
|
+
// best effort
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
690
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
691
|
+
const waitForIdeTools = async () => {
|
|
692
|
+
if (typeof ctx.tool?.list !== 'function') return false;
|
|
693
|
+
const deadline = Date.now() + toolRefreshTimeoutMs;
|
|
694
|
+
while (Date.now() < deadline) {
|
|
695
|
+
try {
|
|
696
|
+
const ids = toolIds(await ctx.tool.list());
|
|
697
|
+
if (ids.some((id) => isIdeToolName(id))) return true;
|
|
698
|
+
} catch {
|
|
699
|
+
// The MCP catalog may still be settling; retry within the bounded wait.
|
|
700
|
+
}
|
|
701
|
+
await new Promise((resolve) => setTimeout(resolve, TOOL_REFRESH_POLL_MS));
|
|
702
|
+
}
|
|
703
|
+
return false;
|
|
704
|
+
};
|
|
698
705
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
return await waitForIdeTools();
|
|
707
|
-
};
|
|
706
|
+
/**
|
|
707
|
+
* 一个项目的 MCP 端口,由它的 IDE 唯一决定(`IDE_PORTS`,固定值)。
|
|
708
|
+
*
|
|
709
|
+
* @param {{ path: string, app: string }} project
|
|
710
|
+
* @returns {number | undefined}
|
|
711
|
+
*/
|
|
712
|
+
const portFor = (project) => IDE_PORTS[project.app];
|
|
708
713
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
const activateIde = async (bindings) => {
|
|
719
|
-
const mcpRegistration = await ctx.mcp.transform((editor) => {
|
|
720
|
-
for (const binding of bindings) {
|
|
721
|
-
editor.set(
|
|
722
|
-
binding.project.server,
|
|
723
|
-
serverConfig(binding.port, binding.project.path, { executionTimeoutMs }),
|
|
724
|
-
);
|
|
725
|
-
}
|
|
726
|
-
});
|
|
727
|
-
registrations = [mcpRegistration];
|
|
728
|
-
return refreshIdeTools();
|
|
729
|
-
};
|
|
730
|
-
|
|
731
|
-
const reconcileOnce = async ({ probeIde = true } = {}) => {
|
|
732
|
-
if (disposed) return false;
|
|
714
|
+
const refreshIdeTools = async () => {
|
|
715
|
+
if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
|
|
716
|
+
// Reconcile the MCP servers above, then replay the tool registry so the
|
|
717
|
+
// refreshed catalog lands in the next model request's tool snapshot.
|
|
718
|
+
// Without this, a session that already captured a snapshot keeps the old
|
|
719
|
+
// tool list until the session is reopened.
|
|
720
|
+
if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
|
|
721
|
+
return await waitForIdeTools();
|
|
722
|
+
};
|
|
733
723
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
724
|
+
/**
|
|
725
|
+
* 按配置给**每个**已打开的项目各注册一个 MCP server。
|
|
726
|
+
*
|
|
727
|
+
* server 名就是项目的工具前缀(root = `ide`,子项目 = 目录名),所以 `editor.set`
|
|
728
|
+
* 的 key 与 `serverConfig` 里带的项目路径必须成对出现 —— 一个 server 只服务一个
|
|
729
|
+
* 项目(路径走 header,注册后无法逐次改写)。
|
|
730
|
+
*
|
|
731
|
+
* @param {ReadonlyArray<{ project: { path: string, server: string }, port: number }>} bindings
|
|
732
|
+
*/
|
|
733
|
+
const activateIde = async (bindings) => {
|
|
734
|
+
const mcpRegistration = await ctx.mcp.transform((editor) => {
|
|
735
|
+
for (const binding of bindings) {
|
|
736
|
+
editor.set(
|
|
737
|
+
binding.project.server,
|
|
738
|
+
serverConfig(binding.port, binding.project.path, {executionTimeoutMs}),
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
registrations = [mcpRegistration];
|
|
743
|
+
return refreshIdeTools();
|
|
744
|
+
};
|
|
745
745
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
const port = await portForProject(project);
|
|
749
|
-
if (port !== undefined) bindings.push({ project, port });
|
|
750
|
-
}
|
|
746
|
+
const reconcileOnce = async ({probeIde = true} = {}) => {
|
|
747
|
+
if (disposed) return false;
|
|
751
748
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
749
|
+
// 未配置:没有任何项目要接入。
|
|
750
|
+
if (!config) {
|
|
751
|
+
await deactivateIde();
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
// Manual: only the commands probe the IDE. Setup calls this with
|
|
755
|
+
// `probeIde: false` so it never connects until a command asks.
|
|
756
|
+
if (!probeIde) {
|
|
757
|
+
await deactivateIde();
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
761
760
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
const enqueue = (task) => {
|
|
768
|
-
reconcileBusy += 1;
|
|
769
|
-
const next = reconcileChain.then(task);
|
|
770
|
-
reconcileChain = next.then(
|
|
771
|
-
() => {},
|
|
772
|
-
() => {},
|
|
773
|
-
);
|
|
774
|
-
return next.finally(() => {
|
|
775
|
-
reconcileBusy -= 1;
|
|
776
|
-
});
|
|
777
|
-
};
|
|
778
|
-
const reconcile = (options) => enqueue(() => reconcileOnce(options));
|
|
761
|
+
const bindings = [];
|
|
762
|
+
for (const project of currentProjects()) {
|
|
763
|
+
const port = await portForProject(project);
|
|
764
|
+
if (port !== undefined) bindings.push({project, port});
|
|
765
|
+
}
|
|
779
766
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
* `classifyIdeFailure` maps to `project`. Any other outcome (a real result,
|
|
790
|
-
* or an unrelated tool error) means the project WAS resolved, i.e. open.
|
|
791
|
-
*
|
|
792
|
-
* A transport failure (`undefined`) counts as "not confirmed open", so the
|
|
793
|
-
* caller still asks the IDE to open the project: `open -a` is idempotent,
|
|
794
|
-
* and the only cost is a redundant focus.
|
|
795
|
-
*
|
|
796
|
-
* 这个判定现在只回答一个问题:"这个 IDE 里打开着这个项目吗?" —— 端口本身由
|
|
797
|
-
* `IDE_PORTS` 按 IDE 唯一确定,不再需要靠它去区分端口归属。
|
|
798
|
-
*
|
|
799
|
-
* ⚠️ 实测(2026-10-16,IntelliJ IDEA 2026.2 + PyCharm 2026.2):请求路径**落在某个已打开
|
|
800
|
-
* 项目的目录树内**时,IDE 会把它解析到那个项目 —— `get_project_modules` 正常返回它的模块,
|
|
801
|
-
* 而不是报错。只有路径完全不在任何已打开项目下(`/tmp`、不存在的路径)才报
|
|
802
|
-
* "Unable to determine the target project"。
|
|
803
|
-
*
|
|
804
|
-
* 因此「子项目与 root 用同一个 IDE」时必须知道:只要那个 IDE 打开了 root,子项目就会被判成
|
|
805
|
-
* "已打开" —— 它确实是 root 项目的一部分,工具能操作它的文件,只是语言支持按 **root 项目**
|
|
806
|
-
* 走(Python 子项目被 root 的 Web 模块解析,符号/检查会不准)。这是 IDE 的目录包含语义,
|
|
807
|
-
* 不是探测可以绕过的;要让子项目按自己的模块解析,得让它在那个 IDE 里**单独打开**一个项目
|
|
808
|
-
* —— 或者(正常做法)给它配它真正被打开的那个 IDE。
|
|
809
|
-
*
|
|
810
|
-
* A transport failure (`undefined`) counts as "not confirmed open", so the
|
|
811
|
-
* caller still asks the IDE to open the project: `open -a` is idempotent,
|
|
812
|
-
* and the only cost is a redundant focus.
|
|
813
|
-
*
|
|
814
|
-
* @param {number} port
|
|
815
|
-
* @param {string} path
|
|
816
|
-
*/
|
|
817
|
-
const isProjectOpen = async (port, path) => {
|
|
818
|
-
const result = await callTool(port, path, 'get_project_modules', {}, {
|
|
819
|
-
timeoutMs: mcpProbeTimeoutMs,
|
|
820
|
-
}).catch(() => undefined);
|
|
821
|
-
if (!result) return false;
|
|
822
|
-
if (result.isError !== true) return true;
|
|
823
|
-
const text = (result.content ?? [])
|
|
824
|
-
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
825
|
-
.map((part) => part.text)
|
|
826
|
-
.join('\n');
|
|
827
|
-
return classifyIdeFailure({ message: text }, serverNames()) !== 'project';
|
|
828
|
-
};
|
|
767
|
+
// Always tear the servers down and register them again. OpenCode's reconcile
|
|
768
|
+
// SKIPS a re-`set` whose config is unchanged, so re-registering without a
|
|
769
|
+
// teardown would leave a dropped server in place forever. The commands and
|
|
770
|
+
// the request hooks are rare; rebuilding costs one restart, which is the
|
|
771
|
+
// right trade for never leaving a dead registration behind.
|
|
772
|
+
await deactivateIde();
|
|
773
|
+
if (bindings.length === 0) return false;
|
|
774
|
+
return activateIde(bindings);
|
|
775
|
+
};
|
|
829
776
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
777
|
+
// Serialize reconciles so overlapping callers can never register/activate
|
|
778
|
+
// the IDE concurrently (which would leak registrations).
|
|
779
|
+
let reconcileChain = Promise.resolve();
|
|
780
|
+
// Non-zero while a reconcile is queued or running.
|
|
781
|
+
let reconcileBusy = 0;
|
|
782
|
+
const enqueue = (task) => {
|
|
783
|
+
reconcileBusy += 1;
|
|
784
|
+
const next = reconcileChain.then(task);
|
|
785
|
+
reconcileChain = next.then(
|
|
786
|
+
() => {
|
|
787
|
+
},
|
|
788
|
+
() => {
|
|
789
|
+
},
|
|
790
|
+
);
|
|
791
|
+
return next.finally(() => {
|
|
792
|
+
reconcileBusy -= 1;
|
|
793
|
+
});
|
|
794
|
+
};
|
|
795
|
+
const reconcile = (options) => enqueue(() => reconcileOnce(options));
|
|
840
796
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
797
|
+
/**
|
|
798
|
+
* Is `path` actually OPEN in the IDE that serves `port`?
|
|
799
|
+
*
|
|
800
|
+
* A live endpoint only means the IDE is RUNNING — it may have a different
|
|
801
|
+
* project open (or sit on the Welcome screen), while everything we do is
|
|
802
|
+
* scoped to THIS project. So ask the IDE directly with one project-scoped
|
|
803
|
+
* call: the server resolves the project from `IJ_MCP_SERVER_PROJECT_PATH`
|
|
804
|
+
* BEFORE running the tool, and for a project that is not open it fails with
|
|
805
|
+
* "Unable to determine the target project ..." — the same shape
|
|
806
|
+
* `classifyIdeFailure` maps to `project`. Any other outcome (a real result,
|
|
807
|
+
* or an unrelated tool error) means the project WAS resolved, i.e. open.
|
|
808
|
+
*
|
|
809
|
+
* A transport failure (`undefined`) counts as "not confirmed open", so the
|
|
810
|
+
* caller still asks the IDE to open the project: `open -a` is idempotent,
|
|
811
|
+
* and the only cost is a redundant focus.
|
|
812
|
+
*
|
|
813
|
+
* 这个判定现在只回答一个问题:"这个 IDE 里打开着这个项目吗?" —— 端口本身由
|
|
814
|
+
* `IDE_PORTS` 按 IDE 唯一确定,不再需要靠它去区分端口归属。
|
|
815
|
+
*
|
|
816
|
+
* ⚠️ 实测(2026-10-16,IntelliJ IDEA 2026.2 + PyCharm 2026.2):请求路径**落在某个已打开
|
|
817
|
+
* 项目的目录树内**时,IDE 会把它解析到那个项目 —— `get_project_modules` 正常返回它的模块,
|
|
818
|
+
* 而不是报错。只有路径完全不在任何已打开项目下(`/tmp`、不存在的路径)才报
|
|
819
|
+
* "Unable to determine the target project"。
|
|
820
|
+
*
|
|
821
|
+
* 因此「子项目与 root 用同一个 IDE」时必须知道:只要那个 IDE 打开了 root,子项目就会被判成
|
|
822
|
+
* "已打开" —— 它确实是 root 项目的一部分,工具能操作它的文件,只是语言支持按 **root 项目**
|
|
823
|
+
* 走(Python 子项目被 root 的 Web 模块解析,符号/检查会不准)。这是 IDE 的目录包含语义,
|
|
824
|
+
* 不是探测可以绕过的;要让子项目按自己的模块解析,得让它在那个 IDE 里**单独打开**一个项目
|
|
825
|
+
* —— 或者(正常做法)给它配它真正被打开的那个 IDE。
|
|
826
|
+
*
|
|
827
|
+
* A transport failure (`undefined`) counts as "not confirmed open", so the
|
|
828
|
+
* caller still asks the IDE to open the project: `open -a` is idempotent,
|
|
829
|
+
* and the only cost is a redundant focus.
|
|
830
|
+
*
|
|
831
|
+
* @param {number} port
|
|
832
|
+
* @param {string} path
|
|
833
|
+
*/
|
|
834
|
+
const isProjectOpen = async (port, path) => {
|
|
835
|
+
const result = await callTool(port, path, 'get_project_modules', {}, {
|
|
836
|
+
timeoutMs: mcpProbeTimeoutMs,
|
|
837
|
+
}).catch(() => undefined);
|
|
838
|
+
if (!result) return false;
|
|
839
|
+
if (result.isError !== true) return true;
|
|
840
|
+
const text = (result.content ?? [])
|
|
841
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
842
|
+
.map((part) => part.text)
|
|
843
|
+
.join('\n');
|
|
844
|
+
return classifyIdeFailure({message: text}, serverNames()) !== 'project';
|
|
845
|
+
};
|
|
861
846
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if (!state) {
|
|
872
|
-
state = {
|
|
873
|
-
/** @type {Promise<IdeCommandResult> | undefined} */
|
|
874
|
-
inFlight: undefined,
|
|
875
|
-
noticeSent: false,
|
|
876
|
-
guard: createLaunchGuard({ cooldownMs: launchCooldownMs, maxAttempts: launchMaxAttempts }),
|
|
847
|
+
/**
|
|
848
|
+
* 这个项目的 IDE 端口上,服务着它吗?是就返回端口,否则 `undefined`。
|
|
849
|
+
*
|
|
850
|
+
* @param {{ path: string, app: string }} project
|
|
851
|
+
*/
|
|
852
|
+
const portForProject = async (project) => {
|
|
853
|
+
const port = portFor(project);
|
|
854
|
+
if (port === undefined) return undefined;
|
|
855
|
+
return (await isProjectOpen(port, project.path)) ? port : undefined;
|
|
877
856
|
};
|
|
878
|
-
launchStates.set(path, state);
|
|
879
|
-
}
|
|
880
|
-
return state;
|
|
881
|
-
};
|
|
882
857
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
if (serving !== undefined) {
|
|
904
|
-
state.guard.reset();
|
|
905
|
-
state.noticeSent = false;
|
|
906
|
-
return { status: 'connected', port: serving, project, unavailableNoticeSent: false };
|
|
907
|
-
}
|
|
908
|
-
// 需要一个 app 才能拉起。拿不到 app 的原因有两种,交给 autoLaunchSupported 区分。
|
|
909
|
-
const app = launchEnabled ? appNameFor(project.app) : undefined;
|
|
910
|
-
if (!app) {
|
|
911
|
-
return { status: autoLaunchSupported() ? 'disabled' : 'manual-launch', project };
|
|
912
|
-
}
|
|
913
|
-
if (state.guard.exhausted() || state.guard.coolingDown()) {
|
|
914
|
-
// There is no reason to wait here: MCP is disabled/not ready, and
|
|
915
|
-
// the user must enable it in the IDE before retrying the command.
|
|
916
|
-
await reportUnavailable(false);
|
|
917
|
-
return { status: 'mcp-unavailable', opened: false, project, unavailableNoticeSent: state.noticeSent };
|
|
918
|
-
}
|
|
919
|
-
state.guard.markSpawn();
|
|
920
|
-
const launched = await openInIde(project.path, { app });
|
|
921
|
-
if (!launched) return { status: 'launch-failed', project };
|
|
922
|
-
const started = await waitForProjectServer(project);
|
|
923
|
-
if (started !== undefined) {
|
|
924
|
-
state.guard.reset();
|
|
925
|
-
state.noticeSent = false;
|
|
926
|
-
return { status: 'connected', port: started, project, unavailableNoticeSent: false };
|
|
927
|
-
}
|
|
928
|
-
await reportUnavailable(true);
|
|
929
|
-
return { status: 'mcp-unavailable', opened: true, project, unavailableNoticeSent: state.noticeSent };
|
|
930
|
-
})();
|
|
931
|
-
state.inFlight = run;
|
|
932
|
-
void run
|
|
933
|
-
.catch(() => {})
|
|
934
|
-
.then(() => {
|
|
935
|
-
if (state.inFlight === run) state.inFlight = undefined;
|
|
936
|
-
});
|
|
937
|
-
return run;
|
|
938
|
-
};
|
|
858
|
+
/**
|
|
859
|
+
* `open -a` only requests activation/opening. A cold IDE needs time before
|
|
860
|
+
* its bundled MCP server listens, so poll until the endpoint appears AND
|
|
861
|
+
* answers for THIS project. This is the step that makes a single
|
|
862
|
+
* `/open-in-ide` enough on a cold start.
|
|
863
|
+
*
|
|
864
|
+
* @param {{ path: string, app: string }} project
|
|
865
|
+
* @returns {Promise<number | undefined>}
|
|
866
|
+
*/
|
|
867
|
+
const waitForProjectServer = async (project) => {
|
|
868
|
+
const deadline = Date.now() + mcpStartTimeoutMs;
|
|
869
|
+
while (Date.now() < deadline) {
|
|
870
|
+
const port = await portForProject(project).catch(() => undefined);
|
|
871
|
+
if (port !== undefined) return port;
|
|
872
|
+
const remaining = deadline - Date.now();
|
|
873
|
+
if (remaining <= 0) break;
|
|
874
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(MCP_START_POLL_MS, remaining)));
|
|
875
|
+
}
|
|
876
|
+
return undefined;
|
|
877
|
+
};
|
|
939
878
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
return results[0];
|
|
961
|
-
}
|
|
962
|
-
let toolsReady = false;
|
|
963
|
-
try {
|
|
964
|
-
// 重新探测一次并整体注册:ensureProject 只保证 IDE 就绪,注册要按"当前真正
|
|
965
|
-
// 打开着哪些项目"重来一遍,这样 `/open-in-ide` 每次都是同一套结果。
|
|
966
|
-
toolsReady = (await reconcile({ probeIde: true })) === true;
|
|
967
|
-
} catch {
|
|
968
|
-
// capability load is best effort; feedback still matters
|
|
969
|
-
}
|
|
970
|
-
const connectedProjects = connected.map((result) => result.project);
|
|
971
|
-
return toolsReady
|
|
972
|
-
? { status: 'connected', port: connected[0].port, projects: connectedProjects }
|
|
973
|
-
: { ...connected[0], status: 'tools-loading', projects: connectedProjects };
|
|
974
|
-
};
|
|
879
|
+
/**
|
|
880
|
+
* 每个项目各自的启动状态:in-flight、拉起冷却、是否已发过通知。
|
|
881
|
+
*
|
|
882
|
+
* 按项目分开是必需的:IDEA 与 PyCharm 是两个独立进程,一个的冷却不能拖住另一个;
|
|
883
|
+
* 而同一个项目的重复调用仍然共享一次拉起(re-entrant)。
|
|
884
|
+
*/
|
|
885
|
+
const launchStates = new Map();
|
|
886
|
+
const launchStateFor = (path) => {
|
|
887
|
+
let state = launchStates.get(path);
|
|
888
|
+
if (!state) {
|
|
889
|
+
state = {
|
|
890
|
+
/** @type {Promise<IdeCommandResult> | undefined} */
|
|
891
|
+
inFlight: undefined,
|
|
892
|
+
noticeSent: false,
|
|
893
|
+
guard: createLaunchGuard({cooldownMs: launchCooldownMs, maxAttempts: launchMaxAttempts}),
|
|
894
|
+
};
|
|
895
|
+
launchStates.set(path, state);
|
|
896
|
+
}
|
|
897
|
+
return state;
|
|
898
|
+
};
|
|
975
899
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
900
|
+
/**
|
|
901
|
+
* 确保 `project` 的 IDE 正开着、并且打开着这个项目;返回它的端口。
|
|
902
|
+
*
|
|
903
|
+
* @param {{ path: string, server: string, app: string, label: string, isRoot: boolean }} project
|
|
904
|
+
* @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
|
|
905
|
+
* @returns {Promise<IdeCommandResult>}
|
|
906
|
+
*/
|
|
907
|
+
const ensureProject = (project, {onUnavailable} = {}) => {
|
|
908
|
+
const state = launchStateFor(project.path);
|
|
909
|
+
if (state.inFlight) return state.inFlight;
|
|
910
|
+
const reportUnavailable = async (opened) => {
|
|
911
|
+
if (state.noticeSent || typeof onUnavailable !== 'function') return;
|
|
912
|
+
// Fast-fail: notify as soon as the endpoint is known to be missing so
|
|
913
|
+
// the user can enable MCP + Brave Mode and retry the command.
|
|
914
|
+
const delivered = await onUnavailable({status: 'mcp-unavailable', opened, project});
|
|
915
|
+
if (delivered) state.noticeSent = true;
|
|
916
|
+
};
|
|
917
|
+
const run = (async () => {
|
|
918
|
+
// 已经开着并且就打开着这个项目:什么都不用做。
|
|
919
|
+
const serving = await portForProject(project).catch(() => undefined);
|
|
920
|
+
if (serving !== undefined) {
|
|
921
|
+
state.guard.reset();
|
|
922
|
+
state.noticeSent = false;
|
|
923
|
+
return {status: 'connected', port: serving, project, unavailableNoticeSent: false};
|
|
924
|
+
}
|
|
925
|
+
// 需要一个 app 才能拉起。拿不到 app 的原因有两种,交给 autoLaunchSupported 区分。
|
|
926
|
+
const app = launchEnabled ? appNameFor(project.app) : undefined;
|
|
927
|
+
if (!app) {
|
|
928
|
+
return {status: autoLaunchSupported() ? 'disabled' : 'manual-launch', project};
|
|
929
|
+
}
|
|
930
|
+
if (state.guard.exhausted() || state.guard.coolingDown()) {
|
|
931
|
+
// There is no reason to wait here: MCP is disabled/not ready, and
|
|
932
|
+
// the user must enable it in the IDE before retrying the command.
|
|
933
|
+
await reportUnavailable(false);
|
|
934
|
+
return {status: 'mcp-unavailable', opened: false, project, unavailableNoticeSent: state.noticeSent};
|
|
935
|
+
}
|
|
936
|
+
state.guard.markSpawn();
|
|
937
|
+
const launched = await openInIde(project.path, {app});
|
|
938
|
+
if (!launched) return {status: 'launch-failed', project};
|
|
939
|
+
const started = await waitForProjectServer(project);
|
|
940
|
+
if (started !== undefined) {
|
|
941
|
+
state.guard.reset();
|
|
942
|
+
state.noticeSent = false;
|
|
943
|
+
return {status: 'connected', port: started, project, unavailableNoticeSent: false};
|
|
944
|
+
}
|
|
945
|
+
await reportUnavailable(true);
|
|
946
|
+
return {status: 'mcp-unavailable', opened: true, project, unavailableNoticeSent: state.noticeSent};
|
|
947
|
+
})();
|
|
948
|
+
state.inFlight = run;
|
|
949
|
+
void run
|
|
950
|
+
.catch(() => {
|
|
951
|
+
})
|
|
952
|
+
.then(() => {
|
|
953
|
+
if (state.inFlight === run) state.inFlight = undefined;
|
|
954
|
+
});
|
|
955
|
+
return run;
|
|
956
|
+
};
|
|
1016
957
|
|
|
1017
|
-
|
|
1018
|
-
|
|
958
|
+
/**
|
|
959
|
+
* `/open-in-ide` 的主流程:让每个已登记项目的 IDE 都就绪,然后把它们注册进
|
|
960
|
+
* OpenCode。
|
|
961
|
+
*
|
|
962
|
+
* 每个项目各自拉起**自己的** IDE —— root 在 IDEA、某子项目在 PyCharm 是常态,
|
|
963
|
+
* 所以这里不是"连一个 IDE",而是"连一组",彼此并行。
|
|
964
|
+
*
|
|
965
|
+
* @param {{ onUnavailable?: (result: IdeCommandResult) => Promise<boolean> }} [options]
|
|
966
|
+
* @returns {Promise<IdeCommandResult>}
|
|
967
|
+
*/
|
|
968
|
+
const runOpenInIdea = async ({onUnavailable} = {}) => {
|
|
969
|
+
const projects = currentProjects();
|
|
970
|
+
if (projects.length === 0) return {status: 'not-configured'};
|
|
971
|
+
const results = await Promise.all(
|
|
972
|
+
projects.map((project) => ensureProject(project, {onUnavailable})),
|
|
973
|
+
);
|
|
974
|
+
const connected = results.filter((result) => result.status === 'connected');
|
|
975
|
+
if (connected.length === 0) {
|
|
976
|
+
// 一个都没连上:注销残留注册,并挑一个代表性结果回去(文案已带上具体项目)。
|
|
977
|
+
await reconcile({probeIde: false}).catch(() => {
|
|
978
|
+
});
|
|
979
|
+
return results[0];
|
|
980
|
+
}
|
|
981
|
+
let toolsReady = false;
|
|
982
|
+
try {
|
|
983
|
+
// 重新探测一次并整体注册:ensureProject 只保证 IDE 就绪,注册要按"当前真正
|
|
984
|
+
// 打开着哪些项目"重来一遍,这样 `/open-in-ide` 每次都是同一套结果。
|
|
985
|
+
toolsReady = (await reconcile({probeIde: true})) === true;
|
|
986
|
+
} catch {
|
|
987
|
+
// capability load is best effort; feedback still matters
|
|
988
|
+
}
|
|
989
|
+
const connectedProjects = connected.map((result) => result.project);
|
|
990
|
+
return toolsReady
|
|
991
|
+
? {status: 'connected', port: connected[0].port, projects: connectedProjects}
|
|
992
|
+
: {...connected[0], status: 'tools-loading', projects: connectedProjects};
|
|
993
|
+
};
|
|
1019
994
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
995
|
+
/**
|
|
996
|
+
* Coalesced background reconnect.
|
|
997
|
+
*
|
|
998
|
+
* The `context` hook latches the rebuild the moment a request shows the tools
|
|
999
|
+
* are gone, but a latch alone only fires on the NEXT user message — one whole
|
|
1000
|
+
* turn later, which is exactly the gap that made a dead session sit there
|
|
1001
|
+
* until `/open-in-ide` was run by hand. This runs out of band (it never
|
|
1002
|
+
* blocks the request) so the next request — the following agent-loop step, or
|
|
1003
|
+
* any later message — already carries the tools again.
|
|
1004
|
+
*
|
|
1005
|
+
* @returns {Promise<void>}
|
|
1006
|
+
*/
|
|
1007
|
+
/** @type {Promise<void> | undefined} */
|
|
1008
|
+
let reviveInFlight;
|
|
1009
|
+
const reviveIde = () => {
|
|
1010
|
+
// 配置 gate 住每一次重连,包括后台发起的那些:后台尝试绝不能把用户已经用
|
|
1011
|
+
// `/delete-in-ide` 删掉的项目重新拉起来。
|
|
1012
|
+
if (disposed || !config) return Promise.resolve();
|
|
1013
|
+
if (reviveInFlight) return reviveInFlight;
|
|
1014
|
+
const run = (async () => {
|
|
1015
|
+
try {
|
|
1016
|
+
const result = await runOpenInIdea();
|
|
1017
|
+
// `/delete-in-ide` 可能在这期间落地;它赢。
|
|
1018
|
+
if (!config) {
|
|
1019
|
+
await deactivateIde();
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (result.port !== undefined) {
|
|
1023
|
+
needsRebuild = false;
|
|
1024
|
+
}
|
|
1025
|
+
} catch {
|
|
1026
|
+
// best effort: the latch stays set, and the prompt hook retries
|
|
1027
|
+
}
|
|
1028
|
+
})();
|
|
1029
|
+
reviveInFlight = run;
|
|
1030
|
+
void run.then(() => {
|
|
1031
|
+
if (reviveInFlight === run) reviveInFlight = undefined;
|
|
1032
|
+
});
|
|
1033
|
+
return run;
|
|
1034
|
+
};
|
|
1036
1035
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
// resident apps are started, `idea-run-config-manager` fixes which repeated
|
|
1040
|
-
// flows are solidified into configurations.
|
|
1041
|
-
//
|
|
1042
|
-
// They are registered **once, when the plugin loads** — with no condition on
|
|
1043
|
-
// the project config and none on the IDE's tool catalog. That is a deliberate
|
|
1044
|
-
// reversal of the earlier design, which offered a skill only after the tools
|
|
1045
|
-
// it drives were actually served. The gate looked right in isolation and was
|
|
1046
|
-
// wrong in use: the client snapshots the skill list when the session starts,
|
|
1047
|
-
// so anything registered later (by `/open-in-ide` finishing, or by a catalog
|
|
1048
|
-
// refresh) stays invisible until the page is reloaded — the user reads that
|
|
1049
|
-
// as "the skill is missing" while the registry already has it.
|
|
1050
|
-
//
|
|
1051
|
-
// What the old gate protected against is handled where it belongs: the skill
|
|
1052
|
-
// text itself says what to do when a tool is absent ("hand it back to the
|
|
1053
|
-
// user", "run /open-in-ide"). A missing tool is a fact the model can read and
|
|
1054
|
-
// act on; an invisible skill is not.
|
|
1055
|
-
//
|
|
1056
|
-
// The frontmatter `requires:` key is gone with the gate. It was this
|
|
1057
|
-
// package's own metadata, never read by OpenCode, and keeping it after the
|
|
1058
|
-
// gate disappeared would look like it still decided something.
|
|
1059
|
-
//
|
|
1060
|
-
// Deliberately NOT wrapped in a catch: these are core features of the plugin,
|
|
1061
|
-
// so a rejected definition must fail loudly. Swallowing it would ship a
|
|
1062
|
-
// silently incomplete plugin — which is exactly how 0.0.6 lost
|
|
1063
|
-
// `/open-in-ide` without anyone noticing until the command was gone.
|
|
1064
|
-
const registerSkills = async () => {
|
|
1065
|
-
if (typeof ctx.skill?.transform !== 'function') return;
|
|
1066
|
-
skillRegistration = await ctx.skill.transform((editor) => {
|
|
1067
|
-
for (const { skill } of readSkills()) editor.add(skill);
|
|
1068
|
-
});
|
|
1069
|
-
};
|
|
1070
|
-
await registerSkills();
|
|
1036
|
+
// Setup never probes or connects any IDE — everything is on the commands.
|
|
1037
|
+
await reconcile({probeIde: false});
|
|
1071
1038
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
// 插件自己摸底,而不是让模型去"找 IDE" —— 它找到的办法通常是 `ps aux | grep`(只能
|
|
1089
|
-
// 看到**正在运行**的进程,装了没开就漏),又慢又不准。这里是按应用目录读一次得到的。
|
|
1090
|
-
const installed = detectInstalledIdes();
|
|
1091
|
-
const missing = SUPPORTED_IDES.filter((app) => !installed.includes(app));
|
|
1092
|
-
const detectLines = installed.length
|
|
1093
|
-
? [
|
|
1094
|
-
`插件已探测本机安装情况:装有 ${installed.map((app) => IDE_LABELS[app] ?? app).join('、')}` +
|
|
1095
|
-
(missing.length
|
|
1096
|
-
? `;没找到 ${missing.map((app) => IDE_LABELS[app] ?? app).join('、')}(用户若要用它,需要自己先装)`
|
|
1097
|
-
: '') +
|
|
1098
|
-
'。',
|
|
1099
|
-
'**不要再自己去跑命令或翻进程** —— 这是按应用目录得到的结论,进程里没有不代表没装。',
|
|
1100
|
-
'',
|
|
1101
|
-
]
|
|
1102
|
-
: [];
|
|
1103
|
-
const text = [
|
|
1104
|
-
`${NOTICE_PREFIX}需要确定 ${where} 用哪个 IDE 打开。`,
|
|
1105
|
-
'',
|
|
1106
|
-
...detectLines,
|
|
1107
|
-
'请向用户提问,并**在得到答复后调用 `configure_ide_project` 工具**写入配置。可选值只有两个:',
|
|
1108
|
-
'- `idea` → IntelliJ IDEA',
|
|
1109
|
-
'- `pycharm` → PyCharm',
|
|
1110
|
-
'',
|
|
1111
|
-
'要选它**真正被打开**的那个 IDE(例如 Python 子项目通常是 PyCharm;选错了,IDE 会把请求解析到',
|
|
1112
|
-
'包含它的另一个已打开项目上,符号检索与检查会按那个项目的语言支持走)。',
|
|
1113
|
-
'',
|
|
1114
|
-
`调用形式:${call}`,
|
|
1115
|
-
'',
|
|
1116
|
-
'不要替用户决定,也不要只回答"已配置" —— 没有真正调用工具就等于没配置。',
|
|
1117
|
-
].join('\n');
|
|
1118
|
-
try {
|
|
1119
|
-
await ctx.session.prompt({
|
|
1120
|
-
...input.prompt,
|
|
1121
|
-
sessionID: input.sessionID,
|
|
1122
|
-
text,
|
|
1123
|
-
metadata: {
|
|
1124
|
-
displayText: `请选择 ${where} 使用的 IDE:IntelliJ IDEA 还是 PyCharm?`,
|
|
1125
|
-
comments: [],
|
|
1126
|
-
},
|
|
1127
|
-
delivery: input.delivery,
|
|
1128
|
-
});
|
|
1129
|
-
} catch {
|
|
1130
|
-
// best effort
|
|
1131
|
-
}
|
|
1132
|
-
};
|
|
1039
|
+
const sendFeedback = async (input, result) => {
|
|
1040
|
+
if (feedback !== 'message' || typeof ctx.session?.prompt !== 'function') return false;
|
|
1041
|
+
try {
|
|
1042
|
+
await ctx.session.prompt({
|
|
1043
|
+
...input.prompt,
|
|
1044
|
+
sessionID: input.sessionID,
|
|
1045
|
+
text: idePromptText(result),
|
|
1046
|
+
metadata: idePromptMetadata(result),
|
|
1047
|
+
delivery: input.delivery,
|
|
1048
|
+
});
|
|
1049
|
+
return true;
|
|
1050
|
+
} catch {
|
|
1051
|
+
// feedback is best effort
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1133
1055
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
current = withSubproject(current, absPath, ide);
|
|
1169
|
-
await writeConfig(current);
|
|
1170
|
-
await runOpenInIdea();
|
|
1171
|
-
return { content: `已登记子项目 \`${rawPath}\`(${IDE_LABELS[ide]})并接入。` };
|
|
1172
|
-
};
|
|
1056
|
+
// The shipped skills are static knowledge (`skills/<id>/SKILL.md`) that write
|
|
1057
|
+
// a project's own rules into its `AGENTS.md`: `idea-standalone` fixes how
|
|
1058
|
+
// resident apps are started, `idea-run-config-manager` fixes which repeated
|
|
1059
|
+
// flows are solidified into configurations.
|
|
1060
|
+
//
|
|
1061
|
+
// They are registered **once, when the plugin loads** — with no condition on
|
|
1062
|
+
// the project config and none on the IDE's tool catalog. That is a deliberate
|
|
1063
|
+
// reversal of the earlier design, which offered a skill only after the tools
|
|
1064
|
+
// it drives were actually served. The gate looked right in isolation and was
|
|
1065
|
+
// wrong in use: the client snapshots the skill list when the session starts,
|
|
1066
|
+
// so anything registered later (by `/open-in-ide` finishing, or by a catalog
|
|
1067
|
+
// refresh) stays invisible until the page is reloaded — the user reads that
|
|
1068
|
+
// as "the skill is missing" while the registry already has it.
|
|
1069
|
+
//
|
|
1070
|
+
// What the old gate protected against is handled where it belongs: the skill
|
|
1071
|
+
// text itself says what to do when a tool is absent ("hand it back to the
|
|
1072
|
+
// user", "run /open-in-ide"). A missing tool is a fact the model can read and
|
|
1073
|
+
// act on; an invisible skill is not.
|
|
1074
|
+
//
|
|
1075
|
+
// The frontmatter `requires:` key is gone with the gate. It was this
|
|
1076
|
+
// package's own metadata, never read by OpenCode, and keeping it after the
|
|
1077
|
+
// gate disappeared would look like it still decided something.
|
|
1078
|
+
//
|
|
1079
|
+
// Deliberately NOT wrapped in a catch: these are core features of the plugin,
|
|
1080
|
+
// so a rejected definition must fail loudly. Swallowing it would ship a
|
|
1081
|
+
// silently incomplete plugin — which is exactly how 0.0.6 lost
|
|
1082
|
+
// `/open-in-ide` without anyone noticing until the command was gone.
|
|
1083
|
+
const registerSkills = async () => {
|
|
1084
|
+
if (typeof ctx.skill?.transform !== 'function') return;
|
|
1085
|
+
skillRegistration = await ctx.skill.transform((editor) => {
|
|
1086
|
+
for (const {skill} of readSkills()) editor.add(skill);
|
|
1087
|
+
});
|
|
1088
|
+
};
|
|
1089
|
+
await registerSkills();
|
|
1173
1090
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1091
|
+
/**
|
|
1092
|
+
* 问用户"这个项目用哪个 IDE",由模型转达并回写。
|
|
1093
|
+
*
|
|
1094
|
+
* 插件自己不能弹选择框,也不能挂起等待;唯一可靠的通道是让模型去问、再把答案用
|
|
1095
|
+
* `configure_ide_project` 结构化地写回来。所以这里发的**不是通知而是任务**:文本
|
|
1096
|
+
* 里必须写明"调用了工具才算完成",否则模型很容易只回一句"好的,用 PyCharm"。
|
|
1097
|
+
*
|
|
1098
|
+
* @param {{ sessionID?: string, prompt?: unknown, delivery?: unknown }} input
|
|
1099
|
+
* @param {{ label: string, isRoot: boolean }} target
|
|
1100
|
+
*/
|
|
1101
|
+
const askIdeChoice = async (input, target) => {
|
|
1102
|
+
if (typeof ctx.session?.prompt !== 'function') return;
|
|
1103
|
+
const where = target.isRoot ? `根项目(${projectPath})` : `子项目 ${target.label}`;
|
|
1104
|
+
const call = target.isRoot
|
|
1105
|
+
? 'configure_ide_project({ ide: "<idea|pycharm>" })'
|
|
1106
|
+
: `configure_ide_project({ path: "${target.label}", ide: "<idea|pycharm>" })`;
|
|
1107
|
+
// 插件自己摸底,而不是让模型去"找 IDE" —— 它找到的办法通常是 `ps aux | grep`(只能
|
|
1108
|
+
// 看到**正在运行**的进程,装了没开就漏),又慢又不准。这里是按应用目录读一次得到的。
|
|
1109
|
+
const installed = detectInstalledIdes();
|
|
1110
|
+
const missing = SUPPORTED_IDES.filter((app) => !installed.includes(app));
|
|
1111
|
+
const detectLines = installed.length
|
|
1112
|
+
? [
|
|
1113
|
+
`插件已探测本机安装情况:装有 ${installed.map((app) => IDE_LABELS[app] ?? app).join('、')}` +
|
|
1114
|
+
(missing.length
|
|
1115
|
+
? `;没找到 ${missing.map((app) => IDE_LABELS[app] ?? app).join('、')}(用户若要用它,需要自己先装)`
|
|
1116
|
+
: '') +
|
|
1117
|
+
'。',
|
|
1118
|
+
'**不要再自己去跑命令或翻进程** —— 这是按应用目录得到的结论,进程里没有不代表没装。',
|
|
1119
|
+
'',
|
|
1120
|
+
]
|
|
1121
|
+
: [];
|
|
1122
|
+
const text = [
|
|
1123
|
+
`${NOTICE_PREFIX}需要确定 ${where} 用哪个 IDE 打开。`,
|
|
1124
|
+
'',
|
|
1125
|
+
...detectLines,
|
|
1126
|
+
'请向用户提问,并**在得到答复后调用 `configure_ide_project` 工具**写入配置。可选值只有两个:',
|
|
1127
|
+
'- `idea` → IntelliJ IDEA',
|
|
1128
|
+
'- `pycharm` → PyCharm',
|
|
1129
|
+
'',
|
|
1130
|
+
'要选它**真正被打开**的那个 IDE(例如 Python 子项目通常是 PyCharm;选错了,IDE 会把请求解析到',
|
|
1131
|
+
'包含它的另一个已打开项目上,符号检索与检查会按那个项目的语言支持走)。',
|
|
1132
|
+
'',
|
|
1133
|
+
`调用形式:${call}`,
|
|
1134
|
+
'',
|
|
1135
|
+
'不要替用户决定,也不要只回答"已配置" —— 没有真正调用工具就等于没配置。',
|
|
1136
|
+
].join('\n');
|
|
1137
|
+
try {
|
|
1138
|
+
await ctx.session.prompt({
|
|
1139
|
+
...input.prompt,
|
|
1140
|
+
sessionID: input.sessionID,
|
|
1141
|
+
text,
|
|
1142
|
+
metadata: {
|
|
1143
|
+
displayText: `请选择 ${where} 使用的 IDE:IntelliJ IDEA 还是 PyCharm?`,
|
|
1144
|
+
comments: [],
|
|
1145
|
+
},
|
|
1146
|
+
delivery: input.delivery,
|
|
1147
|
+
});
|
|
1148
|
+
} catch {
|
|
1149
|
+
// best effort
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1198
1152
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
if (!parsed.ok) {
|
|
1212
|
-
await sendFeedback(input, { status: 'bad-argument', message: parsed.message });
|
|
1213
|
-
return;
|
|
1153
|
+
/**
|
|
1154
|
+
* `configure_ide_project` 的执行体:校验后写入配置并立即接入。
|
|
1155
|
+
*
|
|
1156
|
+
* 这是模型把用户的选择写回插件的**唯一**通道,所以它自己也做全套校验:模型可能
|
|
1157
|
+
* 传错路径、传一个不是 IDE 的值,插件不能因为"是模型给的"就放行。
|
|
1158
|
+
*
|
|
1159
|
+
* @param {{ path?: unknown, ide?: unknown }} args
|
|
1160
|
+
*/
|
|
1161
|
+
const configureIdeProject = async (args) => {
|
|
1162
|
+
const ide = typeof args?.ide === 'string' ? args.ide.toLowerCase() : '';
|
|
1163
|
+
if (!SUPPORTED_IDES.includes(ide)) {
|
|
1164
|
+
return {content: `不支持的 IDE \`${String(args?.ide)}\`。可选:${SUPPORTED_IDES.join(' / ')}。`};
|
|
1214
1165
|
}
|
|
1166
|
+
const rawPath = typeof args?.path === 'string' ? args.path.trim() : '';
|
|
1215
1167
|
let current = await readConfig();
|
|
1216
|
-
if (
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
await writeConfig(current);
|
|
1224
|
-
} else if (parsed.app && parsed.app !== current.root.app) {
|
|
1225
|
-
// 直通逃生口:显式带参时改绑 root 的 IDE(模型不配合时用户仍能自救)。
|
|
1226
|
-
current = { root: { ...current.root, app: parsed.app }, children: current.children };
|
|
1227
|
-
await writeConfig(current);
|
|
1168
|
+
if (rawPath === '') {
|
|
1169
|
+
current = current
|
|
1170
|
+
? {root: {...current.root, app: ide}, children: current.children}
|
|
1171
|
+
: {root: {path: projectPath, app: ide}, children: []};
|
|
1172
|
+
await writeConfig(current);
|
|
1173
|
+
await runOpenInIdea();
|
|
1174
|
+
return {content: `已把根项目配置为 ${IDE_LABELS[ide]} 并接入。`};
|
|
1228
1175
|
}
|
|
1229
|
-
/** @type {boolean} */
|
|
1230
|
-
let unavailableNoticeSent = false;
|
|
1231
|
-
const result = await runOpenInIdea({
|
|
1232
|
-
onUnavailable: async (notice) => {
|
|
1233
|
-
unavailableNoticeSent = await sendFeedback(input, notice);
|
|
1234
|
-
return unavailableNoticeSent;
|
|
1235
|
-
},
|
|
1236
|
-
});
|
|
1237
|
-
// The unavailable notice is the final result for this command. If
|
|
1238
|
-
// it was already delivered, do not show the same warning twice.
|
|
1239
|
-
const unavailableAlreadySent = unavailableNoticeSent || result.unavailableNoticeSent;
|
|
1240
|
-
if (!unavailableAlreadySent || result.status !== 'mcp-unavailable') {
|
|
1241
|
-
await sendFeedback(input, result);
|
|
1242
|
-
}
|
|
1243
|
-
},
|
|
1244
|
-
});
|
|
1245
|
-
editor.add({
|
|
1246
|
-
name: 'add-sub-module-in-ide',
|
|
1247
|
-
description: '登记一个子项目(参数:子项目相对 root 的路径);随后由 AI 询问用哪个 IDE',
|
|
1248
|
-
execute: async (input) => {
|
|
1249
|
-
const parsed = parseSubprojectArgument(input?.prompt?.text);
|
|
1250
|
-
if (!parsed.ok) {
|
|
1251
|
-
await sendFeedback(input, { status: 'bad-argument', message: parsed.message });
|
|
1252
|
-
return;
|
|
1253
|
-
}
|
|
1254
|
-
const current = await readConfig();
|
|
1255
1176
|
if (!current) {
|
|
1256
|
-
|
|
1257
|
-
return;
|
|
1177
|
+
return {content: '当前项目还没有 IDE 配置。请先执行 /open-in-ide。'};
|
|
1258
1178
|
}
|
|
1259
|
-
const absPath = resolve(projectPath,
|
|
1179
|
+
const absPath = resolve(projectPath, rawPath);
|
|
1260
1180
|
if (!existsSync(absPath)) {
|
|
1261
|
-
|
|
1262
|
-
status: 'invalid-subproject',
|
|
1263
|
-
message: `目录不存在:${absPath}`,
|
|
1264
|
-
});
|
|
1265
|
-
return;
|
|
1181
|
+
return {content: `目录不存在:${absPath}。`};
|
|
1266
1182
|
}
|
|
1267
1183
|
const verdict = validateSubproject(current, absPath);
|
|
1268
1184
|
if (!verdict.ok) {
|
|
1269
|
-
|
|
1270
|
-
return;
|
|
1185
|
+
return {content: `无法登记子项目:${verdict.message}`};
|
|
1271
1186
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
description: '删除本项目的 IDE 配置:注销全部 IDE MCP 并清除 root 与子项目的登记',
|
|
1278
|
-
execute: async (input) => {
|
|
1279
|
-
await writeConfig(null);
|
|
1280
|
-
// 注销全部 server。走串行化的 reconcile,所以在飞的重建(另一个
|
|
1281
|
-
// `/open-in-ide`、请求期自愈)不会在配置已删之后再注册回来。
|
|
1282
|
-
await reconcile({ probeIde: false });
|
|
1283
|
-
await sendFeedback(input, { status: 'deleted' });
|
|
1284
|
-
},
|
|
1285
|
-
});
|
|
1286
|
-
});
|
|
1287
|
-
}
|
|
1187
|
+
current = withSubproject(current, absPath, ide);
|
|
1188
|
+
await writeConfig(current);
|
|
1189
|
+
await runOpenInIdea();
|
|
1190
|
+
return {content: `已登记子项目 \`${rawPath}\`(${IDE_LABELS[ide]})并接入。`};
|
|
1191
|
+
};
|
|
1288
1192
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1193
|
+
/** `configure_ide_project` 的工具定义。 */
|
|
1194
|
+
const configureIdeTool = () => ({
|
|
1195
|
+
name: 'configure_ide_project',
|
|
1196
|
+
description:
|
|
1197
|
+
'把用户选定的 IDE 写入本项目的 IDE 配置(即"这个项目用哪个 IDE 打开")。' +
|
|
1198
|
+
'仅当用户明确回答了"用 IntelliJ IDEA 还是 PyCharm"之后调用,不要在用户没答复前猜。' +
|
|
1199
|
+
'path 省略表示配置 root 项目本身;ide 只取 idea / pycharm。',
|
|
1200
|
+
input: {
|
|
1201
|
+
type: 'object',
|
|
1202
|
+
properties: {
|
|
1203
|
+
path: {
|
|
1204
|
+
type: 'string',
|
|
1205
|
+
description: '子项目相对 root 的路径(例如 modelserver);省略表示 root 项目本身。',
|
|
1206
|
+
},
|
|
1207
|
+
ide: {
|
|
1208
|
+
type: 'string',
|
|
1209
|
+
enum: [...SUPPORTED_IDES],
|
|
1210
|
+
description: '该项目的 IDE:idea = IntelliJ IDEA,pycharm = PyCharm。',
|
|
1211
|
+
},
|
|
1212
|
+
},
|
|
1213
|
+
required: ['ide'],
|
|
1214
|
+
},
|
|
1215
|
+
execute: async (args) => configureIdeProject(args),
|
|
1216
|
+
});
|
|
1299
1217
|
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1218
|
+
// `/open-in-ide` 接入 root 与其全部子项目;`/add-sub-module-in-ide <路径>` 登记
|
|
1219
|
+
// 一个子项目(IDE 由模型问用户后写回);`/delete-in-ide` 删除整个配置。
|
|
1220
|
+
//
|
|
1221
|
+
// 三个名字都带 `-ide` 而不是 `-idea`:这里说的是"接入 IDE",与具体产品无关 —— root 可能是
|
|
1222
|
+
// IDEA、子项目可能是 PyCharm,和工具前缀(`ide_*` / `<目录名>_*`)是同一套口径。
|
|
1223
|
+
if (typeof ctx.command?.transform === 'function') {
|
|
1224
|
+
commandRegistration = await ctx.command.transform((editor) => {
|
|
1225
|
+
editor.add({
|
|
1226
|
+
name: 'open-in-ide',
|
|
1227
|
+
description: '接入当前项目:root 及其全部已登记子项目,各自用配置好的 IDE 打开',
|
|
1228
|
+
execute: async (input) => {
|
|
1229
|
+
const parsed = parseIdeArgument(input?.prompt?.text);
|
|
1230
|
+
if (!parsed.ok) {
|
|
1231
|
+
await sendFeedback(input, {status: 'bad-argument', message: parsed.message});
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
let current = await readConfig();
|
|
1235
|
+
if (!current) {
|
|
1236
|
+
// 第一次:需要知道 root 用哪个 IDE。无参时让模型问用户;带参则直通。
|
|
1237
|
+
if (!parsed.app) {
|
|
1238
|
+
await askIdeChoice(input, {isRoot: true, label: '.'});
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
current = {root: {path: projectPath, app: parsed.app}, children: []};
|
|
1242
|
+
await writeConfig(current);
|
|
1243
|
+
} else if (parsed.app && parsed.app !== current.root.app) {
|
|
1244
|
+
// 直通逃生口:显式带参时改绑 root 的 IDE(模型不配合时用户仍能自救)。
|
|
1245
|
+
current = {root: {...current.root, app: parsed.app}, children: current.children};
|
|
1246
|
+
await writeConfig(current);
|
|
1247
|
+
}
|
|
1248
|
+
/** @type {boolean} */
|
|
1249
|
+
let unavailableNoticeSent = false;
|
|
1250
|
+
const result = await runOpenInIdea({
|
|
1251
|
+
onUnavailable: async (notice) => {
|
|
1252
|
+
unavailableNoticeSent = await sendFeedback(input, notice);
|
|
1253
|
+
return unavailableNoticeSent;
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
// The unavailable notice is the final result for this command. If
|
|
1257
|
+
// it was already delivered, do not show the same warning twice.
|
|
1258
|
+
const unavailableAlreadySent = unavailableNoticeSent || result.unavailableNoticeSent;
|
|
1259
|
+
if (!unavailableAlreadySent || result.status !== 'mcp-unavailable') {
|
|
1260
|
+
await sendFeedback(input, result);
|
|
1261
|
+
}
|
|
1262
|
+
},
|
|
1263
|
+
});
|
|
1264
|
+
editor.add({
|
|
1265
|
+
name: 'add-sub-module-in-ide',
|
|
1266
|
+
description: '登记一个子项目(参数:子项目相对 root 的路径);随后由 AI 询问用哪个 IDE',
|
|
1267
|
+
execute: async (input) => {
|
|
1268
|
+
const parsed = parseSubprojectArgument(input?.prompt?.text);
|
|
1269
|
+
if (!parsed.ok) {
|
|
1270
|
+
await sendFeedback(input, {status: 'bad-argument', message: parsed.message});
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
const current = await readConfig();
|
|
1274
|
+
if (!current) {
|
|
1275
|
+
await sendFeedback(input, {status: 'not-configured'});
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
const absPath = resolve(projectPath, parsed.path);
|
|
1279
|
+
if (!existsSync(absPath)) {
|
|
1280
|
+
await sendFeedback(input, {
|
|
1281
|
+
status: 'invalid-subproject',
|
|
1282
|
+
message: `目录不存在:${absPath}`,
|
|
1283
|
+
});
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const verdict = validateSubproject(current, absPath);
|
|
1287
|
+
if (!verdict.ok) {
|
|
1288
|
+
await sendFeedback(input, {status: 'invalid-subproject', message: verdict.message});
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
await askIdeChoice(input, {isRoot: false, label: absPath.slice(projectPath.length + 1)});
|
|
1292
|
+
},
|
|
1293
|
+
});
|
|
1294
|
+
editor.add({
|
|
1295
|
+
name: 'delete-in-ide',
|
|
1296
|
+
description: '删除本项目的 IDE 配置:注销全部 IDE MCP 并清除 root 与子项目的登记',
|
|
1297
|
+
execute: async (input) => {
|
|
1298
|
+
await writeConfig(null);
|
|
1299
|
+
// 注销全部 server。走串行化的 reconcile,所以在飞的重建(另一个
|
|
1300
|
+
// `/open-in-ide`、请求期自愈)不会在配置已删之后再注册回来。
|
|
1301
|
+
await reconcile({probeIde: false});
|
|
1302
|
+
await sendFeedback(input, {status: 'deleted'});
|
|
1303
|
+
},
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
1336
1306
|
}
|
|
1337
|
-
if (!injectGuidance) return;
|
|
1338
|
-
// Guidance promises tools the model can call, so it only goes out when
|
|
1339
|
-
// they are actually in this request.
|
|
1340
|
-
if (!serving) return;
|
|
1341
|
-
// 逐项目注入:每个项目那一节只写它自己的前缀,模型不需要自己换算。
|
|
1342
|
-
// 配套插件 `idea-mcp-enhance` 的两组工具(run-lifecycle / deployment)也按每个项目分别判断,
|
|
1343
|
-
// 所以一个项目装了 enhance、另一个没装时,各节的内容本来就不一样。
|
|
1344
|
-
appendIdeGuidance(event?.system, currentProjects(), { tools });
|
|
1345
|
-
});
|
|
1346
1307
|
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
// 没有工具,只能靠用户手敲 `/open-in-ide`。
|
|
1357
|
-
//
|
|
1358
|
-
// 只探测、不启动 IDE:走 `reconcile({ probeIde: true })`,而不是会 `open -a`
|
|
1359
|
-
// 的 `reviveIde()` —— 一条用户消息不该把 IDE 拉起来。
|
|
1360
|
-
promptRegistration = await ctx.session.hook('prompt', async (event) => {
|
|
1361
|
-
if (disposed || !config) return;
|
|
1362
|
-
const fresh = registrations.length === 0;
|
|
1363
|
-
if (!needsRebuild && !fresh) return;
|
|
1364
|
-
// Never react to the plugin's own notices, or recovery would feed on
|
|
1365
|
-
// itself. Match the notice TEXT, never `metadata.displayText`: OpenCode
|
|
1366
|
-
// also sets `displayText` on ordinary user messages (it is the message
|
|
1367
|
-
// text itself), so a displayText guard silently skips every real message
|
|
1368
|
-
// — verified live, and it made this rebuild never run.
|
|
1369
|
-
const text = event?.prompt?.text;
|
|
1370
|
-
if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
|
|
1371
|
-
try {
|
|
1372
|
-
await reconcile({ probeIde: true });
|
|
1373
|
-
} catch {
|
|
1374
|
-
// best effort: the latch stays set and the next message retries
|
|
1308
|
+
// `configure_ide_project`:模型把用户选定的 IDE 写回配置的唯一通道。
|
|
1309
|
+
//
|
|
1310
|
+
// 常驻注册,不做"用时注册、写完撤销":一个工具的 schema 只有几百 token,省不下
|
|
1311
|
+
// 什么,却要换来一整套状态机(什么时候注册、模型一直不调用怎么办、用户中途说别
|
|
1312
|
+
// 的怎么办)。它该不该被调用由 description 约束。
|
|
1313
|
+
if (typeof ctx.tool?.transform === 'function') {
|
|
1314
|
+
configToolRegistration = await ctx.tool.transform((editor) => {
|
|
1315
|
+
editor.add(configureIdeTool());
|
|
1316
|
+
});
|
|
1375
1317
|
}
|
|
1376
|
-
if (registrations.length > 0) needsRebuild = false;
|
|
1377
|
-
});
|
|
1378
1318
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1319
|
+
// Hide the tools the model must not see, and point it at the rest. This hook
|
|
1320
|
+
// runs for every agent-loop step (including tool-driven continuations), so
|
|
1321
|
+
// it stays a cheap, I/O-free function of the request: it checks the cached
|
|
1322
|
+
// config and this request's own tool snapshot, and takes no action.
|
|
1323
|
+
//
|
|
1324
|
+
// `event.tools` is this request's tool snapshot, and OpenCode's own registry
|
|
1325
|
+
// adds IDE tools when the servers connect and drops them when they stop
|
|
1326
|
+
// (`stopServer` publishes `mcp.tools.changed`, which the registry turns into
|
|
1327
|
+
// a reload). So the snapshot is also the honest answer to "is the IDE
|
|
1328
|
+
// serving?".
|
|
1329
|
+
if (typeof ctx.session?.hook === 'function') {
|
|
1330
|
+
sessionRegistration = await ctx.session.hook('context', (event) => {
|
|
1331
|
+
const tools = event?.tools;
|
|
1332
|
+
const serving =
|
|
1333
|
+
tools !== undefined && Object.keys(tools).some((name) => isIdeToolName(name));
|
|
1334
|
+
if (serving) {
|
|
1335
|
+
wasServing = true;
|
|
1336
|
+
removeIdeToolDefinitions(tools, HIDDEN_IDE_TOOL_SUFFIXES, prefixes());
|
|
1337
|
+
} else if (wasServing && tools !== undefined && config) {
|
|
1338
|
+
// This session HAD the tools and this request does not. Latch a
|
|
1339
|
+
// rebuild here, where "is the IDE serving THIS request?" is a fact: a
|
|
1340
|
+
// failed reconnect can tear the registration down without anything else
|
|
1341
|
+
// noticing. `wasServing` keeps this from firing for a project that
|
|
1342
|
+
// never connected in this session — the `prompt` hook covers that case
|
|
1343
|
+
// (a fresh instance has no registration at all) and setup deliberately
|
|
1344
|
+
// does not connect on its own.
|
|
1345
|
+
needsRebuild = true;
|
|
1346
|
+
wasServing = false;
|
|
1347
|
+
// Do not wait for the next user message. The latch is read by the
|
|
1348
|
+
// `prompt` hook, which has ALREADY run for this request — so without
|
|
1349
|
+
// this the session would stay tool-less for the rest of the turn and
|
|
1350
|
+
// need one more message before anything happened.
|
|
1351
|
+
void reviveIde();
|
|
1352
|
+
}
|
|
1353
|
+
if (config || serving) {
|
|
1354
|
+
appendIdeRecoveryGuidance(event?.system, {marked: Boolean(config), serving});
|
|
1355
|
+
}
|
|
1356
|
+
if (!injectGuidance) return;
|
|
1357
|
+
// Guidance promises tools the model can call, so it only goes out when
|
|
1358
|
+
// they are actually in this request.
|
|
1359
|
+
if (!serving) return;
|
|
1360
|
+
// 逐项目注入:每个项目那一节只写它自己的前缀,模型不需要自己换算。
|
|
1361
|
+
// 配套插件 `idea-mcp-enhance` 的两组工具(run-lifecycle / deployment)也按每个项目分别判断,
|
|
1362
|
+
// 所以一个项目装了 enhance、另一个没装时,各节的内容本来就不一样。
|
|
1363
|
+
appendIdeGuidance(event?.system, currentProjects(), {tools});
|
|
1364
|
+
});
|
|
1399
1365
|
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
const result = await runOpenInIdea();
|
|
1432
|
-
if (result.port === undefined) return;
|
|
1433
|
-
rewriteToolError(event, 'IDE MCP 已重新连接,请重试刚才的操作。');
|
|
1434
|
-
});
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1366
|
+
// 自愈入口(钩子型)。OpenCode 会 await `prompt`,所以在这里修好的连接会在
|
|
1367
|
+
// **本次请求**里就带上 IDE 工具,没有"先回一句正在重连、下一条才行"
|
|
1368
|
+
// 的空窗。
|
|
1369
|
+
//
|
|
1370
|
+
// 两种"需要重连"的形状都从这里进:
|
|
1371
|
+
// * `needsRebuild` — 之前的请求已经证明工具没了;
|
|
1372
|
+
// * `registrations.length === 0` — 本插件实例压根没有注册任何 IDE server,这
|
|
1373
|
+
// 正是 OpenCode 回收 Location 服务(LayerMap idleTimeToLive = 60 分钟)
|
|
1374
|
+
// 或进程重启后新实例的样子。setup 故意不连接,没有这一步会话就会永久
|
|
1375
|
+
// 没有工具,只能靠用户手敲 `/open-in-ide`。
|
|
1376
|
+
//
|
|
1377
|
+
// 只探测、不启动 IDE:走 `reconcile({ probeIde: true })`,而不是会 `open -a`
|
|
1378
|
+
// 的 `reviveIde()` —— 一条用户消息不该把 IDE 拉起来。
|
|
1379
|
+
promptRegistration = await ctx.session.hook('prompt', async (event) => {
|
|
1380
|
+
if (disposed || !config) return;
|
|
1381
|
+
const fresh = registrations.length === 0;
|
|
1382
|
+
if (!needsRebuild && !fresh) return;
|
|
1383
|
+
// Never react to the plugin's own notices, or recovery would feed on
|
|
1384
|
+
// itself. Match the notice TEXT, never `metadata.displayText`: OpenCode
|
|
1385
|
+
// also sets `displayText` on ordinary user messages (it is the message
|
|
1386
|
+
// text itself), so a displayText guard silently skips every real message
|
|
1387
|
+
// — verified live, and it made this rebuild never run.
|
|
1388
|
+
const text = event?.prompt?.text;
|
|
1389
|
+
if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
|
|
1390
|
+
try {
|
|
1391
|
+
await reconcile({probeIde: true});
|
|
1392
|
+
} catch {
|
|
1393
|
+
// best effort: the latch stays set and the next message retries
|
|
1394
|
+
}
|
|
1395
|
+
if (registrations.length > 0) needsRebuild = false;
|
|
1396
|
+
});
|
|
1437
1397
|
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1398
|
+
// The IDE's tool-catalog policy, in two halves that must stay in sync:
|
|
1399
|
+
// an argument this plugin always overwrites must not also be advertised,
|
|
1400
|
+
// or the model sets it, reasons about it, and is wrong. The schema half
|
|
1401
|
+
// runs first, so a request never sees a knob that is already decided; see
|
|
1402
|
+
// `mcp/idea-tool-defaults.js` for the measurements and for why this is a
|
|
1403
|
+
// narrow exception to "never touch tool definitions".
|
|
1404
|
+
//
|
|
1405
|
+
// Both halves are deliberately unconditional on the project config: they are
|
|
1406
|
+
// properties of the tool call, not of whether this project is IDE-connected.
|
|
1407
|
+
if (typeof ctx.tool?.transform === 'function') {
|
|
1408
|
+
toolTransformRegistration = await ctx.tool.transform((editor) => {
|
|
1409
|
+
hideForcedArguments(editor, prefixes());
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
if (typeof ctx.tool?.hook === 'function') {
|
|
1413
|
+
toolBeforeRegistration = await ctx.tool.hook('execute.before', (event) => {
|
|
1414
|
+
if (typeof event?.tool !== 'string') return;
|
|
1415
|
+
applyIdeToolDefaults(event.tool, event.input, prefixes());
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// The only self-initiated action: when an IDE tool call fails in a way
|
|
1420
|
+
// that means the IDE MCP is not usable, run the open flow again. The
|
|
1421
|
+
// failure is classified — a plain business error (a bad path, a missing
|
|
1422
|
+
// file) never triggers a reconnect, only "the MCP is not available" or
|
|
1423
|
+
// "the project is not open in the IDE" do.
|
|
1424
|
+
if (typeof ctx.tool?.hook === 'function') {
|
|
1425
|
+
toolRegistration = await ctx.tool.hook('execute.after', async (event) => {
|
|
1426
|
+
if (event?.status !== 'error') return;
|
|
1427
|
+
if (typeof event?.tool !== 'string' || !isIdeToolName(event.tool)) return;
|
|
1428
|
+
if (!config) return;
|
|
1429
|
+
const kind = classifyIdeFailure(event.error, serverNames());
|
|
1430
|
+
if (kind === 'none') return;
|
|
1431
|
+
if (kind === 'project') {
|
|
1432
|
+
// The IDE is up and answering; only the PROJECT is missing. That is
|
|
1433
|
+
// never a connection problem, so this must never fall through to
|
|
1434
|
+
// the reconnect below — on a platform with no launcher there is
|
|
1435
|
+
// nothing to call, and falling through would report "reconnected"
|
|
1436
|
+
// and send the model round after round after a call that can only
|
|
1437
|
+
// fail. Leave the IDE's own message, which names the real cause.
|
|
1438
|
+
if (!launchEnabled) return;
|
|
1439
|
+
// 只有这个工具所属的那个项目的 IDE 需要重开(多项目下别去动别的项目)。
|
|
1440
|
+
const project = projectForTool(event.tool);
|
|
1441
|
+
const app = project ? appNameFor(project.app) : undefined;
|
|
1442
|
+
if (!project || !app) return;
|
|
1443
|
+
await openInIde(project.path, {app});
|
|
1444
|
+
rewriteToolError(
|
|
1445
|
+
event,
|
|
1446
|
+
`该项目已在 ${IDE_LABELS[project.app] ?? 'IDE'} 中重新打开,请重试刚才的操作。`,
|
|
1447
|
+
);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const result = await runOpenInIdea();
|
|
1451
|
+
if (result.port === undefined) return;
|
|
1452
|
+
rewriteToolError(event, 'IDE MCP 已重新连接,请重试刚才的操作。');
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1494
1455
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1456
|
+
|
|
1457
|
+
return async () => {
|
|
1458
|
+
disposed = true;
|
|
1459
|
+
if (sessionRegistration) {
|
|
1460
|
+
try {
|
|
1461
|
+
await sessionRegistration.dispose();
|
|
1462
|
+
} catch {
|
|
1463
|
+
// best effort
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
if (toolRegistration) {
|
|
1467
|
+
try {
|
|
1468
|
+
await toolRegistration.dispose();
|
|
1469
|
+
} catch {
|
|
1470
|
+
// best effort
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (toolBeforeRegistration) {
|
|
1474
|
+
try {
|
|
1475
|
+
await toolBeforeRegistration.dispose();
|
|
1476
|
+
} catch {
|
|
1477
|
+
// best effort
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (toolTransformRegistration) {
|
|
1481
|
+
try {
|
|
1482
|
+
await toolTransformRegistration.dispose();
|
|
1483
|
+
} catch {
|
|
1484
|
+
// best effort
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
if (configToolRegistration) {
|
|
1488
|
+
try {
|
|
1489
|
+
await configToolRegistration.dispose();
|
|
1490
|
+
} catch {
|
|
1491
|
+
// best effort
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
if (promptRegistration) {
|
|
1495
|
+
try {
|
|
1496
|
+
await promptRegistration.dispose();
|
|
1497
|
+
} catch {
|
|
1498
|
+
// best effort
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
if (commandRegistration) {
|
|
1502
|
+
try {
|
|
1503
|
+
await commandRegistration.dispose();
|
|
1504
|
+
} catch {
|
|
1505
|
+
// best effort
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
if (skillRegistration) {
|
|
1509
|
+
try {
|
|
1510
|
+
await skillRegistration.dispose();
|
|
1511
|
+
} catch {
|
|
1512
|
+
// best effort
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
await deactivateIde();
|
|
1516
|
+
};
|
|
1517
|
+
},
|
|
1499
1518
|
};
|