@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,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
+ }
@@ -12,6 +12,7 @@
12
12
  // 所以调用前必须先跑过 `agentlink pair <code> -r <runtime>` —— ensureState 会校验。
13
13
 
14
14
  import fs from "node:fs";
15
+ import { createHash } from "node:crypto";
15
16
  import os from "node:os";
16
17
  import path from "node:path";
17
18
  import process from "node:process";
@@ -19,7 +20,30 @@ import { spawnSync } from "node:child_process";
19
20
  import { readPairState } from "../core/pairCodeClient.mjs";
20
21
  import { ensureDefaultWorkspace, defaultWorkspacePath } from "../core/defaultWorkspace.mjs";
21
22
 
22
- const SERVICE_RUNTIMES = new Set(["claude", "codex"]);
23
+ const SERVICE_RUNTIMES = new Set([
24
+ "claude", "codex", "cursor", "opencode", "qwen", "kimi", "codebuddy",
25
+ ]);
26
+ const SERVICE_SCOPE_HASH_LENGTH = 12;
27
+
28
+ function resolveAgentlinkHome() {
29
+ const configured = String(process.env.AGENTLINK_HOME ?? "").trim();
30
+ return configured || path.join(os.homedir(), ".agentlink");
31
+ }
32
+
33
+ export function serviceScopeSuffixFor(agentlinkHome, defaultAgentlinkHome) {
34
+ const currentHome = path.resolve(agentlinkHome);
35
+ const defaultHome = path.resolve(defaultAgentlinkHome);
36
+ if (currentHome === defaultHome) return "";
37
+ const digest = createHash("sha256").update(currentHome).digest("hex");
38
+ return `-${digest.slice(0, SERVICE_SCOPE_HASH_LENGTH)}`;
39
+ }
40
+
41
+ function serviceScopeSuffix() {
42
+ return serviceScopeSuffixFor(
43
+ resolveAgentlinkHome(),
44
+ path.join(os.homedir(), ".agentlink"),
45
+ );
46
+ }
23
47
 
