@xyagent/cli 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/bin/agentlink +468 -86
- package/package.json +2 -2
- 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/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,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([
|
|
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 ——
|
|
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(
|
|
263
|
+
const logDir = path.join(resolveAgentlinkHome(), "logs");
|
|
160
264
|
fs.mkdirSync(logDir, { recursive: true });
|
|
161
265
|
|
|
162
|
-
//
|
|
266
|
+
// 所有 bridge runtime 都使用自己的默认工作区,避免服务从不确定目录启动。
|
|
163
267
|
// install 前确保目录存在(否则 launchd 因 WorkingDirectory 不存在拒绝启动)
|
|
164
268
|
let workingDirectory = null;
|
|
165
|
-
if (runtime
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 };
|