@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.
Files changed (72) hide show
  1. package/README.md +42 -0
  2. package/bin/agentlink +1002 -245
  3. package/bin/agentlink-agent +0 -0
  4. package/bin/agentlink-mcp-stdio +0 -0
  5. package/package.json +5 -3
  6. package/src/core.mjs +26 -0
  7. package/src/tunnel_service.mjs +17 -1
  8. package/src-ext/bin.mjs +12 -12
  9. package/src-ext/commands/agent.mjs +33 -10
  10. package/src-ext/commands/pair.mjs +122 -23
  11. package/src-ext/commands/service.mjs +1 -1
  12. package/src-ext/core/activeRuns.mjs +26 -9
  13. package/src-ext/core/agentlinkToolsBootstrap.mjs +1 -1
  14. package/src-ext/core/autoTunnelDetector.mjs +6 -1
  15. package/src-ext/core/autoTunnelWiring.mjs +1 -1
  16. package/src-ext/core/bridgeSelfUninstall.mjs +1 -1
  17. package/src-ext/core/defaultWorkspace.mjs +43 -13
  18. package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
  19. package/src-ext/core/installationIdentity.mjs +94 -0
  20. package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
  21. package/src-ext/core/pairCodeClient.mjs +48 -8
  22. package/src-ext/core/pairInventory.mjs +31 -6
  23. package/src-ext/core/relayWorker.mjs +22 -2
  24. package/src-ext/core/runtimeRegistry.mjs +180 -0
  25. package/src-ext/core/scanDeeplink.mjs +67 -0
  26. package/src-ext/core/scanPairFlow.mjs +28 -0
  27. package/src-ext/core/scanQrRenderer.mjs +40 -0
  28. package/src-ext/core/scanWorkspaces.mjs +163 -23
  29. package/src-ext/core/unifiedDispatchHandler.mjs +12 -5
  30. package/src-ext/core/usageReporter.mjs +1 -2
  31. package/src-ext/openclaw-plugin/envelope-builder.cjs +2 -2
  32. package/src-ext/openclaw-plugin/todoTranslatorUtils.cjs +1 -1
  33. package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
  34. package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
  35. package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
  36. package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
  37. package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
  38. package/src-ext/runtime/_shared/relayObjectToBlock.mjs +9 -11
  39. package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
  40. package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
  41. package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +1 -1
  42. package/src-ext/runtime/claude/handleRequest.mjs +15 -37
  43. package/src-ext/runtime/claude/launcher.mjs +0 -6
  44. package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
  45. package/src-ext/runtime/codebuddy/index.mjs +41 -0
  46. package/src-ext/runtime/codex/handleRequest.mjs +13 -35
  47. package/src-ext/runtime/cursor/index.mjs +46 -0
  48. package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
  49. package/src-ext/runtime/deepagents/preflight.mjs +57 -0
  50. package/src-ext/runtime/hermes/envSetup.mjs +22 -8
  51. package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
  52. package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
  53. package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
  54. package/src-ext/runtime/hermes/index.mjs +1 -1
  55. package/src-ext/runtime/hermes/preflight.mjs +2 -1
  56. package/src-ext/runtime/kimi/index.mjs +100 -0
  57. package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +0 -6
  58. package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
  59. package/src-ext/runtime/opencode/index.mjs +48 -0
  60. package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
  61. package/src-ext/runtime/opencode/preflight.mjs +72 -0
  62. package/src-ext/runtime/qwen/index.mjs +42 -0
  63. package/src-ext/service/serviceManager.mjs +120 -42
  64. package/src-shared/envelope_builder.mjs +2 -2
  65. package/src-ext/runtime/picoclaw/constants.mjs +0 -39
  66. package/src-ext/runtime/picoclaw/handleRequest.mjs +0 -289
  67. package/src-ext/runtime/picoclaw/index.mjs +0 -314
  68. package/src-ext/runtime/picoclaw/pairFlow.mjs +0 -224
  69. package/src-ext/runtime/picoclaw/state.mjs +0 -78
  70. package/src-ext/runtime/picoclaw/todoTranslator.mjs +0 -67
  71. package/src-ext/runtime/picoclaw/translator.mjs +0 -272
  72. 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 = parsed?.choices?.[0];
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 || []).includes("API_SERVER_ENABLED"));
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
+ }