24
48
  /**
25
49
  * reload 用的 runtime 清单 —— 比 install 集合更广:也覆盖 openclaw/hermes
@@ -42,6 +66,31 @@ const ALL_RELOAD_TARGETS = {
42
66
  linux: "agentlink-agent-codex.service",
43
67
  win: "Agentlink\\Agent-codex",
44
68
  },
69
+ cursor: {
70
+ mac: "com.agentlink.agent.cursor",
71
+ linux: "agentlink-agent-cursor.service",
72
+ win: "Agentlink\\Agent-cursor",
73
+ },
74
+ opencode: {
75
+ mac: "com.agentlink.agent.opencode",
76
+ linux: "agentlink-agent-opencode.service",
77
+ win: "Agentlink\\Agent-opencode",
78
+ },
79
+ qwen: {
80
+ mac: "com.agentlink.agent.qwen",
81
+ linux: "agentlink-agent-qwen.service",
82
+ win: "Agentlink\\Agent-qwen",
83
+ },
84
+ kimi: {
85
+ mac: "com.agentlink.agent.kimi",
86
+ linux: "agentlink-agent-kimi.service",
87
+ win: "Agentlink\\Agent-kimi",
88
+ },
89
+ codebuddy: {
90
+ mac: "com.agentlink.agent.codebuddy",
91
+ linux: "agentlink-agent-codebuddy.service",
92
+ win: "Agentlink\\Agent-codebuddy",
93
+ },
45
94
  openclaw: {
46
95
  mac: "com.agentlink.bridge",
47
96
  linux: "agentlink-bridge.service",
@@ -54,6 +103,18 @@ const ALL_RELOAD_TARGETS = {
54
103
  },
55
104
  };
56
105
 
106
+ function reloadTarget(runtime) {
107
+ const target = ALL_RELOAD_TARGETS[runtime];
108
+ if (!target) return null;
109
+ const scope = serviceScopeSuffix();
110
+ if (!scope) return target;
111
+ return {
112
+ mac: `${target.mac}${scope}`,
113
+ linux: target.linux.replace(/\.service$/, `${scope}.service`),
114
+ win: `${target.win}${scope}`,
115
+ };
116
+ }
117
+
57
118
  function ensureRuntime(runtime) {
58
119
  if (!SERVICE_RUNTIMES.has(runtime)) {
59
120
  throw new Error(`service runtime must be one of: ${[...SERVICE_RUNTIMES].join(", ")}`);
@@ -95,7 +156,7 @@ function agentBinPath() {
95
156
  }
96
157
 
97
158
  // ===== macOS launchd =====
98
- function macLabel(runtime) { return `com.agentlink.agent.${runtime}`; }
159
+ function macLabel(runtime) { return `com.agentlink.agent.${runtime}${serviceScopeSuffix()}`; }
99
160
  function macPlistPath(runtime) {
100
161
  return path.join(os.homedir(), "Library", "LaunchAgents", `${macLabel(runtime)}.plist`);
101
162
  }
@@ -114,7 +175,7 @@ function macUid() {
114
175
  * "进程未运行起来",反复 pair / 残留旧 plist 更易触发。
115
176
  *
116
177
  * 健壮序列(与 bin/agentlink 的 installBridgeServiceMac 对齐):
117
- * bootout —— 现代 unload,先拆掉任何同 label 的旧实例(幂等,未加载也无妨)
178
+ * bootout —— 先按 service target 拆掉任何同 label 的旧实例(幂等,未加载也无妨)
118
179
  * bootstrap —— 把 job 加载进 GUI domain
119
180
  * enable —— 清掉上一次 unload -w / disable 留下的 stale "disabled" override
120
181
  * kickstart -k —— 强制真正运行(已在跑则重启),不再靠 RunAtLoad 的运气
@@ -125,6 +186,9 @@ export function buildMacInstallSteps({ uid, label, plistPath }) {
125
186
  const domain = `gui/${uid}`;
126
187
  const service = `gui/${uid}/${label}`;
127
188
  return [
189
+ // 不能只按 plistPath bootout:同 label 的旧 job 可能来自已删掉的临时 HOME。
190
+ { cmd: "launchctl", args: ["bootout", service] },
191
+ // 兼容历史按 plist 加载的实例;上一步未命中时仍尽力清理。
128
192
  { cmd: "launchctl", args: ["bootout", domain, plistPath] },
129
193
  { cmd: "launchctl", args: ["bootstrap", domain, plistPath] },
130
194
  { cmd: "launchctl", args: ["enable", service] },
@@ -132,12 +196,51 @@ export function buildMacInstallSteps({ uid, label, plistPath }) {
132
196
  ];
133
197
  }
134
198
 
199
+ /**
200
+ * 执行并验证 macOS 安装步骤。注入 spawn 使测试不必触碰真实 gui/<uid> launchd 域。
201
+ */
202
+ export function runMacInstallSteps({ steps, plistPath, service, spawn = spawnSync, log }) {
203
+ let bootstrapOk = false;
204
+ let kickstart = null;
205
+ for (const step of steps) {
206
+ const verb = step.args[0];
207
+ const result = spawn(step.cmd, step.args, { encoding: "utf8" });
208
+ // bootout 对未加载的 job 必然失败,安装仍应是幂等的。
209
+ if (verb === "bootout") continue;
210
+ if (verb === "bootstrap") {
211
+ bootstrapOk = result.status === 0;
212
+ continue;
213
+ }
214
+ if (verb === "enable") continue;
215
+ if (verb === "kickstart") kickstart = result;
216
+ }
217
+
218
+ if (!kickstart || kickstart.status !== 0) {
219
+ log?.warn("service.install", "modern launchctl sequence failed, falling back to load -w", {
220
+ bootstrap_ok: bootstrapOk,
221
+ });
222
+ const fallback = spawn("launchctl", ["load", "-w", plistPath], { encoding: "utf8" });
223
+ if (fallback.status !== 0) {
224
+ throw new Error("launchctl bootstrap/kickstart failed");
225
+ }
226
+ }
227
+
228
+ const printed = spawn("launchctl", ["print", service], { encoding: "utf8" });
229
+ if (printed.status !== 0) {
230
+ throw new Error(`launchctl install verification failed for ${service}`);
231
+ }
232
+ const loadedPath = parseLaunchctlPrint(printed.stdout).path;
233
+ if (loadedPath !== plistPath) {
234
+ throw new Error(`launchctl loaded unexpected plist for ${service}`);
235
+ }
236
+ }
237
+
135
238
  /**
136
239
  * 解析 `launchctl print gui/<uid>/<label>` 的关键字段。纯函数,便于单测。
137
240
  * 只有 print 能暴露 `runs`,识破 pid 文件看不出的 "loaded but runs=0" 陷阱。
138
241
  */
