@ycodium-ai/plugin-devices 0.2.3230 → 0.2.3232

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 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(process.env).find((name) => name.toUpperCase() === "PATH") ?? "PATH";
44
- process.env[key] = [...directories, process.env[key] ?? ""]
45
- .filter(Boolean)
46
- .join(NodePath.delimiter);
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
- main().catch((error) => {
284
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
285
- process.exitCode = 1;
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 drainOutput = (handle) => {
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 = [this.options.helperPath, "node"];
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: "none",
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: 23,
2860
+ apiVersion: 24,
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.3230",
3
+ "version": "0.2.3232",
4
4
  "description": "Lets agents drive iOS Simulators and Android Emulators on this machine.",
5
5
  "keywords": [
6
6
  "android",
@@ -33,9 +33,9 @@
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.3230",
37
- "ycodium-fetch-ports": "0.0.0",
38
- "ycodium-sse-stream": "0.0.0"
36
+ "ycodium-sse-stream": "0.0.0",
37
+ "@ycodium-ai/plugin-api": "0.2.3232",
38
+ "ycodium-fetch-ports": "0.0.0"
39
39
  },
40
40
  "ycodium": {
41
41
  "plugin": "./dist/index.js",