vcorp-chrome 1.7.0 → 2.0.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.
@@ -0,0 +1,139 @@
1
+ // 原生消息宿主的安装 / 卸载 / 体检(npx vcorp-chrome desktop install|uninstall|status)。
2
+ //
3
+ // Chrome 找宿主靠一份清单:Windows 在注册表 HKCU 下登记清单路径,macOS / Linux 把清单放进
4
+ // 浏览器约定的目录。清单的 path 指向一个启动脚本,脚本用装它时的那个 node 跑 native-host/host.js。
5
+ // allowed_origins 只写本扩展:扩展 id 由 manifest.json 里的 key 算出来,不手抄。
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+ import { spawn, execFileSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { HOME, ensureHome } from './paths.js';
13
+
14
+ export const HOST_NAME = 'ai.vcorp.desktop';
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
16
+ const HOST_JS = path.join(ROOT, 'native-host', 'host.js');
17
+ const DIR = path.join(HOME, 'desktop-host');
18
+ const MANIFEST = path.join(DIR, `${HOST_NAME}.json`);
19
+
20
+ // Chrome 的扩展 id:公钥 DER 的 sha256 前 16 字节,每个十六进制位 0-f 映射成 a-p
21
+ export function extensionId(manifestKey) {
22
+ const hex = crypto.createHash('sha256').update(Buffer.from(manifestKey, 'base64')).digest('hex').slice(0, 32);
23
+ return [...hex].map((c) => String.fromCharCode(97 + parseInt(c, 16))).join('');
24
+ }
25
+
26
+ function ourExtensionId() {
27
+ const mf = JSON.parse(fs.readFileSync(path.join(ROOT, 'extension', 'manifest.json'), 'utf8'));
28
+ return extensionId(mf.key);
29
+ }
30
+
31
+ const REG_KEYS = [
32
+ `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_NAME}`,
33
+ `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${HOST_NAME}`,
34
+ ];
35
+
36
+ function manifestDirs() {
37
+ const h = os.homedir();
38
+ if (process.platform === 'darwin') {
39
+ const base = path.join(h, 'Library', 'Application Support');
40
+ return ['Google/Chrome', 'Chromium', 'Microsoft Edge', 'BraveSoftware/Brave-Browser'].map((b) => path.join(base, b, 'NativeMessagingHosts'));
41
+ }
42
+ return ['google-chrome', 'chromium', 'microsoft-edge', 'BraveSoftware/Brave-Browser'].map((b) => path.join(h, '.config', b, 'NativeMessagingHosts'));
43
+ }
44
+
45
+ export function install() {
46
+ ensureHome();
47
+ fs.mkdirSync(DIR, { recursive: true });
48
+ const win = process.platform === 'win32';
49
+ const launcher = path.join(DIR, win ? 'vcorp-desktop-host.cmd' : 'vcorp-desktop-host');
50
+ fs.writeFileSync(launcher, win
51
+ ? `@echo off\r\n"${process.execPath}" "${HOST_JS}" %*\r\n`
52
+ : `#!/bin/sh\nexec "${process.execPath}" "${HOST_JS}" "$@"\n`);
53
+ if (!win) fs.chmodSync(launcher, 0o755);
54
+ const id = ourExtensionId();
55
+ fs.writeFileSync(MANIFEST, JSON.stringify({
56
+ name: HOST_NAME,
57
+ description: 'vcorp-chrome desktop host',
58
+ path: launcher,
59
+ type: 'stdio',
60
+ allowed_origins: [`chrome-extension://${id}/`],
61
+ }, null, 2));
62
+ const placed = [];
63
+ if (win) {
64
+ for (const key of REG_KEYS) {
65
+ execFileSync('reg', ['add', key, '/ve', '/t', 'REG_SZ', '/d', MANIFEST, '/f'], { stdio: 'ignore', windowsHide: true });
66
+ placed.push(key);
67
+ }
68
+ } else {
69
+ for (const dir of manifestDirs()) {
70
+ if (!fs.existsSync(path.dirname(dir))) continue; // 没装这个浏览器
71
+ fs.mkdirSync(dir, { recursive: true });
72
+ fs.copyFileSync(MANIFEST, path.join(dir, `${HOST_NAME}.json`));
73
+ placed.push(dir);
74
+ }
75
+ }
76
+ return { id, launcher, manifest: MANIFEST, placed };
77
+ }
78
+
79
+ export function uninstall() {
80
+ const removed = [];
81
+ if (process.platform === 'win32') {
82
+ for (const key of REG_KEYS) {
83
+ try { execFileSync('reg', ['delete', key, '/f'], { stdio: 'ignore', windowsHide: true }); removed.push(key); } catch { /* 本来就没登记 */ }
84
+ }
85
+ } else {
86
+ for (const dir of manifestDirs()) {
87
+ const f = path.join(dir, `${HOST_NAME}.json`);
88
+ if (fs.existsSync(f)) { fs.rmSync(f); removed.push(f); }
89
+ }
90
+ }
91
+ fs.rmSync(DIR, { recursive: true, force: true });
92
+ return { removed };
93
+ }
94
+
95
+ // 按 Chrome 的方式跟宿主说一句话:起启动脚本、写一条带长度前缀的消息、读一条回包
96
+ export function talk(msg, { launcher, timeoutMs = 20000 } = {}) {
97
+ return new Promise((resolve, reject) => {
98
+ const win = process.platform === 'win32';
99
+ const child = win
100
+ ? spawn('cmd.exe', ['/d', '/c', launcher, `chrome-extension://${ourExtensionId()}/`], { windowsHide: true })
101
+ : spawn(launcher, [`chrome-extension://${ourExtensionId()}/`]);
102
+ const chunks = [];
103
+ const timer = setTimeout(() => { child.kill(); reject(new Error(`宿主 ${timeoutMs / 1000}s 没有回包`)); }, timeoutMs);
104
+ child.stdout.on('data', (b) => chunks.push(b));
105
+ child.on('error', (e) => { clearTimeout(timer); reject(e); });
106
+ child.on('close', () => {
107
+ clearTimeout(timer);
108
+ const buf = Buffer.concat(chunks);
109
+ if (buf.length < 4) return reject(new Error('宿主没有回包'));
110
+ resolve(JSON.parse(buf.subarray(4, 4 + buf.readUInt32LE(0)).toString('utf8')));
111
+ });
112
+ const body = Buffer.from(JSON.stringify(msg), 'utf8');
113
+ const len = Buffer.alloc(4);
114
+ len.writeUInt32LE(body.length, 0);
115
+ child.stdin.end(Buffer.concat([len, body]));
116
+ });
117
+ }
118
+
119
+ export async function status() {
120
+ const lines = [];
121
+ let launcher = '';
122
+ if (!fs.existsSync(MANIFEST)) return { installed: false, lines: ['没有安装:运行 npx vcorp-chrome desktop install'] };
123
+ const mf = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
124
+ launcher = mf.path;
125
+ lines.push(`清单 ${MANIFEST}`);
126
+ if (process.platform === 'win32') {
127
+ for (const key of REG_KEYS) {
128
+ let ok = false;
129
+ try { ok = execFileSync('reg', ['query', key, '/ve'], { encoding: 'utf8', windowsHide: true }).includes(MANIFEST); } catch { /* 没登记 */ }
130
+ lines.push(`${ok ? '✅' : '❌'} ${key}`);
131
+ }
132
+ }
133
+ if (!fs.existsSync(HOST_JS)) lines.push(`❌ 启动脚本指向的 ${HOST_JS} 不在了(npx 缓存被清过?):重新运行 desktop install`);
134
+ const hello = await talk({ op: 'hello' }, { launcher });
135
+ lines.push(hello.ready
136
+ ? `✅ 宿主应答 · 设备 ${hello.device.id} · agent-desktop 可用`
137
+ : `❌ 宿主应答了,但 agent-desktop 用不了:[${hello.error?.code}] ${hello.error?.message}`);
138
+ return { installed: true, ready: !!hello.ready, device: hello.device, lines };
139
+ }
package/src/lib/paths.js CHANGED
@@ -11,8 +11,28 @@ export const AUDIT_FILE = path.join(HOME, 'audit.jsonl');
11
11
  export const ALLOWLIST_FILE = path.join(HOME, 'allowlist.json');
12
12
  export const LOG_FILE = path.join(HOME, 'bridge.log');
13
13
 
14
+ export const MACHINE_FILE = path.join(HOME, 'machine.json');
15
+
14
16
  export const DEFAULT_PORT = 8899;
15
17
 
18
+ // 这台电脑(这个系统用户)的身份。本机 MCP 和原生宿主(native-host/)读同一个文件,
19
+ // 所以同一台电脑上的桌面设备在两条通道里是同一个设备号。第一次用时生成,之后不变。
20
+ export function machineId() {
21
+ try {
22
+ const id = JSON.parse(fs.readFileSync(MACHINE_FILE, 'utf8')).machineId;
23
+ if (/^m_[0-9a-f]{16}$/.test(id)) return id;
24
+ } catch { /* 还没有,下面生成 */ }
25
+ ensureHome();
26
+ const id = `m_${crypto.randomBytes(8).toString('hex')}`;
27
+ const tmp = `${MACHINE_FILE}.${process.pid}.tmp`;
28
+ fs.writeFileSync(tmp, JSON.stringify({ machineId: id, createdAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
29
+ try { fs.linkSync(tmp, MACHINE_FILE); } catch { /* 别的进程抢先写好了,用它的 */ }
30
+ fs.rmSync(tmp, { force: true });
31
+ return JSON.parse(fs.readFileSync(MACHINE_FILE, 'utf8')).machineId;
32
+ }
33
+
34
+ export const desktopDeviceId = (machine) => `desktop-${machine}`;
35
+
16
36
  export function ensureHome() {
17
37
  fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
18
38
  return HOME;
package/src/lib/rpc.js CHANGED
@@ -100,6 +100,7 @@ export class BridgeClient {
100
100
  this.extensionVersion = msg.extensionVersion;
101
101
  this.versionMismatch = !!msg.versionMismatch;
102
102
  this.extensionProtocol = msg.extensionProtocol;
103
+ this.bridgeDevices = !!msg.devices;
103
104
  ws.onmessage = (e2) => this.#dispatch(JSON.parse(e2.data));
104
105
  ws.onclose = () => { this.ws = null; this.#failAll('桥连接已断开'); };
105
106
  ws.onerror = () => {};
@@ -144,7 +145,11 @@ export class BridgeClient {
144
145
  }
145
146
 
146
147
  // 桥侧已有 30s 超时兜底;这里的 35s 只防「桥自己也没了」的情况
147
- async call(cmd, params = {}, { tabId, timeoutMs = 35000 } = {}) {
148
+ // device:指定投给哪一个浏览器设备(devices 列出的 id)。不给就由桥挑有窗口、最近活跃的那个。
149
+ async call(cmd, params = {}, { tabId, timeoutMs = 35000, device } = {}) {
150
+ if ((device || cmd === 'devices') && this.ws && !this.bridgeDevices) {
151
+ throw Object.assign(new Error('在跑的桥是旧版本,不认识设备:指定 device 的命令会被它投给默认的 Chrome。所有会话都退出、桥自动重启之后再用。'), { code: 'BRIDGE_OLD' });
152
+ }
148
153
  if (!this.ws) await this.connect();
149
154
  const id = 'c' + ++this.seq;
150
155
  return new Promise((resolve, reject) => {
@@ -154,7 +159,7 @@ export class BridgeClient {
154
159
  }, timeoutMs);
155
160
  this.waiting.set(id, { resolve, reject, timer });
156
161
  // 桥侧也要知道这条命令能等多久,否则它按默认 30s 就把慢命令判死了
157
- this.ws.send(JSON.stringify({ type: 'cmd', id, cmd, params, tabId, timeout: timeoutMs - 3000 }));
162
+ this.ws.send(JSON.stringify({ type: 'cmd', id, cmd, params, tabId, timeout: timeoutMs - 3000, ...(device ? { to: { device } } : {}) }));
158
163
  });
159
164
  }
160
165
  }
package/src/mcp-server.js CHANGED
@@ -10,9 +10,14 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
10
10
  import { BridgeClient } from './lib/rpc.js';
11
11
  import { resolveHost } from './lib/host.js';
12
12
  import { flatCount } from '../extension/script.js';
13
+ import { LOADS } from '../extension/loads/index.js';
13
14
  import { getLearnings, saveLearnings, saveDraft, adoptDraft } from './lib/learnings.js';
14
- import { audit } from './lib/paths.js';
15
+ import { audit, machineId, desktopDeviceId } from './lib/paths.js';
16
+ import { DesktopSession, findBinary } from './lib/desktop.js';
17
+ import { redact } from './bridge.js';
18
+ import { describeDevices } from '../extension/device.js';
15
19
  import fs from 'node:fs';
20
+ import os from 'node:os';
16
21
  import path from 'node:path';
17
22
  import { VERSION } from './lib/version.js';
18
23
  import { compat, compatNote } from '../extension/compat.js';
@@ -207,6 +212,7 @@ const TOOLS = [
207
212
  tabId: TAB,
208
213
  label: { type: 'string', description: 'With new/select: what this tab is for ("CRM import — batch 2"). Shown in list, page panel, and to the user.' },
209
214
  focus: { type: 'boolean', description: 'Also bring the tab to the foreground. Interrupts the user — off by default.' },
215
+ device: { type: 'string', description: 'With 2+ Chromes: id from devices. That tabId then sticks to it.' },
210
216
  },
211
217
  required: ['action'],
212
218
  },
@@ -457,6 +463,31 @@ const TOOLS = [
457
463
  required: ['expr'],
458
464
  },
459
465
  },
466
+ {
467
+ name: 'load',
468
+ description:
469
+ 'Install a capability pack bundled with the extension into the tab (e.g. "eda" on pro.lceda.cn / pro.easyeda.com). ' +
470
+ 'Returns its read/write action list. Use `call` afterwards instead of eval.',
471
+ inputSchema: {
472
+ type: 'object',
473
+ properties: { name: { type: 'string' }, reload: { type: 'boolean' }, tabId: TAB },
474
+ required: ['name'],
475
+ },
476
+ },
477
+ {
478
+ name: 'call',
479
+ description:
480
+ 'Call one action of a loaded pack with a JSON payload; result is JSON. Re-loads by itself after a page refresh. ' +
481
+ 'timeout ms defaults to the pack limit (eda: 120000, max 600000).',
482
+ inputSchema: {
483
+ type: 'object',
484
+ properties: {
485
+ name: { type: 'string' }, action: { type: 'string' }, payload: { type: 'object' },
486
+ timeout: { type: 'number' }, tabId: TAB,
487
+ },
488
+ required: ['name', 'action'],
489
+ },
490
+ },
460
491
  {
461
492
  name: 'learnings',
462
493
  description:
@@ -474,6 +505,39 @@ const TOOLS = [
474
505
  },
475
506
  },
476
507
  },
508
+ {
509
+ name: 'desktop',
510
+ description:
511
+ 'Native apps and OS dialogs (the file picker a page opens) via the accessibility tree. '
512
+ + 'list-windows → snapshot {app|window} → act on the FULL ref "@s…:eN" → read the effect line. '
513
+ + 'Browser windows are refused (use browser tools). headed:true = real mouse/keys. '
514
+ + 'steps:[{cmd,…}] (≤20) stops on failure or send/delete.',
515
+ inputSchema: {
516
+ type: 'object',
517
+ properties: {
518
+ cmd: {
519
+ type: 'string',
520
+ enum: ['list-apps', 'list-windows', 'snapshot', 'find', 'get', 'screenshot', 'wait', 'click', 'double-click',
521
+ 'right-click', 'type', 'set-value', 'clear', 'focus', 'select', 'toggle', 'check', 'uncheck', 'expand',
522
+ 'collapse', 'scroll', 'scroll-to', 'press', 'launch', 'focus-window', 'clipboard-set', 'clipboard-get',
523
+ 'list-notifications', 'notification-action'],
524
+ },
525
+ app: { type: 'string' }, window: { type: 'string' }, ref: { type: 'string' },
526
+ root: { type: 'string' }, skeleton: { type: 'boolean' }, all: { type: 'boolean' },
527
+ text: { type: 'string' }, value: { type: 'string' }, keys: { type: 'string', description: 'press: "ctrl+s"' },
528
+ role: { type: 'string' }, name: { type: 'string' }, nth: { type: 'number' }, property: { type: 'string' },
529
+ direction: { type: 'string' }, amount: { type: 'number' },
530
+ ms: { type: 'number' }, windowTitle: { type: 'string' }, timeout: { type: 'number' },
531
+ index: { type: 'number' }, action: { type: 'string' }, headed: { type: 'boolean' },
532
+ steps: { type: 'array', items: { type: 'object' } },
533
+ },
534
+ },
535
+ },
536
+ {
537
+ name: 'devices',
538
+ description: 'Devices this session can drive: each connected Chrome and this computer\'s desktop.',
539
+ inputSchema: { type: 'object', properties: {} },
540
+ },
477
541
  {
478
542
  name: 'reload',
479
543
  description:
@@ -526,7 +590,7 @@ warning change target or approach, never repeat the same call.
526
590
 
527
591
  A STEP NEEDS A HUMAN (captcha, QR login, OTP, payment)? Call \`ask\` — never retry or work
528
592
  around. Never let a download put a dialog on their screen: see the \`download\` tool for the
529
- required order. OS file dialogs are beyond any extension.`;
593
+ required order. Native apps and OS file dialogs: \`desktop\`.`;
530
594
 
531
595
  // 页面来的文本全部走这里。边界标记 + 降权说明,both 是给模型看的。
532
596
  function wrapUntrusted(body, meta = '') {
@@ -547,6 +611,9 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
547
611
 
548
612
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
549
613
 
614
+ let desk = null;
615
+ const tabDevice = new Map();
616
+
550
617
  // 宿主身份:--client 只是兜底,真名以 initialize 里宿主自报的 clientInfo 为准
551
618
  // (见 src/lib/host.js)。握手一定先于第一条工具调用,所以在那时认一次就够。
552
619
  let host = null;
@@ -559,7 +626,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
559
626
  return host;
560
627
  };
561
628
 
562
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
629
+ server.setRequestHandler(CallToolRequestSchema, busy(async (req) => {
563
630
  const { name, arguments: args = {} } = req.params;
564
631
  identify();
565
632
  try {
@@ -572,12 +639,51 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
572
639
  return { content: [{ type: 'text', text }] };
573
640
  }
574
641
 
642
+ // desktop 也不经过桥:本机直接起 agent-desktop 子进程。审计照记,设备号写本机桌面
643
+ if (name === 'desktop') {
644
+ desk ??= new DesktopSession({ session: `vc-${bridge.sessionId}` });
645
+ const t0 = Date.now();
646
+ const id = `desktop:${t0}`;
647
+ const cmd = `desktop.${args.steps !== undefined ? 'steps' : args.cmd}`;
648
+ audit({ ev: 'cmd', id, cmd, client: bridge.client, sid: bridge.sessionId, device: desktopDeviceId(machineId()), params: redact(args) });
649
+ let r;
650
+ try {
651
+ r = await desk.run(args, { timeoutMs: args.steps ? 120000 : Math.min(Math.max((Number(args.timeout) || 0) + 10000, 30000), 600000) });
652
+ } catch (e) {
653
+ audit({ ev: 'res', id, cmd, ok: false, ms: Date.now() - t0, error: e.code || 'INTERNAL' });
654
+ throw e;
655
+ }
656
+ const ok = r.env ? !!r.env.ok : !r.stopped;
657
+ audit({ ev: 'res', id, cmd, ok, ms: Date.now() - t0, error: ok ? undefined : r.env?.error?.code, effect: r.env?.data?.disposition?.delivery });
658
+ if (r.image) return { content: [{ type: 'image', data: r.image, mimeType: 'image/png' }] };
659
+ return { content: [{ type: 'text', text: wrapUntrusted(r.text, 'source="desktop"') }], isError: !ok || undefined };
660
+ }
661
+
662
+ if (name === 'devices') {
663
+ const machine = machineId();
664
+ const list = [{ id: desktopDeviceId(machine), kind: 'desktop', name: `${os.hostname()} 桌面`, machine, online: true,
665
+ caps: { platform: process.platform } }];
666
+ let browserNote = '';
667
+ try {
668
+ if (!bridge.ws) await bridge.connect();
669
+ const d = await bridge.call('devices', {}, { timeoutMs: 8000 });
670
+ list.unshift(...d.devices.map((x) => (x.primary ? { ...x, name: `${x.name}(默认)` } : x)));
671
+ } catch (e) {
672
+ browserNote = `\n浏览器设备查不到:[${e.code || 'INTERNAL'}] ${e.message}`;
673
+ }
674
+ return { content: [{ type: 'text', text: describeDevices(list) + browserNote }] };
675
+ }
676
+
575
677
  if (!bridge.ws) await bridge.connect();
576
678
 
679
+ // 指定设备:tabs 带 device 时记住新开/选中的 tabId 属于哪台,之后同一 tabId 自动跟过去
680
+ const device = args.device || (args.tabId != null ? tabDevice.get(args.tabId) : undefined);
681
+ if (args.device !== undefined) delete args.device;
682
+
577
683
  // 翻页在这一侧循环:审计里 fetch→fetch 相邻 386 次,agent 在一页一个回合地
578
684
  // 手动翻。扩展只管发单次请求,翻页的循环、落盘、停机判定都不该经过模型。
579
685
  if (name === 'fetch' && args.pages && !args.binary) {
580
- return { content: [{ type: 'text', text: await fetchPages(bridge, args) }] };
686
+ return { content: [{ type: 'text', text: await fetchPages(bridge, args, device) }] };
581
687
  }
582
688
 
583
689
  // upload:先把**路径**交给扩展,让它用 CDP 的 DOM.setFileInputFiles 让浏览器
@@ -591,7 +697,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
591
697
  const base = { path: args.path, name: path.basename(args.path), type: mime, bytes: st.size, selector: args.selector, dropSelector: args.dropSelector };
592
698
  // 上传本身可能很慢(本机→浏览器只是投喂,但页面自己的上传要时间),给足预算
593
699
  const timeoutMs = Math.max(60000, Math.round(st.size / (1024 * 1024)) * 1000);
594
- let out = args.dropSelector ? null : await bridge.call('upload', base, { tabId: args.tabId, timeoutMs });
700
+ let out = args.dropSelector ? null : await bridge.call('upload', base, { tabId: args.tabId, timeoutMs, device });
595
701
  if (!out || out.needBytes) {
596
702
  // 走拖放:这条必须有文件字节。超过 DROP_MAX 就明确报错,不要拿桥去撞——
597
703
  // 桥一断,当前 agent 会话就再也连不回来了,比报错难查得多。
@@ -603,7 +709,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
603
709
  `先压小,或改用页面上真实存在的 file input(给 selector)。`);
604
710
  }
605
711
  out = await bridge.call('upload', { ...base, base64: fs.readFileSync(args.path).toString('base64') },
606
- { tabId: args.tabId, timeoutMs });
712
+ { tabId: args.tabId, timeoutMs, device });
607
713
  }
608
714
  return { content: [{ type: 'text', text: out.text }] };
609
715
  }
@@ -627,6 +733,12 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
627
733
  // 扩展侧的执行预算同源(extension/script.js),否则预算会判死一个
628
734
  // 还在老实跑的剧本,「已经在做」被报成「没做,去重试」。
629
735
  if (n === 'act') return cap(Math.min(flatCount(a.steps) || 1, 60) * 8000 + 20000);
736
+ // 能力包:load 要等编辑器加载(最多 30 秒)再等 ready();call 的上限由参数和包的 limits 决定
737
+ if (n === 'load') return 80000;
738
+ if (n === 'call') {
739
+ const lim = LOADS[a.name]?.limits;
740
+ return cap(Math.min(Number(a.timeout) || lim?.timeoutMs || 120000, lim?.timeoutMaxMs || 600000) + 15000);
741
+ }
630
742
  return undefined; // 其余维持默认,它们的真实耗时离墙还很远
631
743
  };
632
744
 
@@ -638,7 +750,11 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
638
750
  // 无人值守(cron、服务器)没有人可问。开关放在 MCP 这一侧读环境变量,
639
751
  // 扩展只管照做——扩展不该知道自己跑在什么场景里。
640
752
  name === 'ask' ? { ...args, disabled: process.env.HUASHU_CHROME_ASK === 'off' } : args,
641
- { tabId: args.tabId, timeoutMs: budgetOf(name, args, askMs) });
753
+ { tabId: args.tabId, timeoutMs: budgetOf(name, args, askMs), device });
754
+ if (device && name === 'tabs') {
755
+ const tid = data?.tabId ?? (args.action === 'select' ? args.tabId : undefined);
756
+ if (tid != null) tabDevice.set(tid, device);
757
+ }
642
758
 
