@xyagent/cli 1.1.2 → 1.2.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xyagent/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Relay-first CLI for pairing and bridging OpenClaw gateway traffic",
|
|
6
6
|
"type": "module",
|
|
@@ -37,4 +37,4 @@
|
|
|
37
37
|
"qrcode": "^1.5.4",
|
|
38
38
|
"qrcode-terminal": "^0.12.0"
|
|
39
39
|
}
|
|
40
|
-
}
|
|
40
|
+
}
|
|
@@ -189,7 +189,7 @@ export async function runPairInstall({ runtime, code, options, log }) {
|
|
|
189
189
|
` 卸载:agentlink uninstall -r ${runtime}\n`,
|
|
190
190
|
);
|
|
191
191
|
} else {
|
|
192
|
-
const logDir =
|
|
192
|
+
const logDir = path.join(os.homedir(), ".agentlink", "logs");
|
|
193
193
|
process.stderr.write(
|
|
194
194
|
`⚠️ service 已装,但进程 3 秒内未运行起来。\n` +
|
|
195
195
|
` 最可能原因:后台 PATH 里找不到 claude/codex,或 agent 启动崩溃。\n` +
|
|
@@ -32,6 +32,9 @@ const POLL_WAIT_SECONDS = 25;
|
|
|
32
32
|
const POLL_TIMEOUT_MS = (POLL_WAIT_SECONDS + 5) * 1000;
|
|
33
33
|
// 默认 publishEvent 超时:单条事件 10s 足够(数据通常 < 1MB)。
|
|
34
34
|
const PUBLISH_TIMEOUT_MS = 10_000;
|
|
35
|
+
// 上报耗时超过这个值才记一行。正常是几十毫秒(同机 curl 实测 80ms),
|
|
36
|
+
// 逐条 chunk 全记会把日志冲爆,所以只记异常的那些。
|
|
37
|
+
const PUBLISH_SLOW_MS = 500;
|
|
35
38
|
// 心跳间隔。服务端 gateway meta TTL 是 30s(http_gateway.go::gatewayMetaTTL),
|
|
36
39
|
// 取 1/3 留充足容错(2 次心跳丢失仍保活)。
|
|
37
40
|
export const META_HEARTBEAT_MS = 10_000;
|
|
@@ -88,22 +91,33 @@ export function createRelayWorker(opts) {
|
|
|
88
91
|
return false;
|
|
89
92
|
}
|
|
90
93
|
const url = `${base}/v1/gateways/${gw}/requests/${encodeURIComponent(requestId)}/events`;
|
|
94
|
+
const startedAt = Date.now();
|
|
91
95
|
try {
|
|
92
96
|
const res = await fetchJson(
|
|
93
97
|
url,
|
|
94
98
|
{ method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify(event) },
|
|
95
99
|
PUBLISH_TIMEOUT_MS,
|
|
96
100
|
);
|
|
101
|
+
const elapsedMs = Date.now() - startedAt;
|
|
97
102
|
if (!res.ok) {
|
|
98
103
|
log.warn("publish.fail", extractError(res, `publish failed (HTTP ${res.status})`), {
|
|
99
|
-
requestId, event: event.event, status: res.status,
|
|
104
|
+
requestId, event: event.event, status: res.status, elapsedMs,
|
|
100
105
|
});
|
|
101
106
|
return false;
|
|
102
107
|
}
|
|
108
|
+
// 🔴 成功也记耗时(2026-08-31 排查「桌面端每 25 秒才收到一批增量」时加的):
|
|
109
|
+
// 那次卡在「增量到底是产生得晚,还是产生了但发出去慢」分不开 —— 上报成功
|
|
110
|
+
// 时一行日志都没有,等于这段完全不可观测。判据很简单:
|
|
111
|
+
// · 个位数~几十 ms → 上报是快的,慢在别处(relay 出口 / 桌面端接收);
|
|
112
|
+
// · 逼近 PUBLISH_TIMEOUT_MS(10s) → 请求在排队,多半是 long-poll 长期占着
|
|
113
|
+
// 同一条 keep-alive 连接(HTTP/1.1 同连接上的请求必须串行)。
|
|
114
|
+
// 只记耗时与事件名,不记 data(正文可能很长,且含用户对话内容)。
|
|
115
|
+
logSlowPublish(log, { requestId, event: event.event, elapsedMs });
|
|
103
116
|
return true;
|
|
104
117
|
} catch (err) {
|
|
105
118
|
log.warn("publish.err", "publish errored", {
|
|
106
119
|
requestId, event: event.event, err: err?.message || String(err),
|
|
120
|
+
elapsedMs: Date.now() - startedAt,
|
|
107
121
|
});
|
|
108
122
|
return false;
|
|
109
123
|
}
|
|
@@ -119,6 +133,7 @@ export function createRelayWorker(opts) {
|
|
|
119
133
|
// detail === "stopped" 时表示主动下线,其它("ready" / "heartbeat" / "alive")都视为在线。
|
|
120
134
|
async function sendMeta(detail) {
|
|
121
135
|
const url = `${base}/v1/gateways/${gw}/meta`;
|
|
136
|
+
const metaStartedAt = Date.now();
|
|
122
137
|
const isAlive = detail !== "stopped";
|
|
123
138
|
const stateLabel = detail === "stopped" ? "stopped"
|
|
124
139
|
: detail === "ready" ? "running"
|
|
@@ -151,7 +166,12 @@ export function createRelayWorker(opts) {
|
|
|
151
166
|
}
|
|
152
167
|
return true;
|
|
153
168
|
} catch (err) {
|
|
154
|
-
|
|
169
|
+
// 带上耗时:贴着 PUBLISH_TIMEOUT_MS(10s) 说明是被超时掐的(排队 / 服务端慢),
|
|
170
|
+
// 远小于它则是连接层面直接失败(DNS / TCP / TLS)。两者的排查方向完全不同。
|
|
171
|
+
log.warn("meta.err", "meta heartbeat errored", {
|
|
172
|
+
err: err?.message || String(err),
|
|
173
|
+
elapsedMs: Date.now() - metaStartedAt,
|
|
174
|
+
});
|
|
155
175
|
return false;
|
|
156
176
|
}
|
|
157
177
|
}
|
|
@@ -299,6 +319,25 @@ export function createRelayWorker(opts) {
|
|
|
299
319
|
};
|
|
300
320
|
}
|
|
301
321
|
|
|
322
|
+
/**
|
|
323
|
+
* 上报慢到不正常时记一行。
|
|
324
|
+
*
|
|
325
|
+
* 判据(排查「桌面端每 25 秒才收到一批增量」用的):
|
|
326
|
+
* · 日志里一条都没有 → 上报是快的,慢在别处(relay 出口 / 桌面端接收侧);
|
|
327
|
+
* · 大量 elapsedMs 逼近 10s → 请求在排队。最可能的原因是 long-poll
|
|
328
|
+
* (POLL_WAIT_SECONDS=25) 长期占着同一条 keep-alive 连接,而 HTTP/1.1
|
|
329
|
+
* 同一连接上的请求必须串行,排在它后面的只能干等。
|
|
330
|
+
*/
|
|
331
|
+
export function logSlowPublish(log, fields) {
|
|
332
|
+
if (!fields || typeof fields.elapsedMs !== "number") return false;
|
|
333
|
+
if (fields.elapsedMs < PUBLISH_SLOW_MS) return false;
|
|
334
|
+
// 返回值 = 「有没有真的记下来」。log 不可用时如实返回 false,
|
|
335
|
+
// 不要因为「判定为慢」就报 true —— 调用方据此判断的是日志有没有落地。
|
|
336
|
+
if (typeof log?.warn !== "function") return false;
|
|
337
|
+
log.warn("publish.slow", "publish took unusually long", fields);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
302
341
|
function sleep(ms) {
|
|
303
342
|
return new Promise((r) => setTimeout(r, ms));
|
|
304
343
|
}
|
|
@@ -1,7 +1,62 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import readline from "node:readline";
|
|
3
5
|
|
|
4
6
|
const VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
7
|
+
|
|
8
|
+
// ── Windows .cmd shim 解析(openspec m-20260817-windows-runtime-parity 追加)──
|
|
9
|
+
//
|
|
10
|
+
// 🔴 为什么需要:qwen / kimi / codebuddy / opencode 在 Windows 上是 npm 的
|
|
11
|
+
// `.cmd` shim;Node 对 .cmd/.bat 的无 shell spawn 直接失败(CVE-2024-27980 后
|
|
12
|
+
// EINVAL),而 `shell: true` 会把含**用户 prompt**的 args 交给 cmd.exe 解析
|
|
13
|
+
// (注入/折断面)。所以:`where` 解析出真实文件 → `.exe` 直接 spawn;
|
|
14
|
+
// `.cmd`/`.bat` 读 shim 文本提取 node 入口 JS → `spawn(process.execPath,
|
|
15
|
+
// [js, ...args])`(无 shell、无注入)。两者都不成立才兜底 shell:true。
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 从 npm cmd-shim 文本里提取 node 入口 JS 的相对路径(相对 shim 所在目录)。
|
|
19
|
+
* npm 生成的模板固定形如 `"%~dp0\node_modules\<pkg>\bin\x.js" %*`。
|
|
20
|
+
* 提不出返回 null(交给兜底)。
|
|
21
|
+
*/
|
|
22
|
+
export function parseCmdShimTarget(text) {
|
|
23
|
+
const m = /%~?dp0%?[\\/]([^"\r\n]+?\.(?:c|m)?js)"/i.exec(String(text || ""));
|
|
24
|
+
return m ? m[1] : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 把「裸命令名 + args」解析成当前平台可安全 spawn 的形态。 */
|
|
28
|
+
function resolveSpawnSpec(binary, args) {
|
|
29
|
+
if (process.platform !== "win32" || path.isAbsolute(binary)) {
|
|
30
|
+
return { command: binary, args, shell: false };
|
|
31
|
+
}
|
|
32
|
+
const found = spawnSync("where", [binary], { encoding: "utf8", windowsHide: true });
|
|
33
|
+
const candidates = String(found.stdout || "")
|
|
34
|
+
.split(/\r?\n/)
|
|
35
|
+
.map((line) => line.trim())
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
const exe = candidates.find((c) => /\.exe$/i.test(c));
|
|
38
|
+
if (exe) {
|
|
39
|
+
return { command: exe, args, shell: false };
|
|
40
|
+
}
|
|
41
|
+
const shim = candidates.find((c) => /\.(cmd|bat)$/i.test(c));
|
|
42
|
+
if (shim) {
|
|
43
|
+
try {
|
|
44
|
+
const target = parseCmdShimTarget(fs.readFileSync(shim, "utf8"));
|
|
45
|
+
if (target) {
|
|
46
|
+
const jsPath = path.resolve(path.dirname(shim), target);
|
|
47
|
+
if (fs.existsSync(jsPath)) {
|
|
48
|
+
return { command: process.execPath, args: [jsPath, ...args], shell: false };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// 读 shim 失败走兜底
|
|
53
|
+
}
|
|
54
|
+
// 兜底:shell 方式跑 shim。args 会经 cmd 解析,是已知的折断面,
|
|
55
|
+
// 但比 ENOENT 完全不可用强 —— 只在解析失败时才落到这里。
|
|
56
|
+
return { command: shim, args, shell: true };
|
|
57
|
+
}
|
|
58
|
+
return { command: binary, args, shell: false };
|
|
59
|
+
}
|
|
5
60
|
const MAX_STDERR_CHARS = 8_000;
|
|
6
61
|
export const PROCESS_TERMINATION_GRACE_MS = 3_000;
|
|
7
62
|
// 子进程连续无任何 stdout/stderr 输出超过该上限视为卡死:kill 进程树并抛错,
|
|
@@ -24,10 +79,12 @@ function signalProcessTree(child, signal) {
|
|
|
24
79
|
}
|
|
25
80
|
|
|
26
81
|
export function probeBinary(binary) {
|
|
27
|
-
const
|
|
82
|
+
const spec = resolveSpawnSpec(binary, ["--version"]);
|
|
83
|
+
const result = spawnSync(spec.command, spec.args, {
|
|
28
84
|
encoding: "utf8",
|
|
29
85
|
timeout: VERSION_PROBE_TIMEOUT_MS,
|
|
30
86
|
windowsHide: true,
|
|
87
|
+
shell: spec.shell,
|
|
31
88
|
});
|
|
32
89
|
if (result.error || result.status !== 0) {
|
|
33
90
|
return {
|
|
@@ -52,10 +109,11 @@ export async function runNdjsonProcess({
|
|
|
52
109
|
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
53
110
|
}) {
|
|
54
111
|
const childEnv = env ? { ...process.env, ...env } : process.env;
|
|
55
|
-
const
|
|
112
|
+
const spec = resolveSpawnSpec(binary, args);
|
|
113
|
+
const child = spawn(spec.command, spec.args, {
|
|
56
114
|
cwd,
|
|
57
115
|
env: childEnv,
|
|
58
|
-
shell:
|
|
116
|
+
shell: spec.shell,
|
|
59
117
|
windowsHide: true,
|
|
60
118
|
detached: process.platform !== "win32",
|
|
61
119
|
// stdin 必须是 EOF:留 pipe 不关闭会让子进程(如 opencode run)等 stdin
|
|
@@ -776,7 +776,8 @@ export function spawnGatewayDetached({ binary, logPath, env, log }) {
|
|
|
776
776
|
detached: true,
|
|
777
777
|
stdio: ["ignore", fd, fd],
|
|
778
778
|
env: { ...process.env, ...(env || {}) },
|
|
779
|
-
|
|
779
|
+
// os.homedir() 而非 process.env.HOME:Windows 上 HOME 常不存在(USERPROFILE 才有)
|
|
780
|
+
cwd: os.homedir(),
|
|
780
781
|
});
|
|
781
782
|
// 让 stdio FD 不阻塞 GC
|
|
782
783
|
try { fs.closeSync(fd); } catch { /* ok */ }
|