opencode-idea 0.1.3 → 0.1.5

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
@@ -68,6 +68,7 @@ import {
68
68
  import {
69
69
  appendIdeGuidance,
70
70
  appendIdeRecoveryGuidance,
71
+ appendRemoteGuidance,
71
72
  } from './ide-guidance.js';
72
73
  import {
73
74
  IDE_LABELS,
@@ -84,6 +85,20 @@ import {
84
85
  withSubproject,
85
86
  } from './ide-projects.js';
86
87
  import {readSkills} from './skill/loader.js';
88
+ import {
89
+ createRemoteToolset,
90
+ remoteDownloadTool,
91
+ remoteExecTool,
92
+ remoteListServersTool,
93
+ remoteUploadTool,
94
+ } from './remote/tools.js';
95
+ import {persistRemoteConfig} from './remote/persist.js';
96
+ import {
97
+ REMOTE_DEPLOYMENT_DISPLAY,
98
+ REMOTE_SHELL_DISPLAY,
99
+ remoteDeploymentPrompt,
100
+ remoteShellPrompt,
101
+ } from './remote/prompts.js';
87
102
 
88
103
  const DEFAULT_LAUNCH_COOLDOWN_MS = 120000;
89
104
  const DEFAULT_LAUNCH_MAX_ATTEMPTS = 5;
@@ -106,6 +121,129 @@ const TOOL_REFRESH_POLL_MS = 100;
106
121
  // beyond any normal IDE tool and turns that hang into a normal error the failure
107
122
  // classifier can act on.
108
123
  const DEFAULT_EXECUTION_TIMEOUT_MS = 600000;
124
+ // `get_project_env` is a small read that the connect flow AWAITS. Three seconds
125
+ // is enough for a healthy IDE and short enough that a wedged one cannot stall
126
+ // `/open-in-ide` or a shell creation.
127
+ const PROJECT_ENV_TIMEOUT_MS = 3000;
128
+ // The project environment is also re-read while the session runs (SDK / Maven
129
+ // settings can change under a live IDE). One read per minute per session is
130
+ // plenty: a shell that starts within the window reuses the cached value.
131
+ const PROJECT_ENV_TTL_MS = 60000;
132
+ // PATH is the one variable the caller owns: IDE directories are PREPENDED so the
133
+ // configured SDK wins over whatever the host shell already had.
134
+ const PATH_DELIMITER = process.platform === 'win32' ? ';' : ':';
135
+
136
+ /**
137
+ * Parse a `get_project_env` result into `{ PATH, variables }`.
138
+ *
139
+ * `structuredContent` is preferred (registered with the tool's output schema);
140
+ * the text body is the fallback because an error result carries no structured
141
+ * content and some IDE builds only fill the text part.
142
+ *
143
+ * @param {{ isError?: boolean, content?: unknown, structuredContent?: unknown } | undefined} result
144
+ * @returns {{ PATH: string, variables: Record<string, string> } | undefined}
145
+ */
146
+ export function projectEnvFromResult(result) {
147
+ if (!result || result.isError === true) return undefined;
148
+ return normalizeProjectEnv(result.structuredContent) ?? normalizeProjectEnv(textJson(result));
149
+ }
150
+
151
+ /** @param {unknown} value */
152
+ function normalizeProjectEnv(value) {
153
+ if (!value || typeof value !== 'object') return undefined;
154
+ const record = /** @type {Record<string, unknown>} */ (value);
155
+ const hasPath = typeof record.PATH === 'string';
156
+ const hasVariables = Boolean(record.variables) && typeof record.variables === 'object';
157
+ // 其它工具也会返回 JSON(`{"modules":[]}` 之类)。没有这两个键就不是 env,
158
+ // 否则任何一次成功的工具调用都会被误当成"环境已就绪"。
159
+ if (!hasPath && !hasVariables) return undefined;
160
+ const PATH = hasPath ? /** @type {string} */ (record.PATH) : '';
161
+ /** @type {Record<string, string>} */
162
+ const variables = {};
163
+ const raw = record.variables;
164
+ if (raw && typeof raw === 'object') {
165
+ for (const [key, entry] of Object.entries(/** @type {Record<string, unknown>} */ (raw))) {
166
+ if (typeof entry === 'string') variables[key] = entry;
167
+ }
168
+ }
169
+ return {PATH, variables};
170
+ }
171
+
172
+ /** @param {{ content?: unknown } | undefined} result */
173
+ function textJson(result) {
174
+ const parts = Array.isArray(result?.content) ? result.content : [];
175
+ const text = parts
176
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
177
+ .map((part) => part.text)
178
+ .join('\n');
179
+ if (!text) return undefined;
180
+ try {
181
+ return JSON.parse(text);
182
+ } catch {
183
+ return undefined;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * 解析 IDE 返回的路径映射。缺任何一头的条目丢掉 —— 少了 localPath 或 deployPath 都传不了。
189
+ *
190
+ * @param {unknown} raw
191
+ * @returns {Array<{ name: string, localPath: string, deployPath: string }>}
192
+ */
193
+ function parseDeploymentMappings(raw) {
194
+ if (!Array.isArray(raw)) return [];
195
+ return raw.flatMap((entry) => {
196
+ if (!entry || typeof entry !== 'object') return [];
197
+ const record = /** @type {Record<string, unknown>} */ (entry);
198
+ const name = typeof record.name === 'string' ? record.name : '';
199
+ const localPath = typeof record.localPath === 'string' ? record.localPath : '';
200
+ const deployPath = typeof record.deployPath === 'string' ? record.deployPath : '';
201
+ if (!name || !localPath || !deployPath) return [];
202
+ return [{name, localPath, deployPath}];
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Parse a `get_deployment_servers` result into the plugin's own server shape.
208
+ *
209
+ * 只认有 name + host 的条目;其余字段缺了就用默认值 —— 凭据缺失的服务器仍然列出来
210
+ * (`remote_list_servers` 要能看到它),执行时才报"没有凭据"。
211
+ *
212
+ * @param {{ isError?: boolean, content?: unknown, structuredContent?: unknown } | undefined} result
213
+ */
214
+ export function ideServersFromResult(result) {
215
+ if (!result || result.isError === true) return [];
216
+ const payload = (result.structuredContent && typeof result.structuredContent === 'object')
217
+ ? result.structuredContent
218
+ : textJson(result);
219
+ const servers = /** @type {{ servers?: unknown }} */ (payload)?.servers;
220
+ if (!Array.isArray(servers)) return [];
221
+
222
+ return servers.flatMap((entry) => {
223
+ if (!entry || typeof entry !== 'object') return [];
224
+ const record = /** @type {Record<string, unknown>} */ (entry);
225
+ // 主键用 SSH 配置名(IDEA 的 `SshConfig`,即用户眼里的"服务器"):同一台主机上配多套
226
+ // root/mapping 只是多条 deployment,不该变成多台 server。旧版插件不返回这个字段时,
227
+ // 回落到 WebServer 名,保证升级前后的调用都还能用。
228
+ const sshConfigName = typeof record.sshConfigName === 'string' ? record.sshConfigName.trim() : '';
229
+ const name = sshConfigName || (typeof record.name === 'string' ? record.name : '');
230
+ const host = typeof record.host === 'string' ? record.host : '';
231
+ if (!name || !host) return [];
232
+ return [{
233
+ name,
234
+ host,
235
+ port: typeof record.port === 'number' && record.port > 0 ? record.port : 22,
236
+ username: typeof record.username === 'string' ? record.username : '',
237
+ password: typeof record.password === 'string' && record.password !== '' ? record.password : undefined,
238
+ passphrase: typeof record.passphrase === 'string' && record.passphrase !== '' ? record.passphrase : undefined,
239
+ privateKeyPath: typeof record.privateKeyPath === 'string' && record.privateKeyPath !== ''
240
+ ? record.privateKeyPath
241
+ : undefined,
242
+ deployments: parseDeploymentMappings(record.deployments),
243
+ source: 'ide',
244
+ }];
245
+ });
246
+ }
109
247
 
110
248
  /**
111
249
  * 插件自己注册的工具名(不属于任何 IDE server)。
@@ -183,12 +321,14 @@ const PLUGIN_TOOL_NAMES = new Set(['configure_ide_project']);
183
321
  * environment. It was exposed on the assumption that it ran with the SDKs the
184
322
  * user configured in the IDE (JDK, Node, Python, ...), but measured against a
185
323
  * real IDE it brings no environment a native `shell` call does not already
186
- * have — so it is a strictly worse shell, and hiding it removes an entire
187
- * class of "why is it using an old JDK" confusion. Which tool to use instead
188
- * is a routing decision, and it is made in `ide-guidance.js`: commands that
189
- * need the project's environment go through a RUN CONFIGURATION (the IDE
190
- * launches those with exactly the configured SDK), everything else goes
191
- * through the native shell.
324
+ * have. The project environment is delivered by `get_project_env` through the
325
+ * plugin's own `shell` hook, so the native shell IS the project shell. Remote
326
+ * commands are handled by this plugin's `remote_exec` (over SSH) instead of the
327
+ * IDE's own remote terminal, so those tools are hidden as well.
328
+ * - Environment / credential channels: `get_project_env` and
329
+ * `get_deployment_servers` are data channels for this plugin, not tasks. The
330
+ * model never needs them, and exposing them only invites calls that duplicate
331
+ * what the plugin already does.
192
332
  */
193
333
  export const HIDDEN_IDE_TOOL_SUFFIXES = [
194
334
  // VCS
@@ -198,6 +338,21 @@ export const HIDDEN_IDE_TOOL_SUFFIXES = [
198
338
  'execute_tool',
199
339
  // IDE terminal: no advantage over the native shell (see the note above).
200
340
  'execute_terminal_command',
341
+ // Remote terminal / transfer: replaced by this plugin's remote_exec / remote_upload /
342
+ // remote_download. These suffixes now name tools the companion plugin no longer ships —
343
+ // they stay here so an installation that has not upgraded yet cannot leak them to the model.
344
+ 'execute_in_remote_host',
345
+ 'terminal_read',
346
+ 'terminal_interrupt',
347
+ 'list_terminals',
348
+ 'list_server_deployments',
349
+ 'upload_to_deployment',
350
+ 'download_from_deployment',
351
+ 'get_deployment_task_status',
352
+ // Environment channel: consumed by this plugin's shell hook (see the note above).
353
+ 'get_project_env',
354
+ // Credential channel: consumed by this plugin's remote_exec (see the note above).
355
+ 'get_deployment_servers',
201
356
  // Debugger
202
357
  'xdebug_control_session',
203
358
  'xdebug_evaluate_expression',
@@ -399,6 +554,7 @@ function rewriteToolError(event, message) {
399
554
  * status:
400
555
  * | 'connected'
401
556
  * | 'tools-loading'
557
+ * | 'env-unready'
402
558
  * | 'mcp-unavailable'
403
559
  * | 'launch-failed'
404
560
  * | 'disabled'
@@ -440,6 +596,10 @@ export function ideFeedback(result) {
440
596
  }
441
597
  case 'tools-loading':
442
598
  return 'IDE MCP 已连接,但原生 IDE 工具列表尚未完成注册。请稍后再次执行 /open-in-ide。';
599
+ case 'env-unready': {
600
+ const where = nameOf(result?.project);
601
+ return `IDE MCP 已连接(${where}),但项目环境变量没取到(get_project_env 失败),连接未完成。请重试 /open-in-ide;若一直失败,检查 IDE 的日志。`;
602
+ }
443
603
  case 'mcp-unavailable': {
444
604
  const where = nameOf(result?.project);
445
605
  const ide = appOf(result?.project);
@@ -455,6 +615,10 @@ export function ideFeedback(result) {
455
615
  return '当前系统不支持自动打开 IDE(仅 macOS 实现了启动)。请手动打开 IDE,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-ide。';
456
616
  case 'deleted':
457
617
  return '已删除本项目的 IDE 配置:已注销全部 IDE MCP 并清除配置。如需重新接入,请执行 /open-in-ide。';
618
+ case 'persisted':
619
+ return result?.message ?? '已把 IDE 配置写入项目。';
620
+ case 'persist-failed':
621
+ return result?.message ?? '持久化失败。';
458
622
  case 'bad-argument':
459
623
  return `命令参数有误:${result?.message ?? ''}`;
460
624
  case 'not-configured':
@@ -552,7 +716,11 @@ export default {
552
716
  * reload?: () => Promise<void>,
553
717
  * list?: () => Promise<{ data?: ReadonlyArray<{ name?: string, status?: { status?: string } }> }>,
554
718
  * },
555
- * shell: { hook: (name: string, cb: (input: { env: Record<string, string | undefined> }) => void) => Promise<{ dispose: () => Promise<void> | void }> },
719
+ * shell: { hook: (name: string, cb: (event: {
720
+ * command: string,
721
+ * cwd: string,
722
+ * env: Record<string, string | undefined>,
723
+ * }) => Promise<void> | void) => Promise<{ dispose: () => Promise<void> | void }> },
556
724
  * storage?: {
557
725
  * get: (key: string) => Promise<unknown>,
558
726
  * set: (key: string, value: unknown) => Promise<void>,
@@ -592,6 +760,10 @@ export default {
592
760
  let toolTransformRegistration;
593
761
  /** `configure_ide_project` 的注册。 */
594
762
  let configToolRegistration;
763
+ /** `shell.hook("create.before")` 的注册:把项目环境注入每条新 shell。 */
764
+ let shellRegistration;
765
+ /** `remote_exec` / `remote_list_servers` 的注册。 */
766
+ let remoteToolsRegistration;
595
767
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
596
768
  let promptRegistration;
597
769
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
@@ -610,6 +782,14 @@ export default {
610
782
  // not a root that simply never connected.
611
783
  let wasServing = false;
612
784
 
785
+ // root 项目的环境变量(`get_project_env` 的结果)。连接成功后才会有值;
786
+ // 每次 shell 创建时读它,出 TTL 就同一次同步重拉(3s 上限,失败不注入)。
787
+ /** @type {{ PATH: string, variables: Record<string, string> } | undefined} */
788
+ let projectEnv;
789
+ let projectEnvAt = 0;
790
+ /** @type {Promise<boolean> | undefined} */
791
+ let envLoadInFlight;
792
+
613
793
  // 本会话的 IDE 配置:root 用哪个 IDE,以及登记了哪些子项目、各自用哪个 IDE。
614
794
  // 一个插件实例服务一个 root(OpenCode 按 Location 激活插件),所以这就是全部
615
795
  // 状态。持久化在 `ctx.storage` 的 `project/<rootPath>` 下。
@@ -695,6 +875,9 @@ export default {
695
875
 
696
876
  /** 注销当前注册的全部 IDE server。 */
697
877
  const deactivateIde = async () => {
878
+ // 连接不复存在,环境变量缓存也一起作废:下一条 shell 不会再拿到旧项目的 PATH。
879
+ projectEnv = undefined;
880
+ projectEnvAt = 0;
698
881
  const current = registrations.slice();
699
882
  registrations.length = 0;
700
883
  if (current.length === 0) return;
@@ -730,6 +913,72 @@ export default {
730
913
  */
731
914
  const portFor = (project) => IDE_PORTS[project.app];
732
915
 
916
+ /**
917
+ * 拉 **root 项目** 的环境变量(`get_project_env`)。
918
+ *
919
+ * 只取 root:PATH 的语义是"这个会话的基础环境",按子目录分流会把同一份
920
+ * shell 状态切成几套互不相干的 PATH;子项目要独立环境时,用户在它自己的
921
+ * 会话里工作更符合直觉。
922
+ *
923
+ * 这是**连接的硬条件**:`reconcileOnce` 只有它成功才算 ready,失败时
924
+ * `/open-in-ide` 报 `env-unready`,不会静默降级成"工具有了但环境是空的"。
925
+ *
926
+ * @returns {Promise<boolean>}
927
+ */
928
+ const loadProjectEnv = async () => {
929
+ if (envLoadInFlight) return envLoadInFlight;
930
+ const run = (async () => {
931
+ const root = currentProjects().find((project) => project.isRoot);
932
+ const port = root ? portFor(root) : undefined;
933
+ if (!root || port === undefined) return false;
934
+ const result = await callTool(port, root.path, 'get_project_env', {}, {
935
+ timeoutMs: PROJECT_ENV_TIMEOUT_MS,
936
+ }).catch(() => undefined);
937
+ const env = projectEnvFromResult(result);
938
+ if (!env) return false;
939
+ projectEnv = env;
940
+ projectEnvAt = Date.now();
941
+ return true;
942
+ })();
943
+ envLoadInFlight = run;
944
+ try {
945
+ return await run;
946
+ } finally {
947
+ if (envLoadInFlight === run) envLoadInFlight = undefined;
948
+ }
949
+ };
950
+
951
+ /**
952
+ * 远程执行的数据源:IDE 的 Deployment server + 凭据。
953
+ *
954
+ * 每次 `remote_exec` 命中缓存就不打 IDE;未命中或连接失败才强制刷新(见
955
+ * `createRemoteToolset`)。IDE 没开时抛错,toolset 会吞掉并退到项目级 /
956
+ * 全局的 hosts.json。
957
+ */
958
+ const fetchIdeServers = async () => {
959
+ const root = currentProjects().find((project) => project.isRoot);
960
+ const port = root ? portFor(root) : undefined;
961
+ if (!root || port === undefined) {
962
+ throw new Error('还没有接入 IDE 项目:先执行 /open-in-ide。');
963
+ }
964
+ const result = await callTool(port, root.path, 'get_deployment_servers', {}, {
965
+ timeoutMs: PROJECT_ENV_TIMEOUT_MS,
966
+ }).catch(() => undefined);
967
+ if (!result) throw new Error('IDE MCP 未连接:先执行 /open-in-ide。');
968
+ if (result.isError === true) {
969
+ // 工具自己的错误文本(例如 [deployment_config_incomplete] 点名了哪台缺配置)
970
+ // 原样往上抛,调用方直接转给用户,不要覆盖成一句"不可用"。
971
+ const text = (result.content ?? [])
972
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
973
+ .map((part) => part.text)
974
+ .join('\n');
975
+ throw new Error(text || 'get_deployment_servers 返回错误。');
976
+ }
977
+ return ideServersFromResult(result);
978
+ };
979
+
980
+ const remoteToolset = createRemoteToolset({projectPath, fetchIdeServers});
981
+
733
982
  const refreshIdeTools = async () => {
734
983
  if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
735
984
  // Reconcile the MCP servers above, then replay the tool registry so the
@@ -762,19 +1011,31 @@ export default {
762
1011
  return refreshIdeTools();
763
1012
  };
764
1013
 
1014
+ /**
1015
+ * 重连一次,返回这次连接走到了哪一步:
1016
+ * - `ready`:工具目录 + root 环境变量都到位;
1017
+ * - `tools`:server 注册了,但工具目录没落地;
1018
+ * - `env`:工具可用,但 `get_project_env` 没取到;
1019
+ * - `none`:没有可连的项目 / 未配置 / 本次不探测。
1020
+ *
1021
+ * 分阶段返回是必要的:`tools-loading` 与 `env-unready` 是两种完全不同的失败,
1022
+ * 调用方的提示与重试节奏也不同,用 boolean 会把它们混成一个。
1023
+ *
1024
+ * @returns {Promise<'ready' | 'tools' | 'env' | 'none'>}
1025
+ */
765
1026
  const reconcileOnce = async ({probeIde = true} = {}) => {
766
- if (disposed) return false;
1027
+ if (disposed) return 'none';
767
1028
 
768
1029
  // 未配置:没有任何项目要接入。
769
1030
  if (!config) {
770
1031
  await deactivateIde();
771
- return false;
1032
+ return 'none';
772
1033
  }
773
1034
  // Manual: only the commands probe the IDE. Setup calls this with
774
1035
  // `probeIde: false` so it never connects until a command asks.
775
1036
  if (!probeIde) {
776
1037
  await deactivateIde();
777
- return false;
1038
+ return 'none';
778
1039
  }
779
1040
 
780
1041
  const bindings = [];
@@ -789,8 +1050,12 @@ export default {
789
1050
  // the request hooks are rare; rebuilding costs one restart, which is the
790
1051
  // right trade for never leaving a dead registration behind.
791
1052
  await deactivateIde();
792
- if (bindings.length === 0) return false;
793
- return activateIde(bindings);
1053
+ if (bindings.length === 0) return 'none';
1054
+ const toolsReady = await activateIde(bindings);
1055
+ if (!toolsReady) return 'tools';
1056
+ // 环境变量是连接完成的最后一步:拉不到就等于这次连接没做完,
1057
+ // 调用方(`/open-in-ide`、自愈)会据此重试,而不是当作 ready。
1058
+ return (await loadProjectEnv()) ? 'ready' : 'env';
794
1059
  };
795
1060
 
796
1061
  // Serialize reconciles so overlapping callers can never register/activate
@@ -997,18 +1262,29 @@ export default {
997
1262
  });
998
1263
  return results[0];
999
1264
  }
1000
- let toolsReady = false;
1265
+ let stage = 'none';
1001
1266
  try {
1002
1267
  // 重新探测一次并整体注册:ensureProject 只保证 IDE 就绪,注册要按"当前真正
1003
1268
  // 打开着哪些项目"重来一遍,这样 `/open-in-ide` 每次都是同一套结果。
1004
- toolsReady = (await reconcile({probeIde: true})) === true;
1269
+ stage = await reconcile({probeIde: true});
1005
1270
  } catch {
1006
1271
  // capability load is best effort; feedback still matters
1007
1272
  }
1008
1273
  const connectedProjects = connected.map((result) => result.project);
1009
- return toolsReady
1010
- ? {status: 'connected', port: connected[0].port, projects: connectedProjects}
1011
- : {...connected[0], status: 'tools-loading', projects: connectedProjects};
1274
+ if (stage === 'ready') {
1275
+ return {status: 'connected', port: connected[0].port, projects: connectedProjects};
1276
+ }
1277
+ // IDE 已连上、但 env 没拉到:连接按"没做完"处理,命令会要求重试,
1278
+ // 而不是报一个看起来成功的 connected。
1279
+ if (stage === 'env') {
1280
+ return {
1281
+ status: 'env-unready',
1282
+ port: connected[0].port,
1283
+ project: connected[0].project,
1284
+ projects: connectedProjects,
1285
+ };
1286
+ }
1287
+ return {...connected[0], status: 'tools-loading', projects: connectedProjects};
1012
1288
  };
1013
1289
 
1014
1290
  /**
@@ -1038,7 +1314,7 @@ export default {
1038
1314
  await deactivateIde();
1039
1315
  return;
1040
1316
  }
1041
- if (result.port !== undefined) {
1317
+ if (result.status === 'connected') {
1042
1318
  needsRebuild = false;
1043
1319
  }
1044
1320
  } catch {
@@ -1321,6 +1597,96 @@ export default {
1321
1597
  await sendFeedback(input, {status: 'deleted'});
1322
1598
  },
1323
1599
  });
1600
+ // 把 IDE 里的 server + deployment 固定到项目配置:之后 IDE 没开也能列出服务器与路径映射
1601
+ // (凭据要么 IDE 在线时提供,要么在全局 hosts.json 里配)。
1602
+ editor.add({
1603
+ name: 'persist-remote-config',
1604
+ description: '把 IDE 里配置的 server 与 deployment 持久化到项目的 .opencode/remote-shell/hosts.json',
1605
+ execute: async (input) => {
1606
+ let servers;
1607
+ try {
1608
+ servers = await fetchIdeServers();
1609
+ } catch (error) {
1610
+ await sendFeedback(input, {
1611
+ status: 'persist-failed',
1612
+ message:
1613
+ '读取 IDE 配置失败:' +
1614
+ (error instanceof Error ? error.message : String(error)),
1615
+ });
1616
+ return;
1617
+ }
1618
+ try {
1619
+ const result = persistRemoteConfig({projectPath, servers});
1620
+ // 缓存里还是旧的列表,紧接着的 remote_exec 会读不到刚写进去的条目。
1621
+ await remoteToolset.load({force: true}).catch(() => {});
1622
+ await sendFeedback(input, {
1623
+ status: 'persisted',
1624
+ message:
1625
+ `已写入 ${result.path}\n` +
1626
+ `新增 ${result.added} 台 / 更新 ${result.updated} 台 / 保留 ${result.kept} 台,` +
1627
+ `共 ${result.total} 台、${result.deployments} 条 deployment。`,
1628
+ });
1629
+ } catch (error) {
1630
+ await sendFeedback(input, {
1631
+ status: 'persist-failed',
1632
+ message: `写入项目配置失败:${error instanceof Error ? error.message : String(error)}`,
1633
+ });
1634
+ }
1635
+ },
1636
+ });
1637
+
1638
+ // 两条提示词命令:不执行任何东西,只把"用 remote_* 工具、别自己拼 ssh"这条规则注入会话。
1639
+ // 参数原样附在末尾;没带参数时提示词会要求模型先问清楚。
1640
+ editor.add({
1641
+ name: 'remote-shell',
1642
+ description: '远程执行模式:引导 AI 用 remote_exec / remote_upload / remote_download 而不是 ssh',
1643
+ execute: async (input) => {
1644
+ if (typeof ctx.session?.prompt !== 'function') return;
1645
+ const request = input?.prompt?.text ?? '';
1646
+ try {
1647
+ await ctx.session.prompt({
1648
+ ...input.prompt,
1649
+ sessionID: input.sessionID,
1650
+ text: remoteShellPrompt(request),
1651
+ // 用用户自己写的那句话做气泡,不要用固定文案 —— 否则 `/remote-shell xxx` 的
1652
+ // 参数在界面上就消失了,看起来像被吞掉。
1653
+ metadata: {
1654
+ displayText: request.trim()
1655
+ ? `【远程执行】${request.trim()}`
1656
+ : REMOTE_SHELL_DISPLAY,
1657
+ comments: [],
1658
+ },
1659
+ delivery: input.delivery,
1660
+ });
1661
+ } catch {
1662
+ // 提示词投递失败不该把命令本身变成错误
1663
+ }
1664
+ },
1665
+ });
1666
+ editor.add({
1667
+ name: 'remote-deployment',
1668
+ description: '远程部署模式:引导 AI 用 remote_upload / remote_download 而不是 scp / rsync',
1669
+ execute: async (input) => {
1670
+ if (typeof ctx.session?.prompt !== 'function') return;
1671
+ const request = input?.prompt?.text ?? '';
1672
+ try {
1673
+ await ctx.session.prompt({
1674
+ ...input.prompt,
1675
+ sessionID: input.sessionID,
1676
+ text: remoteDeploymentPrompt(request),
1677
+ metadata: {
1678
+ displayText: request.trim()
1679
+ ? `【远程部署同步】${request.trim()}`
1680
+ : REMOTE_DEPLOYMENT_DISPLAY,
1681
+ comments: [],
1682
+ },
1683
+ delivery: input.delivery,
1684
+ });
1685
+ } catch {
1686
+ // 同上
1687
+ }
1688
+ },
1689
+ });
1324
1690
  });
1325
1691
  }
1326
1692
 
@@ -1335,6 +1701,45 @@ export default {
1335
1701
  });
1336
1702
  }
1337
1703
 
1704
+ // 远程执行:两个普通工具(不是 IDE 工具,所以不带 `ide_` 前缀)。
1705
+ // 注册是常驻的:IDE 没开时它们仍然可用,只是服务器列表退到 hosts.json。
1706
+ if (typeof ctx.tool?.transform === 'function') {
1707
+ remoteToolsRegistration = await ctx.tool.transform((editor) => {
1708
+ editor.add(remoteExecTool(remoteToolset));
1709
+ editor.add(remoteListServersTool(remoteToolset));
1710
+ editor.add(remoteUploadTool(remoteToolset));
1711
+ editor.add(remoteDownloadTool(remoteToolset));
1712
+ });
1713
+ }
1714
+
1715
+ // 项目环境注入:**所有** shell(模型的 bash 工具与用户终端)在创建前都会经过这里。
1716
+ //
1717
+ // 只有 PATH 是"拼"的:IDE 配置的目录前置,调用方原有的 PATH 接在后面 —— 这样
1718
+ // IDE 里选的 JDK / Node / Go / Maven / Python 真的优先生效,同时不丢掉 OpenCode
1719
+ // 进程自己的 PATH。其余变量(GOROOT / MAVEN_HOME / VIRTUAL_ENV ...)直接覆盖。
1720
+ //
1721
+ // 缓存命中不阻塞;过期时在同一次 hook 里同步重拉(3s 上限),拉不到就不注入 ——
1722
+ // 一条命令不该因为 IDE 短暂不可用而失败。
1723
+ if (typeof ctx.shell?.hook === 'function') {
1724
+ shellRegistration = await ctx.shell.hook('create.before', async (event) => {
1725
+ const env = event?.env;
1726
+ if (!env || typeof env !== 'object') return;
1727
+ let current = projectEnv;
1728
+ if (!current || Date.now() - projectEnvAt > PROJECT_ENV_TTL_MS) {
1729
+ current = (await loadProjectEnv().catch(() => false)) ? projectEnv : undefined;
1730
+ }
1731
+ if (!current) return;
1732
+ if (current.PATH) {
1733
+ env.PATH = env.PATH
1734
+ ? `${current.PATH}${PATH_DELIMITER}${env.PATH}`
1735
+ : current.PATH;
1736
+ }
1737
+ for (const [key, value] of Object.entries(current.variables)) {
1738
+ env[key] = value;
1739
+ }
1740
+ });
1741
+ }
1742
+
1338
1743
  // Hide the tools the model must not see, and point it at the rest. This hook
1339
1744
  // runs for every agent-loop step (including tool-driven continuations), so
1340
1745
  // it stays a cheap, I/O-free function of the request: it checks the cached
@@ -1373,6 +1778,8 @@ export default {
1373
1778
  appendIdeRecoveryGuidance(event?.system, {marked: Boolean(config), serving});
1374
1779
  }
1375
1780
  if (!injectGuidance) return;
1781
+ // 远程工具是常驻的(IDE 关掉也在,退到 hosts.json),指引不能跟着 IDE 连接一起消失。
1782
+ appendRemoteGuidance(event?.system);
1376
1783
  // Guidance promises tools the model can call, so it only goes out when
1377
1784
  // they are actually in this request.
1378
1785
  if (!serving) return;
@@ -1405,11 +1812,12 @@ export default {
1405
1812
  const text = event?.prompt?.text;
1406
1813
  if (typeof text === 'string' && text.startsWith(NOTICE_PREFIX)) return;
1407
1814
  try {
1408
- await reconcile({probeIde: true});
1815
+ // 只有"连接完整"(工具 + env 都到位)才清 latch;env 没拉到必须继续重试,
1816
+ // 否则会话会永久停在"工具有、环境是空的"这个中间态。
1817
+ if ((await reconcile({probeIde: true})) === 'ready') needsRebuild = false;
1409
1818
  } catch {
1410
1819
  // best effort: the latch stays set and the next message retries
1411
1820
  }
1412
- if (registrations.length > 0) needsRebuild = false;
1413
1821
  });
1414
1822
 
1415
1823
  // The IDE's tool-catalog policy, in two halves that must stay in sync:
@@ -1479,6 +1887,9 @@ export default {
1479
1887
  return;
1480
1888
  }
1481
1889
  const result = await runOpenInIdea();
1890
+ // 这里只关心"工具调用能不能重试":IDE 连上了(port 有值)就够了。
1891
+ // 环境变量没拉到属于后续 shell 的问题,由 `/open-in-ide` 与自愈去修,
1892
+ // 不该让这次工具调用失去重试的提示。
1482
1893
  if (result.port === undefined) return;
1483
1894
  rewriteToolError(event, 'IDE MCP 已重新连接,请重试刚才的操作。');
1484
1895
  });
@@ -1522,6 +1933,20 @@ export default {
1522
1933
  // best effort
1523
1934
  }
1524
1935
  }
1936
+ if (shellRegistration) {
1937
+ try {
1938
+ await shellRegistration.dispose();
1939
+ } catch {
1940
+ // best effort
1941
+ }
1942
+ }
1943
+ if (remoteToolsRegistration) {
1944
+ try {
1945
+ await remoteToolsRegistration.dispose();
1946
+ } catch {
1947
+ // best effort
1948
+ }
1949
+ }
1525
1950
  if (promptRegistration) {
1526
1951
  try {
1527
1952
  await promptRegistration.dispose();