opencode-idea 0.0.10 → 0.0.12
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 +151 -88
- package/package.json +1 -3
- package/src/ide-guidance.js +29 -4
- package/src/ide-launcher.js +58 -0
- package/src/mcp/idea.js +91 -17
- package/src/plugin.js +331 -166
- package/src/env.js +0 -39
- package/src/mcp/ide-env.js +0 -101
package/src/plugin.js
CHANGED
|
@@ -1,31 +1,37 @@
|
|
|
1
1
|
// opencode-idea — OpenCode plugin entry.
|
|
2
2
|
//
|
|
3
3
|
// For a project the user opted into IDEA this plugin:
|
|
4
|
-
// 1.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// 3. registers the `/open-in-idea` command, which opens the current project
|
|
4
|
+
// 1. registers the IDE MCP server with OpenCode — by default as the IDE's
|
|
5
|
+
// stdio bridge (`idea stdioMcpServer`), scoped to the project through
|
|
6
|
+
// `IJ_MCP_SERVER_PROJECT_PATH` — injects direct-call guidance for IDEA MCP
|
|
7
|
+
// tools, and updates only IDEA MCP tool descriptions;
|
|
8
|
+
// 2. registers the `/open-in-idea` command, which opens the current project
|
|
10
9
|
// in IntelliJ IDEA (reusing a running instance), loads the IDE
|
|
11
10
|
// capabilities immediately, and marks the project as an IDEA project;
|
|
12
11
|
// `/close-in-idea` clears that mark;
|
|
13
|
-
//
|
|
12
|
+
// 3. registers the `idea-run-config` skill (marked projects only), which
|
|
14
13
|
// teaches the model how to manage `.run/*.run.xml` run configurations — a
|
|
15
14
|
// capability the IDE MCP server does not expose.
|
|
16
15
|
//
|
|
17
16
|
// A project that was never marked is left untouched. Everything is manual:
|
|
18
|
-
// setup does NOT probe or connect the IDE
|
|
19
|
-
//
|
|
20
|
-
// the IDE MCP + guidance on demand.
|
|
17
|
+
// setup does NOT probe or connect the IDE. `/open-in-idea` probes, opens the
|
|
18
|
+
// IDE, and loads the IDE MCP + guidance on demand.
|
|
21
19
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
20
|
+
// `/open-in-idea` opens the project in the IDE, waits until its MCP endpoint is
|
|
21
|
+
// ready, registers `idea` (stdio, project-scoped) and marks the project in
|
|
22
|
+
// `ctx.storage`; `/close-in-idea` unregisters the server and clears the mark.
|
|
23
|
+
// A live endpoint alone does NOT mean our project is open there (the running IDE
|
|
24
|
+
// may have a different project open), so the open flow asks the IDE directly —
|
|
25
|
+
// one project-scoped MCP call — and runs `open -a` only when the project is not
|
|
26
|
+
// open yet.
|
|
27
|
+
// The mark gates the IDEA skills and the failure-driven recovery. After a manual
|
|
28
|
+
// open, an `idea_*` call that fails because the IDE MCP is not usable (the server
|
|
29
|
+
// dropped, or the project is not open in the IDE) re-runs the open flow; a plain
|
|
30
|
+
// business error is classified apart and left alone. Because a stdio bridge whose
|
|
31
|
+
// IDE connection drops never reports it (it hangs), a background heartbeat probes
|
|
32
|
+
// the IDE endpoint and, once it is clearly gone, deactivates the server — killing
|
|
33
|
+
// the zombie bridge and removing the tools so a call cannot hang — and the next
|
|
34
|
+
// user message rebuilds it.
|
|
29
35
|
//
|
|
30
36
|
// `Plugin.define` from `@opencode/plugin` is an identity function, so a plain
|
|
31
37
|
// `{ id, setup }` object is the whole plugin contract. Exporting it directly
|
|
@@ -34,16 +40,18 @@
|
|
|
34
40
|
import {
|
|
35
41
|
DEFAULT_PORTS,
|
|
36
42
|
IDEA_SERVER_NAME,
|
|
43
|
+
callTool,
|
|
37
44
|
findIdePort,
|
|
38
45
|
serverConfig,
|
|
46
|
+
stdioServerConfig,
|
|
39
47
|
} from './mcp/idea.js';
|
|
40
|
-
import { readIdeTerminalEnv } from './mcp/ide-env.js';
|
|
41
48
|
import { currentProjectPath } from './project.js';
|
|
42
|
-
import { applyEnv, mergeEnv } from './env.js';
|
|
43
49
|
import {
|
|
44
50
|
createLaunchGuard,
|
|
51
|
+
DEFAULT_IDE_APP,
|
|
45
52
|
openInIde,
|
|
46
53
|
resolveIdeApp,
|
|
54
|
+
resolveIdeExecutable,
|
|
47
55
|
} from './ide-launcher.js';
|
|
48
56
|
import {
|
|
49
57
|
appendIdeGuidance,
|
|
@@ -62,6 +70,20 @@ const DEFAULT_MCP_START_TIMEOUT_MS = 60000;
|
|
|
62
70
|
const MCP_START_POLL_MS = 1000;
|
|
63
71
|
const DEFAULT_TOOL_REFRESH_TIMEOUT_MS = 5000;
|
|
64
72
|
const TOOL_REFRESH_POLL_MS = 25;
|
|
73
|
+
// Hard cap on ONE IDE MCP tool call. OpenCode's own default is 12 hours, and the
|
|
74
|
+
// IDE's stdio bridge NEVER answers a call once its upstream IDE connection has
|
|
75
|
+
// dropped (it logs on stderr but sends no JSON-RPC response) — so a call made
|
|
76
|
+
// after the IDE died would otherwise sit unresolved for 12h. 10 minutes is far
|
|
77
|
+
// beyond any normal IDE tool and turns that permanent hang into a normal error.
|
|
78
|
+
const DEFAULT_EXECUTION_TIMEOUT_MS = 600000;
|
|
79
|
+
// Background IDE liveness probe. The stdio bridge NEVER reports a dropped IDE
|
|
80
|
+
// connection (it just hangs), so the plugin watches the IDE endpoint itself.
|
|
81
|
+
// After HEARTBEAT_FAILURES consecutive misses it deactivates the `idea` server
|
|
82
|
+
// — which kills the zombie bridge child AND removes the `idea_*` tools, so the
|
|
83
|
+
// model can never call into a dead pipe — and latches a rebuild for the next
|
|
84
|
+
// user message (the bridge never heals on its own).
|
|
85
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
|
|
86
|
+
const DEFAULT_HEARTBEAT_FAILURES = 2;
|
|
65
87
|
const IDEA_READY_TOOL_IDS = new Set(['idea_read_file', 'idea_apply_patch']);
|
|
66
88
|
|
|
67
89
|
/**
|
|
@@ -84,9 +106,18 @@ const IDEA_READY_TOOL_IDS = new Set(['idea_read_file', 'idea_apply_patch']);
|
|
|
84
106
|
* - Inspection KTS MCP: inspection.kts authoring helpers (PSI tree, API docs,
|
|
85
107
|
* examples, run). Also IntelliJ Platform plugin-development only.
|
|
86
108
|
* - Python Environment MCP: Python interpreter detection and configuration.
|
|
87
|
-
* Hidden;
|
|
109
|
+
* Hidden; project commands run through the IDE terminal instead, which is
|
|
110
|
+
* already launched with the IDE's real environment.
|
|
88
111
|
* - Database data sources: connections are configured by the user in the IDE,
|
|
89
112
|
* so the AI only reads and queries them; create/edit stay hidden.
|
|
113
|
+
*
|
|
114
|
+
* NOT hidden: `idea_execute_terminal_command`. Project commands must run with
|
|
115
|
+
* the SDKs the user configured in the IDE (JDK, Node, Python, ...), so the model
|
|
116
|
+
* uses the IDE's integrated terminal for them. `executeInShell: true` runs the
|
|
117
|
+
* command in the user's real shell (`zsh`/`bash`), which inherits the IDE
|
|
118
|
+
* environment — including version managers such as fnm / SDKMAN whose selection
|
|
119
|
+
* lives in shell init and cannot be reconstructed from a handful of variables.
|
|
120
|
+
* Requires Brave Mode in the IDE; without it the IDE asks for confirmation.
|
|
90
121
|
*/
|
|
91
122
|
export const HIDDEN_IDEA_TOOLS = [
|
|
92
123
|
// VCS
|
|
@@ -94,11 +125,6 @@ export const HIDDEN_IDEA_TOOLS = [
|
|
|
94
125
|
'idea_get_repositories',
|
|
95
126
|
// Router
|
|
96
127
|
'idea_execute_tool',
|
|
97
|
-
// Terminal: the plugin reads the IDE env through its own direct MCP call
|
|
98
|
-
// (`src/mcp/ide-env.js`), so the model never needs this tool. Measured value for
|
|
99
|
-
// the agent is nil: ~60s hard cutoff with no way to read the terminal buffer,
|
|
100
|
-
// and the native shell already keeps the output tail plus a full-output file.
|
|
101
|
-
'idea_execute_terminal_command',
|
|
102
128
|
// Debugger
|
|
103
129
|
'idea_xdebug_control_session',
|
|
104
130
|
'idea_xdebug_evaluate_expression',
|
|
@@ -156,13 +182,97 @@ function toolIds(tools) {
|
|
|
156
182
|
return (tools ?? []).map((tool) => tool.id).filter((id) => typeof id === 'string').sort();
|
|
157
183
|
}
|
|
158
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Classify an `idea_*` tool failure for the reconnect decision.
|
|
187
|
+
*
|
|
188
|
+
* It must never mistake a plain business error (a bad path, a missing file) for
|
|
189
|
+
* a broken connection, so only two shapes trigger action:
|
|
190
|
+
*
|
|
191
|
+
* - `project`: the IDE is up but the requested project is not open in it, e.g.
|
|
192
|
+
* "`projectPath`=... doesn't correspond to any open project." Re-opening the
|
|
193
|
+
* project fixes it.
|
|
194
|
+
* - `mcp`: the IDE MCP server itself is gone (dropped / transport closed).
|
|
195
|
+
*
|
|
196
|
+
* Everything else is `none`.
|
|
197
|
+
*
|
|
198
|
+
* @param {{ message?: unknown } | undefined} error
|
|
199
|
+
* @returns {'project' | 'mcp' | 'none'}
|
|
200
|
+
*/
|
|
201
|
+
function classifyIdeaFailure(error) {
|
|
202
|
+
const message = typeof error?.message === 'string' ? error.message : '';
|
|
203
|
+
// "Project not open in the IDE" comes in two shapes, both meaning the same
|
|
204
|
+
// thing and both fixed by opening the project again:
|
|
205
|
+
// - the called tool carried no `projectPath`, so the server could not pick
|
|
206
|
+
// one: "Unable to determine the target project for the current MCP tool
|
|
207
|
+
// call." (this is what the plugin's stdio registration produces, since it
|
|
208
|
+
// sets the project through env, not through the call)
|
|
209
|
+
// - an explicit `projectPath` was passed but is not open: "`projectPath`=...
|
|
210
|
+
// doesn't correspond to any open project."
|
|
211
|
+
if (
|
|
212
|
+
message.includes('Unable to determine the target project') ||
|
|
213
|
+
message.includes('correspond to any open project')
|
|
214
|
+
) {
|
|
215
|
+
return 'project';
|
|
216
|
+
}
|
|
217
|
+
// Only transport-level markers, which OpenCode / the IDE bridge emit
|
|
218
|
+
// themselves. The message of an MCP *tool error* is the tool's own content
|
|
219
|
+
// (`tool/mcp.ts` turns `result.isError` into `ToolFailure({ message: <text> })`),
|
|
220
|
+
// so a generic string such as "Connection refused" from a SQL or run-config
|
|
221
|
+
// tool would otherwise be misread as a dropped connection and rebuild a
|
|
222
|
+
// healthy bridge.
|
|
223
|
+
if (
|
|
224
|
+
message.includes(`MCP server "${IDEA_SERVER_NAME}" is not available`) ||
|
|
225
|
+
message.includes('MCP server is not connected') ||
|
|
226
|
+
message.includes('Error POSTing to endpoint') ||
|
|
227
|
+
message.includes('SseClientTransport is closed')
|
|
228
|
+
) {
|
|
229
|
+
return 'mcp';
|
|
230
|
+
}
|
|
231
|
+
return 'none';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Replace a tool error's message, so the model sees the replacement. OpenCode
|
|
236
|
+
* returns `event.error` after the `execute.after` hook resolves, so rewriting it
|
|
237
|
+
* is how the plugin steers the model after an automatic reconnect.
|
|
238
|
+
*
|
|
239
|
+
* The error is a `Tool.Error` instance (`ToolFailure` extends it). It is rebuilt
|
|
240
|
+
* through its own constructor — no runtime dependency on `@opencode/ai` — with a
|
|
241
|
+
* direct field mutation as a fallback.
|
|
242
|
+
*
|
|
243
|
+
* @param {{ error?: unknown }} event
|
|
244
|
+
* @param {string} message
|
|
245
|
+
* @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
function rewriteToolError(event, message) {
|
|
248
|
+
const current = event?.error;
|
|
249
|
+
if (!current || typeof current !== 'object') return false;
|
|
250
|
+
try {
|
|
251
|
+
const Ctor = current.constructor;
|
|
252
|
+
if (typeof Ctor === 'function') {
|
|
253
|
+
const next = { message };
|
|
254
|
+
if (current.error !== undefined) next.error = current.error;
|
|
255
|
+
if (current.metadata !== undefined) next.metadata = current.metadata;
|
|
256
|
+
event.error = new Ctor(next);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// fall through to mutation
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
current.message = message;
|
|
264
|
+
return true;
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
159
270
|
/**
|
|
160
271
|
* @typedef {{
|
|
161
272
|
* status: 'connected' | 'tools-loading' | 'mcp-unavailable' | 'launch-failed' | 'disabled' | 'closed',
|
|
162
273
|
* port?: number,
|
|
163
274
|
* opened?: boolean,
|
|
164
|
-
* unavailableNoticeSent?: boolean
|
|
165
|
-
* autoRecovered?: boolean
|
|
275
|
+
* unavailableNoticeSent?: boolean
|
|
166
276
|
* }} IdeCommandResult
|
|
167
277
|
*/
|
|
168
278
|
|
|
@@ -182,9 +292,6 @@ const asPositiveNumber = (value, fallback) =>
|
|
|
182
292
|
/** User-facing status text for a `/open-in-idea` result. */
|
|
183
293
|
/** @param {IdeCommandResult} result */
|
|
184
294
|
export function ideFeedback(result) {
|
|
185
|
-
if (result?.autoRecovered) {
|
|
186
|
-
return '检测到 IDE MCP 连接已断开,已自动重新连接并恢复 IDEA 原生工具。';
|
|
187
|
-
}
|
|
188
295
|
switch (result?.status) {
|
|
189
296
|
case 'connected':
|
|
190
297
|
return 'IDE MCP 已连接;IDEA 原生工具已注册并可用于后续请求。';
|
|
@@ -199,7 +306,7 @@ export function ideFeedback(result) {
|
|
|
199
306
|
case 'disabled':
|
|
200
307
|
return '插件已通过 openInIde: false 关闭 IDE 启动。请手动打开 IntelliJ IDEA,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后重试 /open-in-idea。';
|
|
201
308
|
case 'closed':
|
|
202
|
-
return '已关闭当前项目的 IDEA
|
|
309
|
+
return '已关闭当前项目的 IDEA 接入:已注销 IDE MCP 并清除项目标记。如需重新接入,请再次执行 /open-in-idea。';
|
|
203
310
|
default:
|
|
204
311
|
return '已处理 /open-in-idea。';
|
|
205
312
|
}
|
|
@@ -210,8 +317,11 @@ export function ideFeedback(result) {
|
|
|
210
317
|
* notification, not a task: the model must only acknowledge it. The user sees
|
|
211
318
|
* the clean notice instead, via `metadata.displayText`.
|
|
212
319
|
*/
|
|
320
|
+
/** Marks the plugin's own session notices, so recovery never reacts to them. */
|
|
321
|
+
export const NOTICE_PREFIX = '【插件通知】';
|
|
322
|
+
|
|
213
323
|
export function idePromptText(result) {
|
|
214
|
-
return [
|
|
324
|
+
return [`${NOTICE_PREFIX}${ideFeedback(result)}`, '这是发给用户的通知,你无需处理。请只回复"收到"。'].join('\n\n');
|
|
215
325
|
}
|
|
216
326
|
|
|
217
327
|
/** `metadata` so the UI shows the clean notice while the model sees the instruction. */
|
|
@@ -227,13 +337,17 @@ export default {
|
|
|
227
337
|
* location?: unknown,
|
|
228
338
|
* options?: {
|
|
229
339
|
* ports?: number[],
|
|
230
|
-
* injectEnv?: boolean,
|
|
231
340
|
* openInIde?: boolean | string,
|
|
232
341
|
* launchCooldownMs?: number,
|
|
233
342
|
* launchMaxAttempts?: number,
|
|
234
343
|
* mcpProbeTimeoutMs?: number,
|
|
235
344
|
* mcpStartTimeoutMs?: number,
|
|
236
345
|
* toolRefreshTimeoutMs?: number,
|
|
346
|
+
* executionTimeoutMs?: number,
|
|
347
|
+
* heartbeatIntervalMs?: number,
|
|
348
|
+
* heartbeatFailures?: number,
|
|
349
|
+
* transport?: 'stdio' | 'http',
|
|
350
|
+
* ideExecutable?: string,
|
|
237
351
|
* feedback?: false | 'message',
|
|
238
352
|
* },
|
|
239
353
|
* command?: {
|
|
@@ -260,13 +374,17 @@ export default {
|
|
|
260
374
|
* agent?: string,
|
|
261
375
|
* system?: unknown[],
|
|
262
376
|
* tools?: Record<string, unknown>,
|
|
263
|
-
* prompt?: { text?: string },
|
|
264
|
-
* metadata?: Record<string, unknown>,
|
|
265
377
|
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
266
378
|
* },
|
|
267
379
|
* tool?: {
|
|
268
380
|
* list?: () => Promise<ReadonlyArray<{ id?: string }>>,
|
|
269
381
|
* reload?: () => Promise<void>,
|
|
382
|
+
* hook?: (name: string, cb: (event: {
|
|
383
|
+
* tool?: string,
|
|
384
|
+
* sessionID?: string,
|
|
385
|
+
* status?: string,
|
|
386
|
+
* error?: unknown,
|
|
387
|
+
* }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
270
388
|
* },
|
|
271
389
|
* mcp: {
|
|
272
390
|
* transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
|
|
@@ -287,12 +405,30 @@ export default {
|
|
|
287
405
|
|
|
288
406
|
const options = ctx.options || {};
|
|
289
407
|
const ports = asArray(options.ports, DEFAULT_PORTS);
|
|
290
|
-
const injectEnv = options.injectEnv !== false;
|
|
291
408
|
const injectGuidance = options.injectGuidance !== false;
|
|
292
409
|
const ideApp = resolveIdeApp(options.openInIde);
|
|
410
|
+
// Transport for the IDE MCP server. `stdio` (default) registers the IDE's
|
|
411
|
+
// stdio bridge as a local server: the client side is a child-process pipe,
|
|
412
|
+
// which — unlike the remote Streamable-HTTP stream — does not get closed
|
|
413
|
+
// while idle and never reconnected. `http` keeps the older remote config.
|
|
414
|
+
const transport = options.transport === 'http' ? 'http' : 'stdio';
|
|
415
|
+
const ideExecutable = typeof options.ideExecutable === 'string'
|
|
416
|
+
? options.ideExecutable
|
|
417
|
+
// Resolve independently of `openInIde`: that option only controls whether
|
|
418
|
+
// the plugin may LAUNCH the IDE, not which transport the MCP server uses.
|
|
419
|
+
// Falling back to the default app when launching is disabled keeps stdio
|
|
420
|
+
// the default there too (otherwise `openInIde: false` silently registered
|
|
421
|
+
// the remote HTTP transport).
|
|
422
|
+
: resolveIdeExecutable(ideApp ?? DEFAULT_IDE_APP);
|
|
423
|
+
const useStdio = transport === 'stdio' && typeof ideExecutable === 'string';
|
|
293
424
|
const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
|
|
294
425
|
const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
|
|
295
426
|
const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
|
|
427
|
+
// Cap on a single IDE tool call, so a bridge that hangs (IDE died) fails in
|
|
428
|
+
// finite time instead of OpenCode's 12h default. See DEFAULT_EXECUTION_TIMEOUT_MS.
|
|
429
|
+
const executionTimeoutMs = asPositiveNumber(options.executionTimeoutMs, DEFAULT_EXECUTION_TIMEOUT_MS);
|
|
430
|
+
const heartbeatIntervalMs = asPositiveNumber(options.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
431
|
+
const heartbeatFailures = asPositiveNumber(options.heartbeatFailures, DEFAULT_HEARTBEAT_FAILURES);
|
|
296
432
|
// "message" (default) injects a clearly-labelled notification that the
|
|
297
433
|
// model only acknowledges ("收到"); the user sees the clean notice via
|
|
298
434
|
// metadata.displayText. false is silent.
|
|
@@ -301,15 +437,14 @@ export default {
|
|
|
301
437
|
cooldownMs: asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS),
|
|
302
438
|
maxAttempts: asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS),
|
|
303
439
|
});
|
|
304
|
-
let currentEnv = { env: {}, prependPath: [] };
|
|
305
440
|
|
|
306
441
|
/** @type {Array<{ dispose: () => Promise<void> | void }>} */
|
|
307
442
|
let registrations = [];
|
|
308
443
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
309
|
-
let shellRegistration;
|
|
310
|
-
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
311
444
|
let sessionRegistration;
|
|
312
445
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
446
|
+
let toolRegistration;
|
|
447
|
+
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
313
448
|
let promptRegistration;
|
|
314
449
|
/** @type {{ dispose: () => Promise<void> | void } | undefined} */
|
|
315
450
|
let commandRegistration;
|
|
@@ -318,6 +453,15 @@ export default {
|
|
|
318
453
|
let skillsRegistered = false;
|
|
319
454
|
let disposed = false;
|
|
320
455
|
|
|
456
|
+
// Background heartbeat state (see DEFAULT_HEARTBEAT_INTERVAL_MS).
|
|
457
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
458
|
+
let heartbeatTimer;
|
|
459
|
+
let heartbeatMisses = 0;
|
|
460
|
+
// Latches when the IDE was seen down and the server was deactivated. It is
|
|
461
|
+
// cleared ONLY by a successful rebuild — never by the endpoint coming back —
|
|
462
|
+
// because the bridge does not heal on its own.
|
|
463
|
+
let needsRebuild = false;
|
|
464
|
+
|
|
321
465
|
// Project-level mark: has the user opted this project into IDEA via
|
|
322
466
|
// `/open-in-idea`? One plugin instance serves one project (OpenCode
|
|
323
467
|
// activates plugins per Location), so a single boolean is the whole cache.
|
|
@@ -328,9 +472,6 @@ export default {
|
|
|
328
472
|
const markKey = `project/${projectPath}`;
|
|
329
473
|
/** @type {boolean | undefined} undefined = a storage read is due */
|
|
330
474
|
let markCache;
|
|
331
|
-
// One auto-recovery run per project at a time; overlapping prompts share it.
|
|
332
|
-
/** @type {Promise<void> | undefined} */
|
|
333
|
-
let autoRecovering;
|
|
334
475
|
|
|
335
476
|
/** Read the durable mark into the cache; at most one storage read per invalidation. */
|
|
336
477
|
const readMark = async () => {
|
|
@@ -361,7 +502,9 @@ export default {
|
|
|
361
502
|
} catch {
|
|
362
503
|
// best effort
|
|
363
504
|
}
|
|
364
|
-
|
|
505
|
+
// Keep the in-memory cache in sync with the write (write-through), so any
|
|
506
|
+
// later synchronous reader sees the new value immediately.
|
|
507
|
+
markCache = marked;
|
|
365
508
|
// The skill lifecycle follows the mark: marking the project registers the
|
|
366
509
|
// IDEA skills, clearing the mark removes them again. Same semantics as the
|
|
367
510
|
// reconnect behavior, so the two can never disagree.
|
|
@@ -411,29 +554,16 @@ export default {
|
|
|
411
554
|
};
|
|
412
555
|
|
|
413
556
|
const activateIde = async (port) => {
|
|
557
|
+
const config = useStdio
|
|
558
|
+
? stdioServerConfig(ideExecutable, port, projectPath, { executionTimeoutMs })
|
|
559
|
+
: serverConfig(port, projectPath, { executionTimeoutMs });
|
|
414
560
|
const mcpRegistration = await ctx.mcp.transform((editor) => {
|
|
415
|
-
editor.set(IDEA_SERVER_NAME,
|
|
561
|
+
editor.set(IDEA_SERVER_NAME, config);
|
|
416
562
|
});
|
|
417
563
|
registrations = [mcpRegistration];
|
|
418
564
|
return refreshIdeTools();
|
|
419
565
|
};
|
|
420
566
|
|
|
421
|
-
/**
|
|
422
|
-
* Is the IDE MCP server contributing tools right now? OpenCode's registry is
|
|
423
|
-
* the source of truth: it adds the tools when a server connects and drops
|
|
424
|
-
* them when the connection stops (`stopServer` publishes `mcp.tools.changed`
|
|
425
|
-
* and the registry reloads). No probe of our own is involved, and only
|
|
426
|
-
* `/open-in-idea` asks, so the read is user-initiated.
|
|
427
|
-
*/
|
|
428
|
-
const ideaToolsRegistered = async () => {
|
|
429
|
-
if (typeof ctx.tool?.list !== 'function') return false;
|
|
430
|
-
try {
|
|
431
|
-
return toolIds(await ctx.tool.list()).some((id) => id.startsWith('idea_'));
|
|
432
|
-
} catch {
|
|
433
|
-
return false;
|
|
434
|
-
}
|
|
435
|
-
};
|
|
436
|
-
|
|
437
567
|
const reconcileOnce = async ({ probeIde = true } = {}) => {
|
|
438
568
|
if (disposed) return false;
|
|
439
569
|
|
|
@@ -442,26 +572,19 @@ export default {
|
|
|
442
572
|
const port = probeIde ? await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }) : undefined;
|
|
443
573
|
const isJetBrains = markCache === true || port !== undefined;
|
|
444
574
|
if (!isJetBrains) {
|
|
445
|
-
currentEnv = { env: {}, prependPath: [] };
|
|
446
575
|
await deactivateIde();
|
|
447
576
|
return false;
|
|
448
577
|
}
|
|
449
578
|
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// A live server is only refreshed; a dead one must be replaced, because
|
|
462
|
-
// `reconcile()` skips an unchanged config and would never reconnect it.
|
|
463
|
-
// The registry says which case this is.
|
|
464
|
-
if (port !== undefined && (await ideaToolsRegistered())) return refreshIdeTools();
|
|
579
|
+
// Always tear the server down and register it again. A stdio bridge whose
|
|
580
|
+
// IDE connection dropped becomes a permanent zombie: the child process is
|
|
581
|
+
// still alive, so OpenCode keeps reporting it as connected, never restarts
|
|
582
|
+
// it, and SKIPS a re-`set` whose config is unchanged (`reconcile()` compares
|
|
583
|
+
// the config). Disposing the registration removes the server from the
|
|
584
|
+
// reconciled config, which is what actually kills the child process — so a
|
|
585
|
+
// full rebuild is the only reliable way back to a working bridge.
|
|
586
|
+
// `/open-in-idea` is manual and rare; rebuilding an already-healthy bridge
|
|
587
|
+
// costs one restart, which is the right trade for never leaving a zombie.
|
|
465
588
|
await deactivateIde();
|
|
466
589
|
if (port === undefined) return false;
|
|
467
590
|
return activateIde(port);
|
|
@@ -499,6 +622,35 @@ export default {
|
|
|
499
622
|
return undefined;
|
|
500
623
|
};
|
|
501
624
|
|
|
625
|
+
/**
|
|
626
|
+
* Is `projectPath` actually OPEN in the IDE that serves `port`?
|
|
627
|
+
*
|
|
628
|
+
* A live endpoint only means the IDE is RUNNING — it may have a different
|
|
629
|
+
* project open (or sit on the Welcome screen), while everything we do is
|
|
630
|
+
* scoped to THIS project. So ask the IDE directly with one project-scoped
|
|
631
|
+
* call: the server resolves the project from `IJ_MCP_SERVER_PROJECT_PATH`
|
|
632
|
+
* BEFORE running the tool, and for a project that is not open it fails with
|
|
633
|
+
* "Unable to determine the target project ..." — the same shape
|
|
634
|
+
* `classifyIdeaFailure` maps to `project`. Any other outcome (a real result,
|
|
635
|
+
* or an unrelated tool error) means the project WAS resolved, i.e. open.
|
|
636
|
+
*
|
|
637
|
+
* A transport failure (`undefined`) counts as "not confirmed open", so the
|
|
638
|
+
* caller still asks the IDE to open the project: `open -a` is idempotent,
|
|
639
|
+
* and the only cost is a redundant focus.
|
|
640
|
+
*
|
|
641
|
+
* @param {number} port
|
|
642
|
+
*/
|
|
643
|
+
const isProjectOpen = async (port) => {
|
|
644
|
+
const result = await callTool(port, projectPath, 'get_project_modules', {}).catch(() => undefined);
|
|
645
|
+
if (!result) return false;
|
|
646
|
+
if (result.isError !== true) return true;
|
|
647
|
+
const text = (result.content ?? [])
|
|
648
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
649
|
+
.map((part) => part.text)
|
|
650
|
+
.join('\n');
|
|
651
|
+
return classifyIdeaFailure({ message: text }) !== 'project';
|
|
652
|
+
};
|
|
653
|
+
|
|
502
654
|
// Re-entrant: repeat invocations share one open request so the IDE is not
|
|
503
655
|
// spawned twice while the application is starting.
|
|
504
656
|
/** @type {Promise<IdeCommandResult> | undefined} */
|
|
@@ -523,6 +675,14 @@ export default {
|
|
|
523
675
|
if (serving !== undefined) {
|
|
524
676
|
guard.reset();
|
|
525
677
|
launchNoticeSent = false;
|
|
678
|
+
// The endpoint being up proves only that the IDE is RUNNING — it may
|
|
679
|
+
// have a DIFFERENT project open. Ask the IDE whether THIS project is
|
|
680
|
+
// open, and only request an open when it is not: for an already-open
|
|
681
|
+
// project the IDE runs a short reopen-then-dispose cycle on a
|
|
682
|
+
// redundant open event, so skipping it is a real saving.
|
|
683
|
+
if (ideApp && !(await isProjectOpen(serving))) {
|
|
684
|
+
await openInIde(projectPath, { app: ideApp });
|
|
685
|
+
}
|
|
526
686
|
return { status: 'connected', port: serving, unavailableNoticeSent: false };
|
|
527
687
|
}
|
|
528
688
|
if (!ideApp) return { status: 'disabled' };
|
|
@@ -587,6 +747,48 @@ export default {
|
|
|
587
747
|
// Setup never probes or connects the IDE — everything is on `/open-in-idea`.
|
|
588
748
|
await reconcile({ probeIde: false });
|
|
589
749
|
|
|
750
|
+
/**
|
|
751
|
+
* One heartbeat tick. Only marked projects are watched, and it only acts
|
|
752
|
+
* when an `idea` server is actually registered: the point is to protect an
|
|
753
|
+
* ESTABLISHED connection. A project that never connected has no zombie to
|
|
754
|
+
* kill, and must not have IDEA launched on its behalf. The deactivation
|
|
755
|
+
* runs under the reconcile chain, so a tick cannot race `/open-in-idea`.
|
|
756
|
+
*/
|
|
757
|
+
let heartbeatBusy = false;
|
|
758
|
+
const heartbeatTick = async () => {
|
|
759
|
+
if (disposed || heartbeatBusy) return;
|
|
760
|
+
if (markCache !== true || registrations.length === 0) {
|
|
761
|
+
heartbeatMisses = 0;
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
heartbeatBusy = true;
|
|
765
|
+
try {
|
|
766
|
+
const port = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
|
|
767
|
+
if (port !== undefined) {
|
|
768
|
+
heartbeatMisses = 0;
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
heartbeatMisses += 1;
|
|
772
|
+
if (heartbeatMisses < heartbeatFailures) return;
|
|
773
|
+
// The IDE is gone. Deactivating kills the zombie bridge child and
|
|
774
|
+
// removes the `idea_*` tools, so the model cannot call into a dead
|
|
775
|
+
// pipe. Latch a rebuild for the next user message.
|
|
776
|
+
const kicked = await enqueue(async () => {
|
|
777
|
+
if (disposed || registrations.length === 0) return false;
|
|
778
|
+
await deactivateIde();
|
|
779
|
+
return true;
|
|
780
|
+
});
|
|
781
|
+
if (kicked) needsRebuild = true;
|
|
782
|
+
} finally {
|
|
783
|
+
heartbeatBusy = false;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
heartbeatTimer = setInterval(() => {
|
|
787
|
+
heartbeatTick().catch(() => {});
|
|
788
|
+
}, heartbeatIntervalMs);
|
|
789
|
+
// Never keep the host process alive just to probe (also keeps tests from hanging).
|
|
790
|
+
if (typeof heartbeatTimer?.unref === 'function') heartbeatTimer.unref();
|
|
791
|
+
|
|
590
792
|
const sendFeedback = async (input, result) => {
|
|
591
793
|
if (feedback !== 'message' || typeof ctx.session?.prompt !== 'function') return false;
|
|
592
794
|
try {
|
|
@@ -604,71 +806,6 @@ export default {
|
|
|
604
806
|
}
|
|
605
807
|
};
|
|
606
808
|
|
|
607
|
-
/**
|
|
608
|
-
* Is the plugin's `idea` MCP server connected right now? OpenCode tracks
|
|
609
|
-
* every server's status in memory, and this is the authoritative "the IDE
|
|
610
|
-
* MCP is down" signal: it reflects the transport, not the result of any
|
|
611
|
-
* single tool call, so a business error can never look like a disconnect.
|
|
612
|
-
* `pending` means "still connecting" and is treated as healthy, so a server
|
|
613
|
-
* that is merely starting up is left alone.
|
|
614
|
-
*
|
|
615
|
-
* @returns {Promise<boolean>}
|
|
616
|
-
*/
|
|
617
|
-
const isIdeaConnected = async () => {
|
|
618
|
-
if (typeof ctx.mcp?.list !== 'function') return false;
|
|
619
|
-
try {
|
|
620
|
-
const result = await ctx.mcp.list();
|
|
621
|
-
const servers = Array.isArray(result) ? result : (result?.data ?? []);
|
|
622
|
-
return servers.some((server) => {
|
|
623
|
-
const status = server?.status?.status;
|
|
624
|
-
return server?.name === IDEA_SERVER_NAME && (status === 'connected' || status === 'pending');
|
|
625
|
-
});
|
|
626
|
-
} catch {
|
|
627
|
-
return false;
|
|
628
|
-
}
|
|
629
|
-
};
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* Marked-project recovery: while the project is marked (the user ran
|
|
633
|
-
* `/open-in-idea`), treat the IDE as expected and rebuild the connection
|
|
634
|
-
* whenever its MCP server is not connected. Runs before the prompt's request
|
|
635
|
-
* is built, so a successful recovery makes its `idea_*` tools available to
|
|
636
|
-
* that very message.
|
|
637
|
-
*
|
|
638
|
-
* Only a confirmed reconnect is announced. A failed recovery stays silent:
|
|
639
|
-
* the per-request recovery guidance already tells the model what to say, and
|
|
640
|
-
* a notice per failed message would be noise (the launch guard, shared with
|
|
641
|
-
* the command, keeps it from re-spawning on every message).
|
|
642
|
-
*
|
|
643
|
-
* @param {string} sessionID the notice target; the mark itself is per-project
|
|
644
|
-
*/
|
|
645
|
-
const autoReconnect = (sessionID) => {
|
|
646
|
-
if (autoRecovering) return autoRecovering;
|
|
647
|
-
const run = (async () => {
|
|
648
|
-
try {
|
|
649
|
-
if (disposed) return;
|
|
650
|
-
if (await isIdeaConnected()) return;
|
|
651
|
-
const result = await runOpenInIdea();
|
|
652
|
-
// A found endpoint is what counts as recovered, even if its tool
|
|
653
|
-
// catalog is still registering (`tools-loading`).
|
|
654
|
-
if (result.port === undefined) return;
|
|
655
|
-
// Queue the notice so it is delivered after the user's own message,
|
|
656
|
-
// never mixed into the same model turn as it.
|
|
657
|
-
await sendFeedback(
|
|
658
|
-
{ sessionID, prompt: { text: '' }, delivery: 'queue' },
|
|
659
|
-
{ status: 'connected', autoRecovered: true },
|
|
660
|
-
);
|
|
661
|
-
} catch {
|
|
662
|
-
// auto-recovery is best effort
|
|
663
|
-
}
|
|
664
|
-
})();
|
|
665
|
-
autoRecovering = run;
|
|
666
|
-
void run.finally(() => {
|
|
667
|
-
if (autoRecovering === run) autoRecovering = undefined;
|
|
668
|
-
});
|
|
669
|
-
return run;
|
|
670
|
-
};
|
|
671
|
-
|
|
672
809
|
// The shipped skills are static knowledge (`skills/<id>/SKILL.md`), e.g. the
|
|
673
810
|
// run-configuration workflow: where `.run/*.run.xml` live, how to bootstrap a
|
|
674
811
|
// schema from a real example, and how to verify a write. Registered only for
|
|
@@ -738,9 +875,14 @@ export default {
|
|
|
738
875
|
});
|
|
739
876
|
editor.add({
|
|
740
877
|
name: 'close-in-idea',
|
|
741
|
-
description: '取消当前项目的 IDEA
|
|
878
|
+
description: '取消当前项目的 IDEA 接入:注销 IDE MCP 并清除项目标记',
|
|
742
879
|
execute: async (input) => {
|
|
743
880
|
await setMark(false);
|
|
881
|
+
// Unregister the IDE MCP server and clear the injected env. Reuses
|
|
882
|
+
// the serialized reconcile, so a concurrent rebuild (an in-flight
|
|
883
|
+
// `/open-in-idea`, or a heartbeat tick) cannot re-register the
|
|
884
|
+
// server after the mark is gone.
|
|
885
|
+
await reconcile({ probeIde: false });
|
|
744
886
|
await sendFeedback(input, { status: 'closed' });
|
|
745
887
|
},
|
|
746
888
|
});
|
|
@@ -762,7 +904,7 @@ export default {
|
|
|
762
904
|
const tools = event?.tools;
|
|
763
905
|
const serving = tools !== undefined && Object.keys(tools).some((name) => name.startsWith('idea_'));
|
|
764
906
|
if (serving) removeIdeaToolDefinitions(tools, HIDDEN_IDEA_TOOLS);
|
|
765
|
-
if (markCache === true || serving) appendIdeRecoveryGuidance(event?.system);
|
|
907
|
+
if (markCache === true || serving) appendIdeRecoveryGuidance(event?.system, { marked: markCache === true });
|
|
766
908
|
if (!injectGuidance) return;
|
|
767
909
|
// Guidance promises tools the model can call, so it only goes out when
|
|
768
910
|
// they are actually in this request.
|
|
@@ -770,42 +912,65 @@ export default {
|
|
|
770
912
|
appendIdeGuidance(event?.system, projectPath);
|
|
771
913
|
});
|
|
772
914
|
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
// message that triggered it use `idea_*` tools.
|
|
778
|
-
//
|
|
779
|
-
// The returned promise is awaited by OpenCode before the request is built,
|
|
780
|
-
// so a slow cold start delays that message — the accepted cost of treating
|
|
781
|
-
// a marked project's IDE as always up.
|
|
915
|
+
// Rebuild after a heartbeat-detected drop. It reads only the in-memory
|
|
916
|
+
// `needsRebuild` latch, so it is a cheap no-op on every other message.
|
|
917
|
+
// OpenCode awaits `prompt` before the request is built, so when the IDE is
|
|
918
|
+
// back, the message that triggers this already carries working tools.
|
|
782
919
|
promptRegistration = await ctx.session.hook('prompt', async (event) => {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
//
|
|
786
|
-
// on
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
if (typeof displayText === 'string') return;
|
|
920
|
+
if (disposed || !needsRebuild || markCache !== true) return;
|
|
921
|
+
// Never react to the plugin's own notices, or recovery would feed on
|
|
922
|
+
// itself. Match the notice TEXT, never `metadata.displayText`: OpenCode
|
|
923
|
+
// also sets `displayText` on ordinary user messages (it is the message
|
|
924
|
+
// text itself), so a displayText guard silently skips every real message
|
|
925
|
+
// — verified live, and it made this rebuild never run.
|
|
790
926
|
const text = event?.prompt?.text;
|
|
791
|
-
if (typeof text === 'string' && text.startsWith(
|
|
792
|
-
|
|
793
|
-
|
|
927
|
+
if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
|
|
928
|
+
const result = await runOpenInIdea();
|
|
929
|
+
if (result.port !== undefined) {
|
|
930
|
+
needsRebuild = false;
|
|
931
|
+
heartbeatMisses = 0;
|
|
932
|
+
}
|
|
794
933
|
});
|
|
934
|
+
|
|
935
|
+
// The only self-initiated action: when an `idea_*` call fails in a way
|
|
936
|
+
// that means the IDE MCP is not usable, run the open flow again. The
|
|
937
|
+
// failure is classified — a plain business error (a bad path, a missing
|
|
938
|
+
// file) never triggers a reconnect, only "the MCP is not available" or
|
|
939
|
+
// "the project is not open in the IDE" do.
|
|
940
|
+
if (typeof ctx.tool?.hook === 'function') {
|
|
941
|
+
toolRegistration = await ctx.tool.hook('execute.after', async (event) => {
|
|
942
|
+
if (event?.status !== 'error') return;
|
|
943
|
+
if (typeof event?.tool !== 'string' || !event.tool.startsWith('idea_')) return;
|
|
944
|
+
if (!(await readMark())) return;
|
|
945
|
+
const kind = classifyIdeaFailure(event.error);
|
|
946
|
+
if (kind === 'none') return;
|
|
947
|
+
if (kind === 'project' && ideApp) {
|
|
948
|
+
// The IDE is up; the project simply is not open in it. Ask the IDE
|
|
949
|
+
// to open the project again, and tell the model to retry.
|
|
950
|
+
await openInIde(projectPath, { app: ideApp });
|
|
951
|
+
rewriteToolError(event, '该项目已在 IDEA 中重新打开,请重试刚才的操作。');
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const result = await runOpenInIdea();
|
|
955
|
+
if (result.port === undefined) return;
|
|
956
|
+
rewriteToolError(event, 'IDE MCP 已重新连接,请重试刚才的操作。');
|
|
957
|
+
});
|
|
958
|
+
}
|
|
795
959
|
}
|
|
796
960
|
|
|
797
961
|
return async () => {
|
|
798
962
|
disposed = true;
|
|
799
|
-
if (
|
|
963
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
964
|
+
if (sessionRegistration) {
|
|
800
965
|
try {
|
|
801
|
-
await
|
|
966
|
+
await sessionRegistration.dispose();
|
|
802
967
|
} catch {
|
|
803
968
|
// best effort
|
|
804
969
|
}
|
|
805
970
|
}
|
|
806
|
-
if (
|
|
971
|
+
if (toolRegistration) {
|
|
807
972
|
try {
|
|
808
|
-
await
|
|
973
|
+
await toolRegistration.dispose();
|
|
809
974
|
} catch {
|
|
810
975
|
// best effort
|
|
811
976
|
}
|