139
242
  export function parseLaunchctlPrint(out) {
140
- const h = { state: null, pid: null, runs: null, lastExitCode: null };
243
+ const h = { state: null, pid: null, runs: null, lastExitCode: null, path: null };
141
244
  for (const line of String(out || "").split(/\r?\n/)) {
142
245
  const t = line.trim();
143
246
  let m;
@@ -145,6 +248,7 @@ export function parseLaunchctlPrint(out) {
145
248
  else if ((m = t.match(/^pid\s*=\s*(\d+)$/))) h.pid = Number(m[1]);
146
249
  else if ((m = t.match(/^runs\s*=\s*(\d+)$/))) h.runs = Number(m[1]);
147
250
  else if ((m = t.match(/^last exit code\s*=\s*(\d+)$/))) h.lastExitCode = Number(m[1]);
251
+ else if ((m = t.match(/^path\s*=\s*(.+)$/))) h.path = m[1].trim();
148
252
  }
149
253
  h.running = (h.state || "").toLowerCase() === "running" || (h.pid != null && h.pid > 0);
150
254
  return h;
@@ -156,13 +260,13 @@ async function macInstall(runtime, log) {
156
260
  const state = ensureStateOrFail(runtime);
157
261
  const label = macLabel(runtime);
158
262
  const plistPath = macPlistPath(runtime);
159
- const logDir = path.join(os.homedir(), ".agentlink", "logs");
263
+ const logDir = path.join(resolveAgentlinkHome(), "logs");
160
264
  fs.mkdirSync(logDir, { recursive: true });
161
265
 
162
- // align-runtime-default-workspace: claude/codex 注入 WorkingDirectory
266
+ // 所有 bridge runtime 都使用自己的默认工作区,避免服务从不确定目录启动。
163
267
  // install 前确保目录存在(否则 launchd 因 WorkingDirectory 不存在拒绝启动)
164
268
  let workingDirectory = null;
165
- if (runtime === "claude" || runtime === "codex") {
269
+ if (SERVICE_RUNTIMES.has(runtime)) {
166
270
  workingDirectory = await ensureDefaultWorkspace(runtime);
167
271
  }
168
272
  const stdoutLog = path.join(logDir, `service-${runtime}.out.log`);
@@ -203,6 +307,7 @@ async function macInstall(runtime, log) {
203
307
  <key>EnvironmentVariables</key>
204
308
  <dict>
205
309
  <key>AGENTLINK_RELAY_URL</key><string>${escapeXml(state.relay_url)}</string>
310
+ <key>AGENTLINK_HOME</key><string>${escapeXml(resolveAgentlinkHome())}</string>
206
311
  <key>AGENTLINK_AGENT_LOG_LEVEL</key><string>info</string>
207
312
  <key>PATH</key><string>${escapeXml(mergedPath)}</string>
208
313
  <key>HOME</key><string>${escapeXml(os.homedir())}</string>
@@ -220,35 +325,7 @@ async function macInstall(runtime, log) {
220
325
  // 现代序列 bootout→bootstrap→enable→kickstart(见 buildMacInstallSteps 注释)。
221
326
  const uid = macUid();
222
327
  const steps = buildMacInstallSteps({ uid, label, plistPath });
223
- let bootstrapOk = false;
224
- let kickstart = null;
225
- for (const step of steps) {
226
- const verb = step.args[0];
227
- const r = spawnSync(step.cmd, step.args, { encoding: "utf8" });
228
- // bootout 对未加载的 job 必然失败——幂等忽略。
229
- if (verb === "bootout") continue;
230
- if (verb === "bootstrap") {
231
- bootstrapOk = r.status === 0;
232
- // bootstrap 失败常见于 job 已加载(EALREADY/5);enable+kickstart 仍可救回,
233
- // 故不在此抛错,留到最后看 kickstart 结果。
234
- continue;
235
- }
236
- if (verb === "enable") continue; // 清 disabled override,失败无妨
237
- if (verb === "kickstart") kickstart = r;
238
- }
239
-
240
- if (!kickstart || kickstart.status !== 0) {
241
- // 现代序列没能拉起——回退 legacy load -w 兜底(与 bin/agentlink 一致)。
242
- log?.warn("service.install", "modern launchctl sequence failed, falling back to load -w", {
243
- bootstrap_ok: bootstrapOk,
244
- kickstart_err: (kickstart?.stderr || kickstart?.stdout || "").trim(),
245
- });
246
- const fallback = spawnSync("launchctl", ["load", "-w", plistPath], { encoding: "utf8" });
247
- if (fallback.status !== 0) {
248
- const msg = (kickstart?.stderr || kickstart?.stdout || fallback.stderr || fallback.stdout || "").trim();
249
- throw new Error(`launchctl bootstrap/kickstart failed: ${msg || "unknown"}`);
250
- }
251
- }
328
+ runMacInstallSteps({ steps, plistPath, service: `gui/${uid}/${label}`, log });
252
329
  return { backend: "launchd", path: plistPath, label, logs: { out: stdoutLog, err: stderrLog } };
253
330
  }
254
331
 
@@ -285,7 +362,7 @@ function macStatus(runtime) {
285
362
  }
286
363
 
287
364
  // ===== Linux systemd (user) =====
288
- function sdUnitName(runtime) { return `agentlink-agent-${runtime}.service`; }
365
+ function sdUnitName(runtime) { return `agentlink-agent-${runtime}${serviceScopeSuffix()}.service`; }
289
366
  function sdUnitPath(runtime) {
290
367
  return path.join(os.homedir(), ".config", "systemd", "user", sdUnitName(runtime));
291
368
  }
@@ -305,13 +382,13 @@ async function sdInstall(runtime, log) {
305
382
  const node = process.execPath;
306
383
  const state = ensureStateOrFail(runtime);
307
384
  const unitPath = sdUnitPath(runtime);
308
- const logDir = path.join(os.homedir(), ".agentlink", "logs");
385
+ const logDir = path.join(resolveAgentlinkHome(), "logs");
309
386
  fs.mkdirSync(logDir, { recursive: true });
310
387
 
311
388
  // align-runtime-default-workspace: 仅 claude/codex 注入 WorkingDirectory
312
389
  // install 前确保目录存在(否则 systemd 因 WorkingDirectory 不存在拒绝启动)
313
390
  let workingDirectoryLine = "";
314
- if (runtime === "claude" || runtime === "codex") {
391
+ if (SERVICE_RUNTIMES.has(runtime)) {
315
392
  const wsPath = await ensureDefaultWorkspace(runtime);
316
393
  workingDirectoryLine = `WorkingDirectory=${wsPath}`;
317
394
  }
@@ -327,6 +404,7 @@ After=network-online.target
327
404
  [Service]
328
405
  Type=simple
329
406
  Environment=AGENTLINK_RELAY_URL=${state.relay_url}
407
+ Environment=AGENTLINK_HOME=${resolveAgentlinkHome()}
330
408
  Environment=AGENTLINK_AGENT_LOG_LEVEL=info
331
409
  Environment=PATH=${pathEnv}
332
410
  Environment=HOME=${os.homedir()}${workingDirectoryLine ? `\n${workingDirectoryLine}` : ""}
@@ -463,7 +541,7 @@ export function serviceReloadAll() {
463
541
 
464
542
  // macOS reload
465
543
  function macReload(runtime) {
466
- const label = ALL_RELOAD_TARGETS[runtime].mac;
544
+ const label = reloadTarget(runtime).mac;
467
545
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
468
546
  const ref = `gui/${uid}/${label}`;
469
547
  const present = spawnSync("launchctl", ["print", ref], { encoding: "utf8" });
@@ -480,7 +558,7 @@ function macReload(runtime) {
480
558
 
481
559
  // Linux systemd user reload
482
560
  function sdReload(runtime) {
483
- const unit = ALL_RELOAD_TARGETS[runtime].linux;
561
+ const unit = reloadTarget(runtime).linux;
484
562
  // list-unit-files 可同时检测 masked/enabled/disabled;exit 0 表示 unit 存在。
485
563
  // 另一种探测:`systemctl --user cat <unit>` —— 不存在会返回非 0。
486
564
  const probe = spawnSync("systemctl", ["--user", "cat", unit], { encoding: "utf8" });
@@ -497,7 +575,7 @@ function sdReload(runtime) {
497
575
 
498
576
  // Windows schtasks reload
499
577
  function winReload(runtime) {
500
- const task = ALL_RELOAD_TARGETS[runtime].win;
578
+ const task = reloadTarget(runtime).win;
501
579
  const probe = spawnSync("schtasks", ["/Query", "/TN", task, "/FO", "LIST"], { encoding: "utf8" });
502
580
  if (probe.status !== 0) {
503
581
  return { status: "not-installed", backend: "schtasks", task };