@xyagent/cli 1.0.0 → 1.1.1
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 +468 -86
- package/package.json +3 -3
- package/src/tunnel_service.mjs +17 -1
- package/src-ext/bin.mjs +12 -11
- package/src-ext/commands/agent.mjs +34 -0
- 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/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 +21 -1
- package/src-ext/core/runtimeRegistry.mjs +180 -0
- package/src-ext/core/scanWorkspaces.mjs +163 -23
- package/src-ext/core/unifiedDispatchHandler.mjs +9 -2
- package/src-ext/postinstall.mjs +14 -0
- 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/resolveWorkspaceCwd.mjs +47 -0
- package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
- package/src-ext/runtime/claude/handleRequest.mjs +15 -37
- 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/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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const SERVER_NAME = "agentlink";
|
|
5
|
+
|
|
6
|
+
export function createJsonMcpConfigAdapter({ resolvePath, readServers, writeServers, desiredEntry }) {
|
|
7
|
+
async function read() {
|
|
8
|
+
return await fs.readFile(resolvePath(), "utf8");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function register({ args, log } = {}) {
|
|
12
|
+
const configPath = resolvePath();
|
|
13
|
+
const config = await readConfig(configPath);
|
|
14
|
+
const servers = readServers(config);
|
|
15
|
+
const desired = desiredEntry(args || []);
|
|
16
|
+
if (JSON.stringify(servers[SERVER_NAME]) === JSON.stringify(desired)) {
|
|
17
|
+
return { changed: false, configPath, mode: "noop_idempotent" };
|
|
18
|
+
}
|
|
19
|
+
servers[SERVER_NAME] = desired;
|
|
20
|
+
writeServers(config, servers);
|
|
21
|
+
await writeConfig(configPath, config);
|
|
22
|
+
log?.info?.("mcp.fanout.json.write", "wrote agentlink MCP entry", { configPath });
|
|
23
|
+
return { changed: true, configPath };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function deregister() {
|
|
27
|
+
const configPath = resolvePath();
|
|
28
|
+
let config;
|
|
29
|
+
try {
|
|
30
|
+
config = await readConfig(configPath, { requireExisting: true });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error?.code === "ENOENT") return { changed: false, configPath, mode: "noop_no_config" };
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
const servers = readServers(config);
|
|
36
|
+
if (!(SERVER_NAME in servers)) return { changed: false, configPath, mode: "noop_not_registered" };
|
|
37
|
+
delete servers[SERVER_NAME];
|
|
38
|
+
writeServers(config, servers);
|
|
39
|
+
await writeConfig(configPath, config);
|
|
40
|
+
return { changed: true, configPath };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function status() {
|
|
44
|
+
const configPath = resolvePath();
|
|
45
|
+
try {
|
|
46
|
+
const config = await readConfig(configPath, { requireExisting: true });
|
|
47
|
+
return { installed: true, registered: SERVER_NAME in readServers(config), configPath };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error?.code === "ENOENT") return { installed: true, registered: false, configPath };
|
|
50
|
+
return { installed: true, registered: false, configPath, error: error?.message };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { read, register, deregister, status };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readConfig(configPath, { requireExisting = false } = {}) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
60
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code === "ENOENT" && !requireExisting) return {};
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function writeConfig(configPath, config) {
|
|
68
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
69
|
+
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
70
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
|
|
4
|
+
const VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
5
|
+
const MAX_STDERR_CHARS = 8_000;
|
|
6
|
+
export const PROCESS_TERMINATION_GRACE_MS = 3_000;
|
|
7
|
+
// 子进程连续无任何 stdout/stderr 输出超过该上限视为卡死:kill 进程树并抛错,
|
|
8
|
+
// 由 dispatch 层上报 run.failed —— 否则 run 永久 running,客户端只能无限转圈。
|
|
9
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
10
|
+
|
|
11
|
+
function signalProcessTree(child, signal) {
|
|
12
|
+
if (!child?.pid) return;
|
|
13
|
+
if (process.platform === "win32") {
|
|
14
|
+
const args = ["/pid", String(child.pid), "/t"];
|
|
15
|
+
if (signal === "SIGKILL") args.push("/f");
|
|
16
|
+
spawnSync("taskkill", args, { windowsHide: true, stdio: "ignore" });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
process.kill(-child.pid, signal);
|
|
21
|
+
} catch {
|
|
22
|
+
try { child.kill(signal); } catch {}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function probeBinary(binary) {
|
|
27
|
+
const result = spawnSync(binary, ["--version"], {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
timeout: VERSION_PROBE_TIMEOUT_MS,
|
|
30
|
+
windowsHide: true,
|
|
31
|
+
});
|
|
32
|
+
if (result.error || result.status !== 0) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: result.error?.code === "ENOENT" ? `找不到 ${binary}` : String(result.stderr || result.error || "version probe failed").trim(),
|
|
36
|
+
hint: `请先安装 ${binary},并确认它在 PATH 中。`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, version: String(result.stdout || result.stderr || "").trim().split(/\r?\n/)[0] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runNdjsonProcess({
|
|
43
|
+
binary,
|
|
44
|
+
args,
|
|
45
|
+
cwd,
|
|
46
|
+
env,
|
|
47
|
+
parseEvent,
|
|
48
|
+
onChunk,
|
|
49
|
+
onSession,
|
|
50
|
+
onProcess,
|
|
51
|
+
terminationGraceMs = PROCESS_TERMINATION_GRACE_MS,
|
|
52
|
+
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
53
|
+
}) {
|
|
54
|
+
const childEnv = env ? { ...process.env, ...env } : process.env;
|
|
55
|
+
const child = spawn(binary, args, {
|
|
56
|
+
cwd,
|
|
57
|
+
env: childEnv,
|
|
58
|
+
shell: false,
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
detached: process.platform !== "win32",
|
|
61
|
+
// stdin 必须是 EOF:留 pipe 不关闭会让子进程(如 opencode run)等 stdin
|
|
62
|
+
// 读到 EOF 才开始干活 → 永久卡死(手动跑是 TTY 不受影响,headless 必挂)。
|
|
63
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
64
|
+
});
|
|
65
|
+
let exited = false;
|
|
66
|
+
let aborted = false;
|
|
67
|
+
let timedOut = false;
|
|
68
|
+
let forceTimer = null;
|
|
69
|
+
const killTree = () => {
|
|
70
|
+
signalProcessTree(child, "SIGTERM");
|
|
71
|
+
forceTimer = setTimeout(() => {
|
|
72
|
+
if (!exited) signalProcessTree(child, "SIGKILL");
|
|
73
|
+
}, terminationGraceMs);
|
|
74
|
+
forceTimer.unref?.();
|
|
75
|
+
};
|
|
76
|
+
const terminate = () => {
|
|
77
|
+
if (exited || aborted) return;
|
|
78
|
+
aborted = true;
|
|
79
|
+
killTree();
|
|
80
|
+
};
|
|
81
|
+
// 空闲看门狗:任何 stdout/stderr 输出都会重置;超时视为子进程卡死。
|
|
82
|
+
let idleTimer = null;
|
|
83
|
+
const armIdleTimer = () => {
|
|
84
|
+
if (!idleTimeoutMs) return;
|
|
85
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
86
|
+
idleTimer = setTimeout(() => {
|
|
87
|
+
if (exited || aborted || timedOut) return;
|
|
88
|
+
timedOut = true;
|
|
89
|
+
killTree();
|
|
90
|
+
}, idleTimeoutMs);
|
|
91
|
+
idleTimer.unref?.();
|
|
92
|
+
};
|
|
93
|
+
armIdleTimer();
|
|
94
|
+
onProcess?.({ pid: child.pid, terminate });
|
|
95
|
+
const exitPromise = new Promise((resolve, reject) => {
|
|
96
|
+
child.once("error", reject);
|
|
97
|
+
child.once("close", (code, signal) => {
|
|
98
|
+
exited = true;
|
|
99
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
100
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
101
|
+
resolve({ code, signal });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
let stderr = "";
|
|
105
|
+
let structuredError = "";
|
|
106
|
+
let reportedFailure = false;
|
|
107
|
+
child.stdout.on("data", armIdleTimer);
|
|
108
|
+
child.stderr.setEncoding("utf8");
|
|
109
|
+
child.stderr.on("data", (chunk) => {
|
|
110
|
+
armIdleTimer();
|
|
111
|
+
stderr = (stderr + chunk).slice(-MAX_STDERR_CHARS);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
115
|
+
for await (const line of lines) {
|
|
116
|
+
if (!line.trim()) continue;
|
|
117
|
+
let event;
|
|
118
|
+
try { event = JSON.parse(line); }
|
|
119
|
+
catch { continue; }
|
|
120
|
+
const eventError = event?.error?.message || event?.message?.error?.message;
|
|
121
|
+
if (eventError) structuredError = String(eventError);
|
|
122
|
+
const sessionId = event.sessionID || event.session_id;
|
|
123
|
+
if (sessionId) onSession?.(String(sessionId));
|
|
124
|
+
const chunks = parseEvent(event);
|
|
125
|
+
for (const chunk of Array.isArray(chunks) ? chunks : (chunks ? [chunks] : [])) {
|
|
126
|
+
if (chunk?.event === "response.failed") reportedFailure = true;
|
|
127
|
+
await onChunk(chunk);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const exit = await exitPromise;
|
|
132
|
+
if (aborted) return { aborted: true, exit };
|
|
133
|
+
if (timedOut) {
|
|
134
|
+
throw new Error(`${binary} 连续 ${Math.round(idleTimeoutMs / 1000)}s 无输出,判定卡死,已超时终止`);
|
|
135
|
+
}
|
|
136
|
+
if (exit.code !== 0) {
|
|
137
|
+
if (reportedFailure) return;
|
|
138
|
+
throw new Error(structuredError || stderr.trim() || `${binary} exited with code ${exit.code ?? "unknown"}${exit.signal ? ` (${exit.signal})` : ""}`);
|
|
139
|
+
}
|
|
140
|
+
return { aborted: false, exit };
|
|
141
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolveWorkspaceCwd.mjs — headless NDJSON runtime 的 spawn cwd 解析。
|
|
3
|
+
*
|
|
4
|
+
* 与 claude/codex handleRequest 的解析链对齐:
|
|
5
|
+
* 1. workspace_path 非空 → expandHome + existsSync → 用它(线程绑定工作区)
|
|
6
|
+
* 2. workspace_path 给定但无效/目录不存在 → 稳定失败,绝不 fallback
|
|
7
|
+
* 3. workspace_path 未给 → 默认工作区
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { statSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { expandHome } from "../../core/pathExpand.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{
|
|
17
|
+
* workspacePath?: string | null,
|
|
18
|
+
* fallbackWorkspace: string,
|
|
19
|
+
* runtime: string,
|
|
20
|
+
* log?: { info?: Function, warn?: Function },
|
|
21
|
+
* }} opts
|
|
22
|
+
* @returns {{ cwd: string, source: "workspace_path" | "default" }}
|
|
23
|
+
*/
|
|
24
|
+
export function resolveWorkspaceCwd({ workspacePath, fallbackWorkspace, runtime, log }) {
|
|
25
|
+
const requested = typeof workspacePath === "string" ? workspacePath.trim() : "";
|
|
26
|
+
if (requested) {
|
|
27
|
+
const resolved = expandHome(requested);
|
|
28
|
+
const requestedAbsolute = path.isAbsolute(requested) || requested === "~" || requested.startsWith("~/");
|
|
29
|
+
if (requestedAbsolute && path.isAbsolute(resolved)) {
|
|
30
|
+
let isDirectory = false;
|
|
31
|
+
try { isDirectory = statSync(resolved).isDirectory(); } catch {}
|
|
32
|
+
if (isDirectory) {
|
|
33
|
+
log?.info?.(`${runtime}.spawn.cwd.workspace`, "using workspace_path as cwd", { cwd: resolved });
|
|
34
|
+
return { cwd: resolved, source: "workspace_path" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
log?.warn?.(`${runtime}.spawn.cwd.unavailable`, "requested workspace context is unavailable", {
|
|
38
|
+
requested: true,
|
|
39
|
+
});
|
|
40
|
+
const error = new Error(`${runtime}: requested workspace context is unavailable`);
|
|
41
|
+
error.code = "workspace_context_unavailable";
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
const fallback = path.resolve(fallbackWorkspace);
|
|
45
|
+
log?.info?.(`${runtime}.spawn.cwd.default`, "no workspace_path, using default workspace", { cwd: fallback });
|
|
46
|
+
return { cwd: fallback, source: "default" };
|
|
47
|
+
}
|
|
@@ -115,6 +115,16 @@ export async function classifySlashCommand(input, runtime, bridgeCtx) {
|
|
|
115
115
|
// 命中(用户自定义 ~/.claude/commands / skill 命令)→ 透传真跑;
|
|
116
116
|
// 未命中 → __default__ 合成提示,避免英文 "Unknown command" 直出。
|
|
117
117
|
const bare = command.slice(1);
|
|
118
|
+
// system/init 在部分 Claude/Node 组合下会复用 CLI 进程级缓存,刚创建的
|
|
119
|
+
// workspace command 即使 forceFresh 也可能暂时缺席。磁盘 marker 是这里的
|
|
120
|
+
// 权威兜底,且只检查由 SLASH_RE 限定过的安全文件名。
|
|
121
|
+
const workspaceCommand = ctx?.cwd
|
|
122
|
+
? join(ctx.cwd, ".claude", "commands", `${bare}.md`)
|
|
123
|
+
: null;
|
|
124
|
+
const userCommand = join(homedir(), ".claude", "commands", `${bare}.md`);
|
|
125
|
+
if ((workspaceCommand && existsSync(workspaceCommand)) || existsSync(userCommand)) {
|
|
126
|
+
return { kind: "passthrough", command };
|
|
127
|
+
}
|
|
118
128
|
const hitKnown = (init) => {
|
|
119
129
|
const known = Array.isArray(init?.slash_commands) ? init.slash_commands : [];
|
|
120
130
|
return known.some((c) => String(c).toLowerCase() === bare);
|
|
@@ -24,8 +24,8 @@ import { probeClaudeSystemInit } from "../_shared/cliExec.mjs";
|
|
|
24
24
|
import { normalizeClaudeLine, createLineStreamParser } from "./stdoutParser.mjs";
|
|
25
25
|
import { createUnifiedDispatchHandler } from "../../core/unifiedDispatchHandler.mjs";
|
|
26
26
|
import { scanWorkspacesByRuntime } from "../../core/scanWorkspaces.mjs";
|
|
27
|
-
import { expandHome } from "../../core/pathExpand.mjs";
|
|
28
27
|
import { ensureDefaultWorkspace, defaultWorkspacePath } from "../../core/defaultWorkspace.mjs";
|
|
28
|
+
import { resolveWorkspaceCwd } from "../_shared/resolveWorkspaceCwd.mjs";
|
|
29
29
|
import { createWorkspace as _defaultCreateWorkspace } from "../../core/createWorkspace.mjs";
|
|
30
30
|
import { createClaudeTodoTranslator } from "./todoTranslator.mjs";
|
|
31
31
|
import { createAskUserQuestionTranslator, aggregateAnswers, buildOptionMaps } from "./askUserQuestionTranslator.mjs";
|
|
@@ -257,38 +257,22 @@ export function createClaudeHandleRequest({ cwd, log, usage, bridgeCtx, dispatch
|
|
|
257
257
|
|
|
258
258
|
// 默认 openclawInvoke:spawn claude --print 子进程,解析 stdout,翻译事件
|
|
259
259
|
// Task 3.5: 加入 blocks 参数(来自 dispatch payload),透传给 writeUserMessage 以支持多模态
|
|
260
|
-
// workspace-aware cwd
|
|
261
|
-
//
|
|
262
|
-
const defaultOpenclawInvoke = async ({ thread_id, run_id, input, blocks = [], workspace_path: reqWorkspacePath, session_key = null, onChunk: _rawOnChunk }) => {
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// 2. workspace_path 给定但不存在 → fallback 到 defaultWorkspace,emit cwd_fallback 可观测
|
|
266
|
-
// 3. workspace_path 未给 → 直接用 defaultWorkspace
|
|
267
|
-
// 注:factory cwd 不再作为兜底(保留作显式 --cwd 入口)
|
|
260
|
+
// workspace-aware cwd:显式 workspace_path 必须是可用绝对目录;缺失时才使用
|
|
261
|
+
// defaultWorkspace(不再用工厂 cwd,修 service 模式 "/" bug)。
|
|
262
|
+
const defaultOpenclawInvoke = async ({ thread_id, run_id, input, blocks = [], workspace_path: reqWorkspacePath, resume_session_id: resumeSessionIDFromRelay = null, session_key = null, onChunk: _rawOnChunk }) => {
|
|
263
|
+
// v2 workspace_path 是强约束:不可用时稳定失败,不得退回默认目录。
|
|
264
|
+
// route shell 未带 workspace_path 时继续使用原默认工作区。
|
|
268
265
|
let spawnCwd;
|
|
269
|
-
let cwdFallback = null;
|
|
270
266
|
|
|
271
267
|
const { existsSync } = await import("node:fs");
|
|
272
268
|
|
|
273
269
|
if (reqWorkspacePath && typeof reqWorkspacePath === "string" && reqWorkspacePath.trim()) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
} else {
|
|
281
|
-
// workspace_path 给定但不存在 → fallback 到默认工作区,fallback 可观测
|
|
282
|
-
spawnCwd = await ensureDefaultWorkspace("claude");
|
|
283
|
-
cwdFallback = {
|
|
284
|
-
actual_cwd: spawnCwd,
|
|
285
|
-
requested_cwd: reqWorkspacePath,
|
|
286
|
-
reason: `workspace_path '${reqWorkspacePath}' does not exist after expand, falling back to default workspace`,
|
|
287
|
-
};
|
|
288
|
-
log.warn?.("claude.spawn.cwd.not_found", "workspace_path does not exist, falling back to default workspace", {
|
|
289
|
-
workspace_path: reqWorkspacePath, fallback_cwd: spawnCwd,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
270
|
+
({ cwd: spawnCwd } = resolveWorkspaceCwd({
|
|
271
|
+
workspacePath: reqWorkspacePath,
|
|
272
|
+
fallbackWorkspace: cwd,
|
|
273
|
+
runtime: "claude",
|
|
274
|
+
log,
|
|
275
|
+
}));
|
|
292
276
|
} else {
|
|
293
277
|
// 未给 workspace_path → 用默认工作区(lazy ensure,兜底 service 模式 "/" bug)
|
|
294
278
|
spawnCwd = await ensureDefaultWorkspace("claude");
|
|
@@ -320,14 +304,6 @@ export function createClaudeHandleRequest({ cwd, log, usage, bridgeCtx, dispatch
|
|
|
320
304
|
}
|
|
321
305
|
: _rawOnChunk;
|
|
322
306
|
|
|
323
|
-
// fallback 可观测:workspace_path 给定但不存在时,emit run.started 含 cwd_fallback
|
|
324
|
-
if (cwdFallback) {
|
|
325
|
-
await onChunk({
|
|
326
|
-
event: "__envelopes",
|
|
327
|
-
envelopes: [buildRunStarted(run_id, { options: { cwd_fallback: cwdFallback } })],
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
|
|
331
307
|
// approval 模式资源(在 try 块外声明,以便 finally 块访问)
|
|
332
308
|
let _approvalPermServer = null;
|
|
333
309
|
let _approvalMcpConfigPath = null;
|
|
@@ -977,6 +953,7 @@ export function createClaudeHandleRequest({ cwd, log, usage, bridgeCtx, dispatch
|
|
|
977
953
|
});
|
|
978
954
|
resumeHandle.child.once("error", () => resolve(-1));
|
|
979
955
|
});
|
|
956
|
+
await resumeParser.drain();
|
|
980
957
|
|
|
981
958
|
// 新 session_id 写入 threadSessionMap
|
|
982
959
|
if (resumeCode === 0 && resumeSawNonSynthetic && resumePendingSessionId && thread_id) {
|
|
@@ -1036,6 +1013,7 @@ export function createClaudeHandleRequest({ cwd, log, usage, bridgeCtx, dispatch
|
|
|
1036
1013
|
});
|
|
1037
1014
|
handle.child.once("error", () => resolve(-1));
|
|
1038
1015
|
});
|
|
1016
|
+
await parser.drain();
|
|
1039
1017
|
childExited = true;
|
|
1040
1018
|
unregister();
|
|
1041
1019
|
|
|
@@ -1048,7 +1026,7 @@ export function createClaudeHandleRequest({ cwd, log, usage, bridgeCtx, dispatch
|
|
|
1048
1026
|
}
|
|
1049
1027
|
|
|
1050
1028
|
// 第一次尝试:用现有 session_id(如果有)
|
|
1051
|
-
let resumeSessionId = thread_id ? threadSessionMap.get(thread_id) :
|
|
1029
|
+
let resumeSessionId = thread_id ? threadSessionMap.get(thread_id) || resumeSessionIDFromRelay : resumeSessionIDFromRelay;
|
|
1052
1030
|
let result = await attemptOnce({ resumeSessionId, isRetry: false });
|
|
1053
1031
|
|
|
1054
1032
|
// 兜底 1:spawn 失败直接报。
|
|
@@ -258,6 +258,10 @@ export function normalizeClaudeLine(line) {
|
|
|
258
258
|
export function createLineStreamParser({ onObject, onInvalid, log, bufferLimit = 1 << 20 }) {
|
|
259
259
|
const decoder = new TextDecoder("utf-8");
|
|
260
260
|
let buf = "";
|
|
261
|
+
// stdout 的 data 回调是同步的,但 onObject 会异步发布 relay envelope。
|
|
262
|
+
// 必须串行排队并允许调用方在 child exit 后 drain,否则 run.completed 可能
|
|
263
|
+
// 越过仍在途的 delta,relay 按状态机拒绝后 App 就永久停在 streaming。
|
|
264
|
+
let pending = Promise.resolve();
|
|
261
265
|
const pLog = log ? log.child({ comp: "stdout" }) : null;
|
|
262
266
|
|
|
263
267
|
function feedLine(line) {
|
|
@@ -281,13 +285,12 @@ export function createLineStreamParser({ onObject, onInvalid, log, bufferLimit =
|
|
|
281
285
|
pLog?.debug("stdout.line.parsed", "line parsed", {
|
|
282
286
|
type: obj.type || "", has_message: !!obj.message,
|
|
283
287
|
});
|
|
284
|
-
|
|
285
|
-
catch (err) {
|
|
288
|
+
pending = pending.then(() => onObject(obj)).catch((err) => {
|
|
286
289
|
pLog?.error("error.caught", "onObject threw", {
|
|
287
290
|
where: "stdoutParser.onObject",
|
|
288
291
|
err: err?.message || String(err),
|
|
289
292
|
});
|
|
290
|
-
}
|
|
293
|
+
});
|
|
291
294
|
}
|
|
292
295
|
|
|
293
296
|
return {
|
|
@@ -316,6 +319,8 @@ export function createLineStreamParser({ onObject, onInvalid, log, bufferLimit =
|
|
|
316
319
|
buf = "";
|
|
317
320
|
}
|
|
318
321
|
},
|
|
322
|
+
/** 等待已解析行的异步 onObject 全部按输入顺序处理完。 */
|
|
323
|
+
drain() { return pending; },
|
|
319
324
|
};
|
|
320
325
|
}
|
|
321
326
|
|
|
@@ -0,0 +1,41 @@
|
|
|
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 codeBuddyPreflight() {
|
|
9
|
+
const result = probeBinary("codebuddy");
|
|
10
|
+
if (!result.ok) result.hint = "请先安装 CodeBuddy Code CLI,并完成账号登录。";
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildCodeBuddyArgs({ input, sessionId }) {
|
|
15
|
+
const args = ["--print", String(input || ""), "--output-format", "stream-json", "--dangerously-skip-permissions"];
|
|
16
|
+
if (sessionId) args.push("--resume", sessionId);
|
|
17
|
+
return args;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createCodeBuddyInvoke({ 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: "codebuddy", log });
|
|
24
|
+
await runProcess({
|
|
25
|
+
binary: "codebuddy",
|
|
26
|
+
args: buildCodeBuddyArgs({ input, sessionId: sessions.get(thread_id) || resume_session_id || null }),
|
|
27
|
+
cwd,
|
|
28
|
+
parseEvent: translateClaudeSchemaEvent,
|
|
29
|
+
onChunk,
|
|
30
|
+
onProcess,
|
|
31
|
+
onSession: (id) => {
|
|
32
|
+
sessions.set(thread_id, id);
|
|
33
|
+
recordBridgedSession("codebuddy", id); // 打标:桥接产生的 CLI 会话,导入时跳过
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runCodeBuddyBridge({ options, log }) {
|
|
40
|
+
return runHeadlessCliBridge({ runtime: "codebuddy", options, log, preflight: codeBuddyPreflight, createInvoke: createCodeBuddyInvoke });
|
|
41
|
+
}
|
|
@@ -24,9 +24,9 @@ import { scanWorkspacesByRuntime } from "../../core/scanWorkspaces.mjs";
|
|
|
24
24
|
import { createMcpStdioClient } from "../../core/mcpStdioClient.mjs";
|
|
25
25
|
import { createActiveRunRegistry } from "../../core/activeRuns.mjs";
|
|
26
26
|
import { BoundedMap } from "../../core/boundedMap.mjs";
|
|
27
|
-
import { expandHome } from "../../core/pathExpand.mjs";
|
|
28
27
|
import { createWorkspace as _defaultCreateWorkspace } from "../../core/createWorkspace.mjs";
|
|
29
28
|
import { ensureDefaultWorkspace } from "../../core/defaultWorkspace.mjs";
|
|
29
|
+
import { resolveWorkspaceCwd } from "../_shared/resolveWorkspaceCwd.mjs";
|
|
30
30
|
import { mapCodexMsg } from "./eventMapper.mjs";
|
|
31
31
|
import { createCodexPlanHandler } from "./planNotificationHandler.mjs";
|
|
32
32
|
import {
|
|
@@ -227,34 +227,18 @@ export function createCodexHandleRequest({ pre, cwd, log, usage, shuttingDownRef
|
|
|
227
227
|
// 默认 openclawInvoke:起 MCP stdio 子进程,监听 codex/event notification
|
|
228
228
|
// Task 5.1: 加入 blocks 参数(默认 []),支持 image + PDF attachment inline URL
|
|
229
229
|
// align-runtime-default-workspace: 加入 workspace_path 参数,解析链同 claude
|
|
230
|
-
const defaultOpenclawInvoke = async ({ thread_id, run_id, input, blocks = [], workspace_path: reqWorkspacePath, onChunk: _rawOnChunk }) => {
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// 2. workspace_path 给定但不存在 → fallback 到 defaultWorkspace,emit cwd_fallback
|
|
234
|
-
// 3. workspace_path 未给 → 直接用 defaultWorkspace(修 service 模式 "/" bug)
|
|
230
|
+
const defaultOpenclawInvoke = async ({ thread_id, run_id, input, blocks = [], workspace_path: reqWorkspacePath, resume_session_id: resumeSessionIDFromRelay = null, onChunk: _rawOnChunk }) => {
|
|
231
|
+
// v2 workspace_path 是强约束:不可用时稳定失败,不得退回默认目录。
|
|
232
|
+
// route shell 未带 workspace_path 时继续使用原默认工作区。
|
|
235
233
|
let spawnCwd;
|
|
236
|
-
let cwdFallback = null;
|
|
237
|
-
|
|
238
|
-
const { existsSync: fsExistsSync } = await import("node:fs");
|
|
239
234
|
|
|
240
235
|
if (reqWorkspacePath && typeof reqWorkspacePath === "string" && reqWorkspacePath.trim()) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
} else {
|
|
248
|
-
spawnCwd = await ensureDefaultWorkspace("codex");
|
|
249
|
-
cwdFallback = {
|
|
250
|
-
actual_cwd: spawnCwd,
|
|
251
|
-
requested_cwd: reqWorkspacePath,
|
|
252
|
-
reason: `workspace_path '${reqWorkspacePath}' does not exist after expand, falling back to default workspace`,
|
|
253
|
-
};
|
|
254
|
-
log.warn?.("codex.spawn.cwd.not_found", "workspace_path does not exist, falling back to default workspace", {
|
|
255
|
-
workspace_path: reqWorkspacePath, fallback_cwd: spawnCwd,
|
|
256
|
-
});
|
|
257
|
-
}
|
|
236
|
+
({ cwd: spawnCwd } = resolveWorkspaceCwd({
|
|
237
|
+
workspacePath: reqWorkspacePath,
|
|
238
|
+
fallbackWorkspace: cwd,
|
|
239
|
+
runtime: "codex",
|
|
240
|
+
log,
|
|
241
|
+
}));
|
|
258
242
|
} else {
|
|
259
243
|
spawnCwd = await ensureDefaultWorkspace("codex");
|
|
260
244
|
log.info?.("codex.spawn.cwd.default", "no workspace_path, using default workspace", {
|
|
@@ -275,14 +259,6 @@ export function createCodexHandleRequest({ pre, cwd, log, usage, shuttingDownRef
|
|
|
275
259
|
}
|
|
276
260
|
: _rawOnChunk;
|
|
277
261
|
|
|
278
|
-
// fallback 可观测:workspace_path 给定但不存在时,emit run.started 含 cwd_fallback
|
|
279
|
-
if (cwdFallback) {
|
|
280
|
-
await onChunk({
|
|
281
|
-
event: "__envelopes",
|
|
282
|
-
envelopes: [buildRunStarted(run_id, { options: { cwd_fallback: cwdFallback } })],
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
|
|
286
262
|
// audit 修复:unsubEvent/mcp 提升声明——turn 中任何异常此前会跳过
|
|
287
263
|
// try 尾部的清理(listener 累积 → 下一轮事件翻倍;__currentRun 悬空 →
|
|
288
264
|
// elicitation 发往旧 run)。清理统一挪进 finally。
|
|
@@ -404,7 +380,9 @@ export function createCodexHandleRequest({ pre, cwd, log, usage, shuttingDownRef
|
|
|
404
380
|
// prompt 引导 codex 用 view_image 工具查看本地路径。下载失败回退 URL 行为。
|
|
405
381
|
const localizedBlocks = await localizeImageBlocksForCodex(blocks, { log });
|
|
406
382
|
const promptWithAttachments = buildCodexPromptWithAttachments(input, localizedBlocks);
|
|
407
|
-
|
|
383
|
+
// An imported workspace can seed the first turn from relay. Once this
|
|
384
|
+
// bridge has captured a local session, that active mapping always wins.
|
|
385
|
+
let priorCodexThreadId = thread_id ? threadCodexIdMap.get(thread_id) || resumeSessionIDFromRelay : resumeSessionIDFromRelay;
|
|
408
386
|
mcp = thread_id ? threadMcpMap.get(thread_id) : null;
|
|
409
387
|
let mcpIsNew = false;
|
|
410
388
|
if (!mcp) {
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
|
|
7
|
+
export function cursorPreflight() {
|
|
8
|
+
const result = probeBinary("cursor-agent");
|
|
9
|
+
if (!result.ok) result.hint = "请安装 Cursor Agent CLI,并执行 cursor-agent login 完成登录。";
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function translateCursorEvent(event) {
|
|
14
|
+
if (event?.type !== "assistant") return null;
|
|
15
|
+
const content = Array.isArray(event.message?.content) ? event.message.content : [];
|
|
16
|
+
return content
|
|
17
|
+
.filter((item) => item?.type === "text" && item.text)
|
|
18
|
+
.map((item) => ({ event: "response.output_text.delta", delta: String(item.text) }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createCursorInvoke({ 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: "cursor", log });
|
|
25
|
+
const args = ["-p", "--output-format", "stream-json", "--stream-partial-output", "--trust", "--workspace", cwd];
|
|
26
|
+
const prior = sessions.get(thread_id) || resume_session_id;
|
|
27
|
+
if (prior) args.push("--resume", prior);
|
|
28
|
+
args.push(String(input || ""));
|
|
29
|
+
await runProcess({
|
|
30
|
+
binary: "cursor-agent",
|
|
31
|
+
args,
|
|
32
|
+
cwd,
|
|
33
|
+
parseEvent: translateCursorEvent,
|
|
34
|
+
onChunk,
|
|
35
|
+
onProcess,
|
|
36
|
+
onSession: (id) => {
|
|
37
|
+
sessions.set(thread_id, id);
|
|
38
|
+
recordBridgedSession("cursor", id); // 打标:桥接产生的 CLI 会话,导入时跳过
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runCursorBridge({ options, log }) {
|
|
45
|
+
return runHeadlessCliBridge({ runtime: "cursor", options, log, preflight: cursorPreflight, createInvoke: createCursorInvoke });
|
|
46
|
+
}
|
|
@@ -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: () => path.join(os.homedir(), ".cursor", "mcp.json"),
|
|
10
|
+
readServers: (config) => config.mcpServers || (config.mcpServers = {}),
|
|
11
|
+
writeServers: (config, servers) => { config.mcpServers = servers; },
|
|
12
|
+
desiredEntry: (args) => ({ command: "node", args: [...args] }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const { read, register, deregister, status } = adapter;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const DEEPAGENTS_COMMAND = "agentlink-deepagents";
|
|
7
|
+
const DEEPAGENTS_STATUS_URL = "http://127.0.0.1:2024/agentlink/v1/status";
|
|
8
|
+
const DEEPAGENTS_STATUS_TIMEOUT_MS = 1_500;
|
|
9
|
+
|
|
10
|
+
function commandInstalled(spawnImpl) {
|
|
11
|
+
const lookup = process.platform === "win32" ? "where" : "which";
|
|
12
|
+
const result = spawnImpl(lookup, [DEEPAGENTS_COMMAND], {
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
timeout: DEEPAGENTS_STATUS_TIMEOUT_MS,
|
|
15
|
+
});
|
|
16
|
+
return !result.error && result.status === 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mountedInCurrentProject(projectDir) {
|
|
20
|
+
try {
|
|
21
|
+
const config = JSON.parse(fs.readFileSync(path.join(projectDir, "langgraph.json"), "utf8"));
|
|
22
|
+
return String(config?.http?.app || "").toLowerCase().includes("agentlink");
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function statusReachable(fetchImpl) {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timer = setTimeout(() => controller.abort(), DEEPAGENTS_STATUS_TIMEOUT_MS);
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetchImpl(DEEPAGENTS_STATUS_URL, { signal: controller.signal });
|
|
33
|
+
return response.ok;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
} finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** DeepAgents 由独立 sidecar 承载,不属于 npm_cli bridge。 */
|
|
42
|
+
export async function deepagentsPreflight({
|
|
43
|
+
fetchImpl = globalThis.fetch,
|
|
44
|
+
spawnImpl = spawnSync,
|
|
45
|
+
projectDir = process.cwd(),
|
|
46
|
+
} = {}) {
|
|
47
|
+
if (commandInstalled(spawnImpl)) return { ok: true, source: "command" };
|
|
48
|
+
if (mountedInCurrentProject(projectDir)) return { ok: true, source: "langgraph_config" };
|
|
49
|
+
if (typeof fetchImpl === "function" && await statusReachable(fetchImpl)) {
|
|
50
|
+
return { ok: true, source: "status" };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
error: "DeepAgents sidecar 未安装或未启动",
|
|
55
|
+
hint: "请安装 deepagents-sdk,并在 langgraph.json 挂载 AgentLink app,或启动 agentlink-deepagents。",
|
|
56
|
+
};
|
|
57
|
+
}
|