@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.
Files changed (47) hide show
  1. package/README.md +42 -0
  2. package/bin/agentlink +468 -86
  3. package/package.json +3 -3
  4. package/src/tunnel_service.mjs +17 -1
  5. package/src-ext/bin.mjs +12 -11
  6. package/src-ext/commands/agent.mjs +34 -0
  7. package/src-ext/commands/pair.mjs +122 -23
  8. package/src-ext/commands/service.mjs +1 -1
  9. package/src-ext/core/activeRuns.mjs +26 -9
  10. package/src-ext/core/defaultWorkspace.mjs +43 -13
  11. package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
  12. package/src-ext/core/installationIdentity.mjs +94 -0
  13. package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
  14. package/src-ext/core/pairCodeClient.mjs +48 -8
  15. package/src-ext/core/pairInventory.mjs +31 -6
  16. package/src-ext/core/relayWorker.mjs +21 -1
  17. package/src-ext/core/runtimeRegistry.mjs +180 -0
  18. package/src-ext/core/scanWorkspaces.mjs +163 -23
  19. package/src-ext/core/unifiedDispatchHandler.mjs +9 -2
  20. package/src-ext/postinstall.mjs +14 -0
  21. package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
  22. package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
  23. package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
  24. package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
  25. package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
  26. package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
  27. package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
  28. package/src-ext/runtime/claude/handleRequest.mjs +15 -37
  29. package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
  30. package/src-ext/runtime/codebuddy/index.mjs +41 -0
  31. package/src-ext/runtime/codex/handleRequest.mjs +13 -35
  32. package/src-ext/runtime/cursor/index.mjs +46 -0
  33. package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
  34. package/src-ext/runtime/deepagents/preflight.mjs +57 -0
  35. package/src-ext/runtime/hermes/envSetup.mjs +22 -8
  36. package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
  37. package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
  38. package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
  39. package/src-ext/runtime/hermes/index.mjs +1 -1
  40. package/src-ext/runtime/hermes/preflight.mjs +2 -1
  41. package/src-ext/runtime/kimi/index.mjs +100 -0
  42. package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
  43. package/src-ext/runtime/opencode/index.mjs +48 -0
  44. package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
  45. package/src-ext/runtime/opencode/preflight.mjs +72 -0
  46. package/src-ext/runtime/qwen/index.mjs +42 -0
  47. 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: workspace_path 非空且目录存在,spawn 用它作 cwd;
261
- // 否则 fallback 到 defaultWorkspace(不再用工厂 cwd,修 service 模式 "/" bug)。
262
- const defaultOpenclawInvoke = async ({ thread_id, run_id, input, blocks = [], workspace_path: reqWorkspacePath, session_key = null, onChunk: _rawOnChunk }) => {
263
- // per-request cwd 解析(align-runtime-default-workspace):
264
- // 1. workspace_path 非空 expandHome + existsSync → 用它
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
- const resolved = expandHome(reqWorkspacePath.trim());
275
- if (existsSync(resolved)) {
276
- spawnCwd = resolved;
277
- log.info?.("claude.spawn.cwd.workspace", "using workspace_path as cwd", {
278
- cwd: resolved, source: "workspace_path",
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) : null;
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
- try { onObject(obj); }
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
- // per-request cwd 解析(align-runtime-default-workspace):
232
- // 1. workspace_path 非空 expandHome + existsSync → 用它
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
- const resolved = expandHome(reqWorkspacePath.trim());
242
- if (fsExistsSync(resolved)) {
243
- spawnCwd = resolved;
244
- log.info?.("codex.spawn.cwd.workspace", "using workspace_path as cwd", {
245
- cwd: resolved, source: "workspace_path",
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
- let priorCodexThreadId = thread_id ? threadCodexIdMap.get(thread_id) : null;
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
+ }