@xyagent/cli 0.0.1 → 1.1.0
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 +42 -0
- package/bin/agentlink +1002 -245
- package/bin/agentlink-agent +0 -0
- package/bin/agentlink-mcp-stdio +0 -0
- package/package.json +5 -3
- package/src/core.mjs +26 -0
- package/src/tunnel_service.mjs +17 -1
- package/src-ext/bin.mjs +12 -12
- package/src-ext/commands/agent.mjs +33 -10
- package/src-ext/commands/pair.mjs +122 -23
- package/src-ext/commands/service.mjs +1 -1
- package/src-ext/core/activeRuns.mjs +26 -9
- package/src-ext/core/agentlinkToolsBootstrap.mjs +1 -1
- package/src-ext/core/autoTunnelDetector.mjs +6 -1
- package/src-ext/core/autoTunnelWiring.mjs +1 -1
- package/src-ext/core/bridgeSelfUninstall.mjs +1 -1
- package/src-ext/core/defaultWorkspace.mjs +43 -13
- package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
- package/src-ext/core/installationIdentity.mjs +94 -0
- package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
- package/src-ext/core/pairCodeClient.mjs +48 -8
- package/src-ext/core/pairInventory.mjs +31 -6
- package/src-ext/core/relayWorker.mjs +22 -2
- package/src-ext/core/runtimeRegistry.mjs +180 -0
- package/src-ext/core/scanDeeplink.mjs +67 -0
- package/src-ext/core/scanPairFlow.mjs +28 -0
- package/src-ext/core/scanQrRenderer.mjs +40 -0
- package/src-ext/core/scanWorkspaces.mjs +163 -23
- package/src-ext/core/unifiedDispatchHandler.mjs +12 -5
- package/src-ext/core/usageReporter.mjs +1 -2
- package/src-ext/openclaw-plugin/envelope-builder.cjs +2 -2
- package/src-ext/openclaw-plugin/todoTranslatorUtils.cjs +1 -1
- package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
- package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
- package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
- package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
- package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
- package/src-ext/runtime/_shared/relayObjectToBlock.mjs +9 -11
- package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
- package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
- package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +1 -1
- package/src-ext/runtime/claude/handleRequest.mjs +15 -37
- package/src-ext/runtime/claude/launcher.mjs +0 -6
- package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
- package/src-ext/runtime/codebuddy/index.mjs +41 -0
- package/src-ext/runtime/codex/handleRequest.mjs +13 -35
- package/src-ext/runtime/cursor/index.mjs +46 -0
- package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
- package/src-ext/runtime/deepagents/preflight.mjs +57 -0
- package/src-ext/runtime/hermes/envSetup.mjs +22 -8
- package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
- package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
- package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
- package/src-ext/runtime/hermes/index.mjs +1 -1
- package/src-ext/runtime/hermes/preflight.mjs +2 -1
- package/src-ext/runtime/kimi/index.mjs +100 -0
- package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +0 -6
- package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
- package/src-ext/runtime/opencode/index.mjs +48 -0
- package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
- package/src-ext/runtime/opencode/preflight.mjs +72 -0
- package/src-ext/runtime/qwen/index.mjs +42 -0
- package/src-ext/service/serviceManager.mjs +120 -42
- package/src-shared/envelope_builder.mjs +2 -2
- package/src-ext/runtime/picoclaw/constants.mjs +0 -39
- package/src-ext/runtime/picoclaw/handleRequest.mjs +0 -289
- package/src-ext/runtime/picoclaw/index.mjs +0 -314
- package/src-ext/runtime/picoclaw/pairFlow.mjs +0 -224
- package/src-ext/runtime/picoclaw/state.mjs +0 -78
- package/src-ext/runtime/picoclaw/todoTranslator.mjs +0 -67
- package/src-ext/runtime/picoclaw/translator.mjs +0 -272
- package/src-ext/runtime/picoclaw/wsClient.mjs +0 -129
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { randomBytes } from "node:crypto";
|
|
27
|
+
import { statSync } from "node:fs";
|
|
27
28
|
import os from "node:os";
|
|
28
29
|
import path from "node:path";
|
|
29
30
|
|
|
@@ -113,12 +114,19 @@ const SYSTEM_PROMPT_DEFAULT =
|
|
|
113
114
|
*/
|
|
114
115
|
function _resolveWorkspacePathProfile(workspacePath) {
|
|
115
116
|
if (!workspacePath || typeof workspacePath !== "string") return null;
|
|
117
|
+
const raw = workspacePath.trim();
|
|
118
|
+
if (!path.isAbsolute(raw) && raw !== "~" && !raw.startsWith("~/")) return null;
|
|
116
119
|
// expand ~ 前缀
|
|
117
120
|
const expanded = workspacePath.startsWith("~")
|
|
118
121
|
? path.join(os.homedir(), workspacePath.slice(1))
|
|
119
122
|
: workspacePath;
|
|
120
123
|
const normalized = path.resolve(expanded);
|
|
121
124
|
if (!normalized) return null;
|
|
125
|
+
try {
|
|
126
|
+
if (!statSync(normalized).isDirectory()) return null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
122
130
|
|
|
123
131
|
// align-runtime-default-workspace: root hermesHome(~/.hermes)→ profileName="default"
|
|
124
132
|
const hermesRoot = path.join(os.homedir(), ".hermes");
|
|
@@ -209,6 +217,11 @@ export function createHermesHandleRequest({ pre, log, gwId, usage, dispatchHandl
|
|
|
209
217
|
let effectiveApiKey = pre.apiKey; // 默认:bridge 启动时固定的 apiKey
|
|
210
218
|
|
|
211
219
|
const workspaceResolved = workspace_path ? _resolveWorkspacePathProfile(workspace_path) : null;
|
|
220
|
+
if (workspace_path && !workspaceResolved) {
|
|
221
|
+
const error = new Error("hermes: requested workspace context is unavailable");
|
|
222
|
+
error.code = "workspace_context_unavailable";
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
212
225
|
if (workspaceResolved) {
|
|
213
226
|
const { profileName, profileHome: reqProfileHome } = workspaceResolved;
|
|
214
227
|
let gwResult;
|
|
@@ -145,6 +145,17 @@ export async function* streamChat(messages, opts) {
|
|
|
145
145
|
try { return JSON.parse(payload); } catch { return null; }
|
|
146
146
|
})();
|
|
147
147
|
if (parsed) {
|
|
148
|
+
// Hermes 网关在上游鉴权、额度或模型调用失败时仍保持 HTTP 200,
|
|
149
|
+
// 错误位于最后一个 SSE chunk。不能把它当作空的 completed,否则
|
|
150
|
+
// Desktop 会永久表现成“没有回复”且用户看不到真实处置原因。
|
|
151
|
+
const parsedChoice = parsed?.choices?.[0];
|
|
152
|
+
if (parsed?.error || parsedChoice?.finish_reason === "error") {
|
|
153
|
+
const message = String(
|
|
154
|
+
parsed?.error?.message || parsed?.hermes?.error || "Hermes upstream request failed",
|
|
155
|
+
).trim();
|
|
156
|
+
yield { kind: "error", error: message || "Hermes upstream request failed" };
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
148
159
|
// OpenAI streaming shape: choices[].delta.content + optional usage on
|
|
149
160
|
// last chunk (with stream_options.include_usage=true).
|
|
150
161
|
//
|
|
@@ -156,7 +167,7 @@ export async function* streamChat(messages, opts) {
|
|
|
156
167
|
// thinking inside delta.content (e.g. Gemini via OpenAI-compat
|
|
157
168
|
// gateway) won't trigger this branch — that's expected, since the
|
|
158
169
|
// upstream has not split reasoning out.
|
|
159
|
-
const choice =
|
|
170
|
+
const choice = parsedChoice;
|
|
160
171
|
const reasoningDelta = choice?.delta?.reasoning_content;
|
|
161
172
|
if (typeof reasoningDelta === "string" && reasoningDelta.length > 0) {
|
|
162
173
|
yield { kind: "thinking", text: reasoningDelta };
|
|
@@ -109,7 +109,7 @@ export async function runHermesBridge({ options, positional, log }) {
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
const worker = createRelayWorker({
|
|
112
|
-
relayUrl: relayBase, gwId, bridgeToken, runtime: "hermes", log: sessLog,
|
|
112
|
+
relayUrl: relayBase, gwId, bridgeToken, runtime: "hermes", profileName, log: sessLog,
|
|
113
113
|
});
|
|
114
114
|
installAutoTunnel({ relayUrl: relayBase, log: sessLog });
|
|
115
115
|
|
|
@@ -320,7 +320,8 @@ export async function hermesPreflightWithHeal({ options = {}, log, hermesHome }
|
|
|
320
320
|
const envChanged =
|
|
321
321
|
envResult.status === "patched" &&
|
|
322
322
|
(envResult.overridden?.length > 0 ||
|
|
323
|
-
(envResult.appended || []).
|
|
323
|
+
(envResult.appended || []).some((key) =>
|
|
324
|
+
key === "API_SERVER_ENABLED" || key === "API_SERVER_KEY"));
|
|
324
325
|
const mode = envChanged ? "restart" : "ensure";
|
|
325
326
|
const effectiveHome = hermesHome || resolveHermesHome();
|
|
326
327
|
const logPath = path.join(effectiveHome, "gateway.log");
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { recordBridgedSession } from "../_shared/bridgedSessionLedger.mjs";
|
|
2
|
+
import { runHeadlessCliBridge } from "../_shared/headlessCliBridge.mjs";
|
|
3
|
+
import { probeBinary, runNdjsonProcess } from "../_shared/ndjsonProcess.mjs";
|
|
4
|
+
import { resolveWorkspaceCwd } from "../_shared/resolveWorkspaceCwd.mjs";
|
|
5
|
+
|
|
6
|
+
export function kimiPreflight({ probe = probeBinary } = {}) {
|
|
7
|
+
let result = probe("kimi");
|
|
8
|
+
let binary = "kimi";
|
|
9
|
+
if (!result.ok) {
|
|
10
|
+
const legacy = probe("kimi-cli");
|
|
11
|
+
if (legacy.ok) {
|
|
12
|
+
result = legacy;
|
|
13
|
+
binary = "kimi-cli";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (!result.ok) result.hint = "请先安装 Kimi Code CLI,并执行 kimi login 完成认证。";
|
|
17
|
+
return { ...result, binary };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function translateKimiEvent(event) {
|
|
21
|
+
if (!event || typeof event !== "object") return null;
|
|
22
|
+
if (event.role === "assistant") {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
if (event.content) {
|
|
25
|
+
chunks.push({ event: "response.output_text.delta", delta: String(event.content) });
|
|
26
|
+
}
|
|
27
|
+
for (const call of Array.isArray(event.tool_calls) ? event.tool_calls : []) {
|
|
28
|
+
const callId = String(call?.id || "");
|
|
29
|
+
const name = String(call?.function?.name || "unknown");
|
|
30
|
+
let input = {};
|
|
31
|
+
try { input = JSON.parse(call?.function?.arguments || "{}"); } catch {}
|
|
32
|
+
chunks.push({ event: "response.tool_use.start", call_id: callId, name, input });
|
|
33
|
+
chunks.push({ event: "response.tool_use.completed", call_id: callId, name, input });
|
|
34
|
+
}
|
|
35
|
+
return chunks;
|
|
36
|
+
}
|
|
37
|
+
if (event.role === "tool") {
|
|
38
|
+
const text = typeof event.content === "string" ? event.content : "";
|
|
39
|
+
return {
|
|
40
|
+
event: "response.tool_result",
|
|
41
|
+
call_id: String(event.tool_call_id || ""),
|
|
42
|
+
output: text ? [{ type: "text", format: "plain", text }] : [],
|
|
43
|
+
is_error: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* kimi 真实 session id 的前缀(`session.resume_hint` 事件回报的形态)。
|
|
51
|
+
*
|
|
52
|
+
* 🔴 2026-08-16 真机故障固化的两条口径(kimi 0.34.0 实测):
|
|
53
|
+
* 1. `--print` 参数已不存在(报 `unknown option '--print' (Did you mean --prompt?)`),
|
|
54
|
+
* `--prompt` 本身就是「非交互跑一轮并打印」;
|
|
55
|
+
* 2. `--session <id>` 是**纯 resume** 语义 —— 传一个不存在的 id 直接报
|
|
56
|
+
* `Session "..." not found`,不再是旧版的 create-or-resume。所以首轮**不带**
|
|
57
|
+
* `--session`(让 CLI 自己建),从输出的 `session.resume_hint` 事件抓真实 id,
|
|
58
|
+
* 续轮才 resume;老 bridge 存过的 randomUUID 一律不信(没有此前缀)。
|
|
59
|
+
*/
|
|
60
|
+
const KIMI_SESSION_ID_PREFIX = "session_";
|
|
61
|
+
|
|
62
|
+
export function buildKimiArgs({ input, sessionId }) {
|
|
63
|
+
const args = ["--prompt", String(input || ""), "--output-format", "stream-json"];
|
|
64
|
+
if (sessionId) args.push("--session", sessionId);
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createKimiInvoke({ fallbackWorkspace, preflightResult, log, runProcess = runNdjsonProcess }) {
|
|
69
|
+
const sessions = new Map();
|
|
70
|
+
return async ({ thread_id, input, workspace_path, resume_session_id, onChunk, onProcess }) => {
|
|
71
|
+
const { cwd } = resolveWorkspaceCwd({ workspacePath: workspace_path, fallbackWorkspace, runtime: "kimi", log });
|
|
72
|
+
// 只信带 kimi 真实前缀的 id(见 KIMI_SESSION_ID_PREFIX 的口径说明)。
|
|
73
|
+
const saved = sessions.get(thread_id) || resume_session_id;
|
|
74
|
+
const sessionId =
|
|
75
|
+
typeof saved === "string" && saved.startsWith(KIMI_SESSION_ID_PREFIX) ? saved : undefined;
|
|
76
|
+
await runProcess({
|
|
77
|
+
binary: preflightResult?.binary || "kimi",
|
|
78
|
+
args: buildKimiArgs({ input, sessionId }),
|
|
79
|
+
cwd,
|
|
80
|
+
parseEvent: (event) => {
|
|
81
|
+
// 首轮 CLI 自建会话后经 resume_hint 回报真实 id —— 记下来供续轮 resume。
|
|
82
|
+
if (
|
|
83
|
+
event?.type === "session.resume_hint" &&
|
|
84
|
+
typeof event.session_id === "string" &&
|
|
85
|
+
event.session_id.startsWith(KIMI_SESSION_ID_PREFIX)
|
|
86
|
+
) {
|
|
87
|
+
sessions.set(thread_id, event.session_id);
|
|
88
|
+
recordBridgedSession("kimi", event.session_id); // 打标:桥接产生的 CLI 会话,导入时跳过
|
|
89
|
+
}
|
|
90
|
+
return translateKimiEvent(event);
|
|
91
|
+
},
|
|
92
|
+
onChunk,
|
|
93
|
+
onProcess,
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runKimiBridge({ options, log }) {
|
|
99
|
+
return runHeadlessCliBridge({ runtime: "kimi", options, log, preflight: kimiPreflight, createInvoke: createKimiInvoke });
|
|
100
|
+
}
|
|
@@ -127,12 +127,6 @@ export async function buildOpenclawDaemonInput(textInput, blocks, {
|
|
|
127
127
|
// text 文件内容 / binary 描述 / document text extraction fallback
|
|
128
128
|
content.push({ type: "input_text", text: result.text });
|
|
129
129
|
break;
|
|
130
|
-
case "url_ref":
|
|
131
|
-
// picoclaw only — openclaw/claude runtimes never return url_ref from relayObjectToBlock;
|
|
132
|
-
// if this branch is somehow hit, drop to a safe description with NO URL in LLM context
|
|
133
|
-
// (铁律 #1: signed URL must not appear in LLM input). (CR-003)
|
|
134
|
-
content.push({ type: "input_text", text: `[image] (preview unavailable for this runtime)` });
|
|
135
|
-
break;
|
|
136
130
|
default:
|
|
137
131
|
break;
|
|
138
132
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function expandUserPath(value) {
|
|
6
|
+
const text = String(value || "").trim();
|
|
7
|
+
if (text === "~") return os.homedir();
|
|
8
|
+
if (text.startsWith("~/")) return path.join(os.homedir(), text.slice(2));
|
|
9
|
+
return text;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function configuredAgent(config, agentId) {
|
|
13
|
+
const entries = Array.isArray(config?.agents?.list) ? config.agents.list.filter(Boolean) : [];
|
|
14
|
+
const normalized = String(agentId || "main").trim().toLowerCase();
|
|
15
|
+
return entries.find((entry) => String(entry?.id || "").trim().toLowerCase() === normalized) || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function defaultAgentId(config) {
|
|
19
|
+
const entries = Array.isArray(config?.agents?.list) ? config.agents.list.filter(Boolean) : [];
|
|
20
|
+
return String(entries.find((entry) => entry?.default)?.id || entries[0]?.id || "main").trim().toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveConfiguredOpenClawWorkspace(config, agentId) {
|
|
24
|
+
const normalizedAgentId = String(agentId || "main").trim().toLowerCase() || "main";
|
|
25
|
+
const configured = configuredAgent(config, normalizedAgentId)?.workspace;
|
|
26
|
+
if (typeof configured === "string" && configured.trim()) {
|
|
27
|
+
return path.resolve(expandUserPath(configured));
|
|
28
|
+
}
|
|
29
|
+
const fallback = config?.agents?.defaults?.workspace;
|
|
30
|
+
if (typeof fallback === "string" && fallback.trim()) {
|
|
31
|
+
const root = path.resolve(expandUserPath(fallback));
|
|
32
|
+
return normalizedAgentId === defaultAgentId(config) ? root : path.join(root, normalizedAgentId);
|
|
33
|
+
}
|
|
34
|
+
if (normalizedAgentId === defaultAgentId(config)) {
|
|
35
|
+
return path.join(os.homedir(), ".openclaw", "workspace");
|
|
36
|
+
}
|
|
37
|
+
return path.join(os.homedir(), ".openclaw", `workspace-${normalizedAgentId}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* OpenClaw /v1/responses has no request-level cwd field (strict request
|
|
42
|
+
* schema). Therefore a workspace run is safe only when the selected agent's
|
|
43
|
+
* configured workspace is exactly the requested directory.
|
|
44
|
+
*/
|
|
45
|
+
export function assertOpenClawWorkspaceContext({ config, agentId, workspacePath }) {
|
|
46
|
+
const requested = String(workspacePath || "").trim();
|
|
47
|
+
if (!requested) return;
|
|
48
|
+
let available = path.isAbsolute(requested);
|
|
49
|
+
if (available) {
|
|
50
|
+
try { available = fs.statSync(requested).isDirectory(); } catch { available = false; }
|
|
51
|
+
}
|
|
52
|
+
const configured = resolveConfiguredOpenClawWorkspace(config, agentId);
|
|
53
|
+
if (!available || path.resolve(requested) !== path.resolve(configured)) {
|
|
54
|
+
const error = new Error("openclaw: requested workspace context is unavailable");
|
|
55
|
+
error.code = "workspace_context_unavailable";
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { runHeadlessCliBridge } from "../_shared/headlessCliBridge.mjs";
|
|
2
|
+
import { recordBridgedSession } from "../_shared/bridgedSessionLedger.mjs";
|
|
3
|
+
import { probeBinary, runNdjsonProcess } from "../_shared/ndjsonProcess.mjs";
|
|
4
|
+
import { resolveWorkspaceCwd } from "../_shared/resolveWorkspaceCwd.mjs";
|
|
5
|
+
|
|
6
|
+
export function opencodePreflight() {
|
|
7
|
+
return probeBinary("opencode");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function translateOpenCodeEvent(event) {
|
|
11
|
+
if (event?.type === "error") {
|
|
12
|
+
const message = event.error?.message || event.message || "OpenCode runtime error";
|
|
13
|
+
throw new Error(String(message));
|
|
14
|
+
}
|
|
15
|
+
if (event?.type === "text" && event.part?.text) {
|
|
16
|
+
return { event: "response.output_text.delta", delta: String(event.part.text) };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createOpenCodeInvoke({ fallbackWorkspace, log, runProcess = runNdjsonProcess }) {
|
|
22
|
+
const sessions = new Map();
|
|
23
|
+
return async ({ thread_id, input, workspace_path, resume_session_id, onChunk, onProcess }) => {
|
|
24
|
+
const { cwd } = resolveWorkspaceCwd({ workspacePath: workspace_path, fallbackWorkspace, runtime: "opencode", log });
|
|
25
|
+
const args = ["run", "--format", "json", "--dir", cwd];
|
|
26
|
+
const model = String(process.env.AGENTLINK_OPENCODE_MODEL || "").trim();
|
|
27
|
+
if (model) args.push("--model", model);
|
|
28
|
+
const prior = sessions.get(thread_id) || resume_session_id;
|
|
29
|
+
if (prior) args.push("--session", prior);
|
|
30
|
+
args.push(String(input || ""));
|
|
31
|
+
await runProcess({
|
|
32
|
+
binary: "opencode",
|
|
33
|
+
args,
|
|
34
|
+
cwd,
|
|
35
|
+
parseEvent: translateOpenCodeEvent,
|
|
36
|
+
onChunk,
|
|
37
|
+
onProcess,
|
|
38
|
+
onSession: (id) => {
|
|
39
|
+
sessions.set(thread_id, id);
|
|
40
|
+
recordBridgedSession("opencode", id); // 打标:桥接产生的 CLI 会话,导入时跳过
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runOpenCodeBridge({ options, log }) {
|
|
47
|
+
return runHeadlessCliBridge({ runtime: "opencode", options, log, preflight: opencodePreflight, createInvoke: createOpenCodeInvoke });
|
|
48
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { createJsonMcpConfigAdapter } from "../_shared/jsonMcpConfigAdapter.mjs";
|
|
5
|
+
|
|
6
|
+
export const fileExtension = "json";
|
|
7
|
+
|
|
8
|
+
const adapter = createJsonMcpConfigAdapter({
|
|
9
|
+
resolvePath: () => process.env.OPENCODE_CONFIG || path.join(os.homedir(), ".config", "opencode", "opencode.json"),
|
|
10
|
+
readServers: (config) => config.mcp || (config.mcp = {}),
|
|
11
|
+
writeServers: (config, servers) => { config.mcp = servers; },
|
|
12
|
+
desiredEntry: (args) => ({ type: "local", command: ["node", ...args], enabled: true }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const { read, register, deregister, status } = adapter;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// opencode 预检:
|
|
2
|
+
// - 二进制存在(PATH 上能 spawn 到 `opencode`)
|
|
3
|
+
// - 版本号 best-effort 解析(只作展示/日志,不参与 installed 判定)
|
|
4
|
+
//
|
|
5
|
+
// 🔴 刻意与 src-ext/runtime/codex/preflight.mjs **同构**(同返回形状、同 5s 超时、
|
|
6
|
+
// 同 ENOENT 专属分支),不为 opencode 另造探测范式:本仓判「runtime 装没装」只有
|
|
7
|
+
// 一套办法 —— 直接问它自己的二进制。
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ 不得拿 `agentlink-agent` 的存在当作「opencode 已安装」信号:agentlink-agent
|
|
10
|
+
// 是 @xyagent/cli 自带的桥进程,永远在位,用它判定会把没装 opencode 的机器误报成
|
|
11
|
+
// 已安装(这个坑 detectInstalledRuntimes 的注释里对 hermes 已经踩过一次)。
|
|
12
|
+
//
|
|
13
|
+
// spawnImpl 可注入(沿用仓内 `consumePairCode({ fetchImpl })` 的注入范式),
|
|
14
|
+
// 让单测无需真装 opencode 就能确定性覆盖四条分支。
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import process from "node:process";
|
|
18
|
+
|
|
19
|
+
const PREFLIGHT_TIMEOUT_MS = 5000;
|
|
20
|
+
|
|
21
|
+
const INSTALL_HINT =
|
|
22
|
+
"未找到 `opencode` 命令。请先安装 opencode CLI(并确保它在当前 shell 的 PATH 里)。";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ log?: object, spawnImpl?: Function }} [opts]
|
|
26
|
+
* @returns {{ ok: boolean, version?: string, error?: string, hint?: string }}
|
|
27
|
+
*/
|
|
28
|
+
export function opencodePreflight({ log, spawnImpl } = {}) {
|
|
29
|
+
const isWin = process.platform === "win32";
|
|
30
|
+
const pfLog = log ? log.child({ comp: "child", stage: "preflight" }) : null;
|
|
31
|
+
const spawn = spawnImpl || spawnSync;
|
|
32
|
+
|
|
33
|
+
let res;
|
|
34
|
+
try {
|
|
35
|
+
res = spawn("opencode", ["--version"], {
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
timeout: PREFLIGHT_TIMEOUT_MS,
|
|
38
|
+
shell: isWin ? true : false,
|
|
39
|
+
});
|
|
40
|
+
} catch (err) {
|
|
41
|
+
pfLog?.error("bridge.preflight.fail", "spawn threw", { err: err?.message || String(err) });
|
|
42
|
+
return { ok: false, error: err?.message || String(err), hint: INSTALL_HINT };
|
|
43
|
+
}
|
|
44
|
+
if (!res) {
|
|
45
|
+
return { ok: false, error: "opencode --version returned no result", hint: INSTALL_HINT };
|
|
46
|
+
}
|
|
47
|
+
if (res.error) {
|
|
48
|
+
const isENOENT = res.error.code === "ENOENT";
|
|
49
|
+
pfLog?.error("bridge.preflight.fail", "spawn errored", {
|
|
50
|
+
code: res.error.code, err: res.error.message,
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
error: res.error.message,
|
|
55
|
+
hint: isENOENT ? INSTALL_HINT : `spawn opencode 失败(${res.error.code || "unknown"})`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (res.status !== 0) {
|
|
59
|
+
const stderr = String(res.stderr ?? "").trim().split(/\r?\n/)[0] || "";
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: `opencode --version exit ${res.status}${stderr ? `: ${stderr}` : ""}`,
|
|
63
|
+
hint: "opencode 启动报错,请先在终端手动跑一次 `opencode --version` 确认可用。",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 版本只取首行做展示;解析不出来**不**影响 ok —— installed 由 exit 0 决定,
|
|
68
|
+
// 与 detectInstalledRuntimes 的两阶段探测保持同一口径。
|
|
69
|
+
const version = String(res.stdout ?? "").trim().split(/\r?\n/)[0] || "";
|
|
70
|
+
pfLog?.info("bridge.preflight.pass", "opencode binary ok", { version });
|
|
71
|
+
return { ok: true, version };
|
|
72
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
|
|
2
|
+
import { runHeadlessCliBridge } from "../_shared/headlessCliBridge.mjs";
|
|
3
|
+
import { recordBridgedSession } from "../_shared/bridgedSessionLedger.mjs";
|
|
4
|
+
import { probeBinary, runNdjsonProcess } from "../_shared/ndjsonProcess.mjs";
|
|
5
|
+
import { resolveWorkspaceCwd } from "../_shared/resolveWorkspaceCwd.mjs";
|
|
6
|
+
import { translateClaudeSchemaEvent } from "../_shared/claudeSchemaEvent.mjs";
|
|
7
|
+
|
|
8
|
+
export function qwenPreflight() {
|
|
9
|
+
const result = probeBinary("qwen");
|
|
10
|
+
if (!result.ok) result.hint = "请先安装 Qwen Code,并执行 qwen 登录完成认证。";
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildQwenArgs({ input, sessionId }) {
|
|
15
|
+
const args = ["--prompt", String(input || ""), "--output-format", "stream-json", "--yolo"];
|
|
16
|
+
if (sessionId) args.push("--resume", sessionId);
|
|
17
|
+
return args;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createQwenInvoke({ fallbackWorkspace, log, runProcess = runNdjsonProcess }) {
|
|
21
|
+
const sessions = new Map();
|
|
22
|
+
return async ({ thread_id, input, workspace_path, resume_session_id, onChunk, onProcess }) => {
|
|
23
|
+
const { cwd } = resolveWorkspaceCwd({ workspacePath: workspace_path, fallbackWorkspace, runtime: "qwen", log });
|
|
24
|
+
await runProcess({
|
|
25
|
+
binary: "qwen",
|
|
26
|
+
args: buildQwenArgs({ input, sessionId: sessions.get(thread_id) || resume_session_id || null }),
|
|
27
|
+
cwd,
|
|
28
|
+
env: { QWEN_CODE_SUPPRESS_YOLO_WARNING: "1" },
|
|
29
|
+
parseEvent: translateClaudeSchemaEvent,
|
|
30
|
+
onChunk,
|
|
31
|
+
onProcess,
|
|
32
|
+
onSession: (id) => {
|
|
33
|
+
sessions.set(thread_id, id);
|
|
34
|
+
recordBridgedSession("qwen", id); // 打标:桥接产生的 CLI 会话,导入时跳过
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runQwenBridge({ options, log }) {
|
|
41
|
+
return runHeadlessCliBridge({ runtime: "qwen", options, log, preflight: qwenPreflight, createInvoke: createQwenInvoke });
|
|
42
|
+
}
|