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/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. injects the IDE integrated-terminal environment (JAVA_HOME / GOROOT /
5
- // Node / Maven / ...) into every OpenCode shell;
6
- // 2. registers the IDE MCP server with OpenCode (scoped to the project via the
7
- // IJ_MCP_SERVER_PROJECT_PATH header), injects direct-call guidance for
8
- // IDEA MCP tools, and updates only IDEA MCP tool descriptions;
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
- // 4. registers the `idea-run-config` skill (marked projects only), which
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 (only the IDE-terminal env is read
19
- // when a probe is requested). `/open-in-idea` probes, opens the IDE, and loads
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
- // Once the user runs `/open-in-idea` in a project, that project is marked as an
23
- // IDEA project. The mark is persisted through `ctx.storage` (durable across
24
- // restarts) and cleared by `/close-in-idea`. While a project is marked, every
25
- // user message assumes the IDE is still up: the plugin asks OpenCode for the
26
- // `idea` MCP server's status and, when it is not connected, re-runs the open
27
- // flow before the request is built. Detection is by MCP status, never by tool
28
- // errors, so a normal business error can never look like a dropped connection.
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; the IDE-injected shell env already carries the SDK paths.
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 接入标记;IDE MCP 断开后将不再自动重连。如需重新接入,请再次执行 /open-in-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 [`【插件通知】${ideFeedback(result)}`, '这是发给用户的通知,你无需处理。请只回复"收到"。'].join('\n\n');
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
- markCache = undefined;
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, serverConfig(port, projectPath));
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
- // Environment: read from the IDE integrated terminal, the single source
451
- // of truth for the environment the IDE actually runs with (version-managed
452
- // Node, goenv Go, SDKMAN Java, Maven, ...). Only available once the IDE is
453
- // connected, which is why the manual `/open-in-idea` drives it.
454
- const terminal = port === undefined ? {} : await readIdeTerminalEnv(port, projectPath);
455
- currentEnv = mergeEnv([{ env: terminal }]);
456
-
457
- if (injectEnv && !shellRegistration) {
458
- shellRegistration = await ctx.shell.hook('create.before', (input) => applyEnv(input, currentEnv));
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
- // Project-level auto-recovery. The only self-initiated action in the
774
- // plugin, and only for a marked project. It belongs to the `prompt` hook
775
- // (not `context`) because the tool catalog for a request is already fixed
776
- // by the time `context` runs: a `prompt`-time reconnect is what lets the
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
- const sessionID = event?.sessionID;
784
- if (typeof sessionID !== 'string') return;
785
- // Never react to the plugin's own notices, or auto-recovery would feed
786
- // on itself. Notices carry `metadata.displayText`; the prefix guards
787
- // against an older notice shape.
788
- const displayText = event?.metadata?.displayText;
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('【插件通知】')) return;
792
- if (!(await readMark())) return;
793
- return autoReconnect(sessionID);
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 (shellRegistration) {
963
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
964
+ if (sessionRegistration) {
800
965
  try {
801
- await shellRegistration.dispose();
966
+ await sessionRegistration.dispose();
802
967
  } catch {
803
968
  // best effort
804
969
  }
805
970
  }
806
- if (sessionRegistration) {
971
+ if (toolRegistration) {
807
972
  try {
808
- await sessionRegistration.dispose();
973
+ await toolRegistration.dispose();
809
974
  } catch {
810
975
  // best effort
811
976
  }