643
759
  // 浏览器只能下到 Downloads 里;下完再挪到 agent 要的位置,对它保持透明
644
760
  if (name === 'download' && args.savePath && data.path) {
@@ -689,7 +805,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
689
805
  } catch (e) {
690
806
  return { content: [{ type: 'text', text: hint(e) + mismatchNote(true) }], isError: true };
691
807
  }
692
- });
808
+ }));
693
809
 
694
810
  // 扩展和本地端版本对不上时在回执里说一次——回执是唯一一定有人读的通道。
695
811
  // 分级见 extension/compat.js:补丁号差异静默;本地端旧了提一次怎么升级;
@@ -706,16 +822,51 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
706
822
  }
707
823
 
708
824
  await server.connect(new StdioServerTransport());
825
+ watchIdle();
709
826
  return server;
710
827
  }
711
828
 
829
+ // 闲置自退。
830
+ //
831
+ // 本该是宿主的事:会话结束就关掉 stdin,我们读到 EOF 自然就退了(实测成立)。
832
+ // 可 Goose 桌面版每开一个会话起一个 mcp、会话结束却从不关管道——11-2 查到
833
+ // 一个 goose serve 下面挂着 34 个,最老的一周前,合计约 2 GB。宿主不收,只能自己收。
834
+ //
835
+ // 判据是「stdin 上多久没来过任何东西」,且手上没有在跑的工具调用。默认 4 小时:
836
+ // 一个真在用的会话很少静默这么久;万一真是,宿主下一次调用会看到连接已断,
837
+ // 代价是重开一次会话,远小于无限期地攒进程。HUASHU_CHROME_MCP_IDLE_MIN=0 关掉。
838
+ let inflight = 0;
839
+ let lastActive = Date.now();
840
+ const busy = (fn) => async (...a) => {
841
+ inflight++;
842
+ try { return await fn(...a); } finally { inflight--; lastActive = Date.now(); }
843
+ };
844
+
845
+ export function idleLimitMs(env = process.env) {
846
+ const raw = env.HUASHU_CHROME_MCP_IDLE_MIN;
847
+ const min = raw == null || raw === '' ? 240 : Number(raw);
848
+ return Number.isFinite(min) && min > 0 ? min * 60000 : 0;
849
+ }
850
+
851
+ function watchIdle() {
852
+ const limit = idleLimitMs();
853
+ if (!limit) return;
854
+ process.stdin.on('data', () => { lastActive = Date.now(); });
855
+ const tick = setInterval(() => {
856
+ if (inflight || Date.now() - lastActive < limit) return;
857
+ console.error(`[vcorp-chrome] ${Math.round(limit / 60000)} 分钟没有任何请求,宿主大概已经不用这个进程了,自行退出`);
858
+ process.exit(0);
859
+ }, Math.min(60000, Math.max(limit / 4, 50)));
860
+ tick.unref(); // 吊着进程的该是 stdin,不是这个检查
861
+ }
862
+
712
863
  // 自动翻页。页码型:改一个 query 参数递增;游标型:从上一页响应里按路径取下一个游标。
