@ycodium-ai/plugin-devices 0.2.3230 → 0.2.3236
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 +10 -0
- package/dist/helper.mjs +115 -9
- package/dist/index.js +27 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -24,6 +24,16 @@ agent or a person actually uses a device (listing or opening one), the plugin:
|
|
|
24
24
|
|
|
25
25
|
Turning the plugin off stops both processes.
|
|
26
26
|
|
|
27
|
+
On Windows the hub and its ordinary children are owned by a kill-on-close Job.
|
|
28
|
+
Android Emulators started through its `emulator.exe` shim belong to the user and
|
|
29
|
+
continue running when the hub stops. This requires the shipped Windows launcher.
|
|
30
|
+
The launcher uses managed mode when its executable stem is `ycodium-job-launcher`
|
|
31
|
+
(case-insensitive). Any other name selects breakaway mode and looks for a same-named
|
|
32
|
+
`.exe` on PATH outside its own directory; Devices copies it as `emulator.exe`.
|
|
33
|
+
The shim depends on `agent-device` finding emulator through `PATH`; an absolute
|
|
34
|
+
SDK path bypasses it and the emulator then stops with the hub. The plugin logs a
|
|
35
|
+
warning when a launch cannot be matched to the shim's process record.
|
|
36
|
+
|
|
27
37
|
## Agent tools
|
|
28
38
|
|
|
29
39
|
On MCP the tools are prefixed with the plugin id (`devices_device_list`, …).
|
package/dist/helper.mjs
CHANGED
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
// 它不是插件进程里的代码,而是插件经 `ctx.process` 起的独立程序,跟插件自带的二进制同一类,
|
|
12
12
|
// 所以放在 `bin/` 而不是 `src/`:插件源码边界(不许直接起子进程)管的是 `src/`。`run` 模式自己
|
|
13
13
|
// 起 adb / emulator,并把 SIGTERM / SIGINT 转给它;Windows 上宿主直接结束本进程,收不到信号。
|
|
14
|
+
//
|
|
15
|
+
// emulator 脱离垫片绑在 agent-device「Windows 上只靠 PATH 找 emulator」上。若它改读
|
|
16
|
+
// ANDROID_HOME 或绝对路径,垫片会被绕过,模拟器随 hub 的 Job 一起被收;不会留孤儿,
|
|
17
|
+
// 但用户可能察觉不到。真实进程用例守脱离语义,下面的逐次启动记录核对守绝对路径绕过。
|
|
14
18
|
import * as NodeChildProcess from "node:child_process";
|
|
19
|
+
import * as NodeCrypto from "node:crypto";
|
|
20
|
+
import * as NodeDiagnosticsChannel from "node:diagnostics_channel";
|
|
15
21
|
import * as NodeFS from "node:fs";
|
|
16
22
|
import * as NodePath from "node:path";
|
|
17
23
|
import * as NodeURL from "node:url";
|
|
@@ -39,11 +45,104 @@ function parseArgs(argv) {
|
|
|
39
45
|
return options;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
function prependPath(directories) {
|
|
43
|
-
const key = Object.keys(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
function prependPath(directories, env = process.env, delimiter = NodePath.delimiter) {
|
|
49
|
+
const key = Object.keys(env).find((name) => name.toUpperCase() === "PATH") ?? "PATH";
|
|
50
|
+
env[key] = [...directories, env[key] ?? ""].filter(Boolean).join(delimiter);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 允许脱离 hub 的 Job 的那一个程序:垫片文件名与启动核对都从它来。
|
|
54
|
+
const BREAKAWAY_TARGET = "emulator";
|
|
55
|
+
|
|
56
|
+
/** 只在宿主提供 launcher 时布置;每次 hub 启动换 nonce,旧 pid 文件不能冒充本次脱离。 */
|
|
57
|
+
export function prepareBreakawayShim({ platform, directory, env }) {
|
|
58
|
+
const source = env.YCODIUM_BREAKAWAY_SHIM_SOURCE;
|
|
59
|
+
if (platform !== "win32" || !source || !directory) return null;
|
|
60
|
+
NodeFS.mkdirSync(directory, { recursive: true });
|
|
61
|
+
const target = NodePath.join(directory, `${BREAKAWAY_TARGET}.exe`);
|
|
62
|
+
// 复制到新文件再换名,不能覆写仍在执行或硬链接到安装目录的可执行文件。
|
|
63
|
+
const temporary = NodePath.join(directory, `${NodeCrypto.randomUUID()}.tmp`);
|
|
64
|
+
try {
|
|
65
|
+
NodeFS.copyFileSync(source, temporary);
|
|
66
|
+
NodeFS.renameSync(temporary, target);
|
|
67
|
+
} finally {
|
|
68
|
+
NodeFS.rmSync(temporary, { force: true });
|
|
69
|
+
}
|
|
70
|
+
for (const name of NodeFS.readdirSync(directory)) {
|
|
71
|
+
if (/^\d+\.json$/.test(name)) NodeFS.rmSync(NodePath.join(directory, name), { force: true });
|
|
72
|
+
}
|
|
73
|
+
const nonce = NodeCrypto.randomUUID();
|
|
74
|
+
env.YCODIUM_BREAKAWAY_SHIM_NONCE = nonce;
|
|
75
|
+
prependPath([directory], env, ";");
|
|
76
|
+
return { directory, nonce };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 记录在 CreateProcess 后、ResumeThread 前原子落盘,targetPid 的父进程因此就是 shimPid。 */
|
|
80
|
+
export function verifyBreakawayLaunch(child, shim, report, timeoutMs = 5_000) {
|
|
81
|
+
const pid = child.pid;
|
|
82
|
+
if (!pid) return;
|
|
83
|
+
const recordPath = NodePath.join(shim.directory, `${pid}.json`);
|
|
84
|
+
let done = false;
|
|
85
|
+
let watcher;
|
|
86
|
+
let timer;
|
|
87
|
+
const finish = (record) => {
|
|
88
|
+
if (done) return;
|
|
89
|
+
done = true;
|
|
90
|
+
watcher?.close();
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
report({
|
|
93
|
+
event: "ycodium-emulator-breakaway",
|
|
94
|
+
shimPid: pid,
|
|
95
|
+
targetPid: record?.targetPid ?? null,
|
|
96
|
+
verified: record !== null,
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
const check = () => {
|
|
100
|
+
if (done) return;
|
|
101
|
+
try {
|
|
102
|
+
const record = JSON.parse(NodeFS.readFileSync(recordPath, "utf8"));
|
|
103
|
+
if (
|
|
104
|
+
record.shimPid !== pid ||
|
|
105
|
+
record.nonce !== shim.nonce ||
|
|
106
|
+
!Number.isSafeInteger(record.targetPid) ||
|
|
107
|
+
record.targetPid <= 0 ||
|
|
108
|
+
record.targetPid === pid
|
|
109
|
+
)
|
|
110
|
+
return;
|
|
111
|
+
finish(record);
|
|
112
|
+
NodeFS.rmSync(recordPath, { force: true });
|
|
113
|
+
} catch {
|
|
114
|
+
// 记录尚未落盘;由目录事件再次检查,到期仍不匹配才警告。
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
// Windows 目录事件可能合并或丢失;到期再读一次,不能把通知缺失当成垫片缺失。
|
|
118
|
+
timer = setTimeout(() => {
|
|
119
|
+
check();
|
|
120
|
+
if (!done) finish(null);
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
timer.unref();
|
|
123
|
+
try {
|
|
124
|
+
watcher = NodeFS.watch(shim.directory, { persistent: false }, check);
|
|
125
|
+
watcher.on("error", () => watcher.close());
|
|
126
|
+
} catch {
|
|
127
|
+
// 目录监听不可用时仍保留到期读取,不能把监听错误当成脱离失败。
|
|
128
|
+
}
|
|
129
|
+
check();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function observeEmulatorLaunches(shim) {
|
|
133
|
+
// Node 的公开诊断通道在 ChildProcess 构造时通知;spawn 事件后才读 pid / spawnfile。
|
|
134
|
+
NodeDiagnosticsChannel.subscribe("child_process", ({ process: child }) => {
|
|
135
|
+
child.once("spawn", () => {
|
|
136
|
+
const name = NodePath.win32
|
|
137
|
+
.basename(child.spawnfile)
|
|
138
|
+
.toLowerCase()
|
|
139
|
+
.replace(/\.exe$/u, "");
|
|
140
|
+
if (name !== BREAKAWAY_TARGET) return;
|
|
141
|
+
verifyBreakawayLaunch(child, shim, (record) =>
|
|
142
|
+
process.stderr.write(`${JSON.stringify(record)}\n`),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
47
146
|
}
|
|
48
147
|
|
|
49
148
|
/**
|
|
@@ -237,6 +336,12 @@ async function main() {
|
|
|
237
336
|
const [entry, ...args] = options.rest;
|
|
238
337
|
if (!entry) throw new Error("helper node: missing entry script");
|
|
239
338
|
applySdkEnvironment(options.sdk);
|
|
339
|
+
const shim = prepareBreakawayShim({
|
|
340
|
+
platform: hostPlatform,
|
|
341
|
+
directory: options.shim,
|
|
342
|
+
env: process.env,
|
|
343
|
+
});
|
|
344
|
+
if (shim) observeEmulatorLaunches(shim);
|
|
240
345
|
await loadEntry(entry, args);
|
|
241
346
|
return;
|
|
242
347
|
}
|
|
@@ -280,7 +385,8 @@ async function main() {
|
|
|
280
385
|
throw new Error(`helper: unknown mode ${String(mode)}`);
|
|
281
386
|
}
|
|
282
387
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
388
|
+
if (process.argv[1] && NodePath.resolve(process.argv[1]) === NodeURL.fileURLToPath(import.meta.url))
|
|
389
|
+
main().catch((error) => {
|
|
390
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
391
|
+
process.exitCode = 1;
|
|
392
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -901,7 +901,17 @@ var nodeProcessProbe = {
|
|
|
901
901
|
}
|
|
902
902
|
};
|
|
903
903
|
var defaultSleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
904
|
-
var
|
|
904
|
+
var parseBreakawayWarning = (line) => {
|
|
905
|
+
try {
|
|
906
|
+
const record = JSON.parse(line);
|
|
907
|
+
if (typeof record === "object" && record !== null && "event" in record && record.event === "ycodium-emulator-breakaway" && "verified" in record && record.verified === false) {
|
|
908
|
+
return record;
|
|
909
|
+
}
|
|
910
|
+
} catch {
|
|
911
|
+
}
|
|
912
|
+
return null;
|
|
913
|
+
};
|
|
914
|
+
var drainOutput = (handle, log) => {
|
|
905
915
|
const tail = [];
|
|
906
916
|
const decoder = new TextDecoder();
|
|
907
917
|
const drain = async (stream) => {
|
|
@@ -912,6 +922,13 @@ var drainOutput = (handle) => {
|
|
|
912
922
|
pending = lines.pop() ?? "";
|
|
913
923
|
for (const line of lines) {
|
|
914
924
|
if (line.trim().length === 0) continue;
|
|
925
|
+
const warning = parseBreakawayWarning(line);
|
|
926
|
+
if (warning !== null) {
|
|
927
|
+
log.warn(
|
|
928
|
+
"Emulator breakaway shim was bypassed or could not be verified; the emulator may stop with the device hub",
|
|
929
|
+
warning
|
|
930
|
+
);
|
|
931
|
+
}
|
|
915
932
|
tail.push(line.trim());
|
|
916
933
|
if (tail.length > OUTPUT_TAIL_LINES) tail.shift();
|
|
917
934
|
}
|
|
@@ -981,7 +998,12 @@ var HubProcess = class {
|
|
|
981
998
|
await NodeFSP2.mkdir(this.options.stateDir, { recursive: true });
|
|
982
999
|
const port = await (this.options.reservePort ?? (() => reserveFetchSafePort()))();
|
|
983
1000
|
const origin = `http://127.0.0.1:${String(port)}`;
|
|
984
|
-
const args = [
|
|
1001
|
+
const args = [
|
|
1002
|
+
this.options.helperPath,
|
|
1003
|
+
"node",
|
|
1004
|
+
"--shim",
|
|
1005
|
+
NodePath2.join(this.options.stateDir, "emulator-shim")
|
|
1006
|
+
];
|
|
985
1007
|
if (launch.sdkRoot !== null) args.push("--sdk", launch.sdkRoot);
|
|
986
1008
|
args.push(
|
|
987
1009
|
"--",
|
|
@@ -997,7 +1019,7 @@ var HubProcess = class {
|
|
|
997
1019
|
try {
|
|
998
1020
|
handle = await this.options.process.spawn({
|
|
999
1021
|
program: "host:node",
|
|
1000
|
-
jobPolicy: "
|
|
1022
|
+
jobPolicy: "allowExplicitBreakaway",
|
|
1001
1023
|
args,
|
|
1002
1024
|
cwd: this.options.stateDir,
|
|
1003
1025
|
env: { FORCE_COLOR: "0", NO_COLOR: "1" }
|
|
@@ -1011,7 +1033,7 @@ var HubProcess = class {
|
|
|
1011
1033
|
}
|
|
1012
1034
|
);
|
|
1013
1035
|
}
|
|
1014
|
-
const output = drainOutput(handle);
|
|
1036
|
+
const output = drainOutput(handle, this.options.log);
|
|
1015
1037
|
let exited = false;
|
|
1016
1038
|
void handle.exited.then(() => {
|
|
1017
1039
|
exited = true;
|
|
@@ -2835,7 +2857,7 @@ var deviceToolkit = (service) => ({
|
|
|
2835
2857
|
var HUB_SERVICE = "hub";
|
|
2836
2858
|
var index_default = definePlugin({
|
|
2837
2859
|
id: "devices",
|
|
2838
|
-
apiVersion:
|
|
2860
|
+
apiVersion: 25,
|
|
2839
2861
|
activate: async (ctx) => {
|
|
2840
2862
|
const direct = (input, init) => ctx.proxies.fetch(PLUGIN_PROXY_DIRECT).then((fetchDirect) => fetchDirect(input, init));
|
|
2841
2863
|
const host = new LocalDeviceHost({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ycodium-ai/plugin-devices",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3236",
|
|
4
4
|
"description": "Lets agents drive iOS Simulators and Android Emulators on this machine.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"tailwindcss": "^4.0.0",
|
|
34
34
|
"typescript": "~6.0.3",
|
|
35
35
|
"vite-plus": "0.2.2",
|
|
36
|
-
"@ycodium-ai/plugin-api": "0.2.
|
|
36
|
+
"@ycodium-ai/plugin-api": "0.2.3236",
|
|
37
37
|
"ycodium-fetch-ports": "0.0.0",
|
|
38
38
|
"ycodium-sse-stream": "0.0.0"
|
|
39
39
|
},
|