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/README.md +116 -63
- package/package.json +6 -1
- package/src/ide-guidance.js +32 -0
- package/src/ide-guidance.template.md +9 -64
- package/src/mcp/idea.js +1 -1
- package/src/plugin-tools-guidance.template.md +18 -0
- package/src/plugin.js +445 -20
- package/src/remote/hosts.js +269 -0
- package/src/remote/hosts.test.js +171 -0
- package/src/remote/persist.js +106 -0
- package/src/remote/persist.test.js +100 -0
- package/src/remote/prompts.js +52 -0
- package/src/remote/prompts.test.js +38 -0
- package/src/remote/sftp.js +242 -0
- package/src/remote/sftp.test.js +47 -0
- package/src/remote/ssh.js +195 -0
- package/src/remote/ssh.test.js +48 -0
- package/src/remote/sync.test.js +204 -0
- package/src/remote/tools.js +403 -0
- package/src/remote/tools.test.js +238 -0
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
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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: (
|
|
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
|
|
1027
|
+
if (disposed) return 'none';
|
|
767
1028
|
|
|
768
1029
|
// 未配置:没有任何项目要接入。
|
|
769
1030
|
if (!config) {
|
|
770
1031
|
await deactivateIde();
|
|
771
|
-
return
|
|
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
|
|
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
|
|
793
|
-
|
|
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
|
|
1265
|
+
let stage = 'none';
|
|
1001
1266
|
try {
|
|
1002
1267
|
// 重新探测一次并整体注册:ensureProject 只保证 IDE 就绪,注册要按"当前真正
|
|
1003
1268
|
// 打开着哪些项目"重来一遍,这样 `/open-in-ide` 每次都是同一套结果。
|
|
1004
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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.
|
|
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
|
-
|
|
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();
|