713
864
  // 停机判据都是确定性的:非 2xx、空体、和上一页一模一样、游标为空或没变、跑满 max。
714
865
  // 落盘是每页一行 JSON(url/status/body),body 能解析成 JSON 就存解析后的。
715
866
  const pathGet = (obj, p) => String(p || '').split('.').filter(Boolean).reduce((o, k) => (o == null ? undefined : o[k]), obj);
716
867
  const EMPTY_BODY = /^\s*(\[\s*\]|\{\s*\}|null)?\s*$/;
717
868
 
718
- export async function fetchPages(bridge, args) {
869
+ export async function fetchPages(bridge, args, device) {
719
870
  const pg = args.pages || {};
720
871
  if (!pg.param && !pg.cursorParam) throw Object.assign(new Error('pages 需要 param(页码参数名)或 cursorParam(游标参数名)'), { code: 'INTERNAL' });
721
872
  const max = Math.min(Math.max(Number(pg.max) || 10, 1), 50);
@@ -730,7 +881,7 @@ export async function fetchPages(bridge, args) {
730
881
  const url = new URL(base);
731
882
  if (pg.param) url.searchParams.set(pg.param, String(n));
732
883
  if (pg.cursorParam && cursor) url.searchParams.set(pg.cursorParam, cursor);
733
- const data = await bridge.call('fetch', { url: url.toString(), init: args.init, maxBody: args.maxBody || 2000000, via: args.via }, { tabId: args.tabId });
884
+ const data = await bridge.call('fetch', { url: url.toString(), init: args.init, maxBody: args.maxBody || 2000000, via: args.via }, { tabId: args.tabId, device });
734
885
  const m = /^(\d+)\n\n([\s\S]*)$/.exec(data?.text || '');
735
886
  const status = Number(m?.[1] || 0), body = m?.[2] ?? '';
736
887
  if (!(status >= 200 && status < 300)) { stop = `第 ${i + 1} 页返回 ${status},停下`; break; }
@@ -790,6 +941,12 @@ function hint(e) {
790
941
  + '在「高保真模式」那一栏点「开启」——只需一次。',
791
942
  L2_BUSY: '真实输入事件用不了(多半是用户自己开着 DevTools,一个标签页只允许一个调试器)。'
792
943
  + '已经用普通事件完成了;如果结果不对,让用户关掉 DevTools 再试。',
944
+ DEVICE_OFFLINE: '指定的设备不在线。devices 看现在有哪些,换一台或请用户把它打开。',
945
+ AMBIGUOUS_DEVICE: '有不止一台设备能做这件事。从候选里挑一个,用 device 指定。',
946
+ DESKTOP_DENIED: '这条桌面命令被安全规则拦下(原因见上)。浏览器窗口用浏览器工具;别换个写法绕。',
947
+ DESKTOP_UNAVAILABLE: '这台电脑上的桌面能力没装好。运行 npx vcorp-chrome doctor 看缺什么。',
948
+ PAY_REFUSED: '付款类按钮只能由用户亲手点。用 ask 把要付的东西说清楚,请用户自己完成。',
949
+ NEEDS_SINGLE_CALL: '发送/删除一类的动作不在批处理里做。先确认目标没错,再单独调用一次。',
793
950
  };
794
951
  return `[${e.code || 'INTERNAL'}] ${e.message}` + (map[e.code] ? `\n→ ${map[e.code]}` : '');
795
952
  }