dsh-mobile-plugin 0.5.17 → 0.5.18

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/lib/impl.js CHANGED
@@ -33,6 +33,7 @@ const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManu
33
33
  const { pairingPayload } = require('./relay/pairing-payload.cjs')
34
34
  const { resolvePort } = require('./relay/port-allocator.cjs')
35
35
  const { createPreviewService } = require('./relay/preview.js')
36
+ const { mountTerminal, mountCli } = require('./mobile/services.js')
36
37
  // 手机打开「电脑上的网页版」时用的那枚一次性登录令牌(见 mobile/host-web.js)。
37
38
  const hostWeb = require('./mobile/host-web.js')
38
39
  // 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
@@ -42,6 +43,7 @@ const credentials = require('./shared/credentials-entry.js').credentialsEntry()
42
43
  const { createApiProxy } = require('./mobile/host-bridge.js')
43
44
  const { installApprovalBridge } = require('./mobile/approvals.js')
44
45
  const { installQuestionBridge } = require('./mobile/questions.js')
46
+ const { installNarration } = require('./mobile/narration.js') // 过程播报:默认关,开关真值每步现读
45
47
  // 「手机上最近来过没有」:守卫记、控制面状态读。单独一个模块,装配层只转交。
46
48
  const activity = require('./mobile/activity.cjs')
47
49
  const { installSessionCleanup } = require('./session-cleanup.cjs')
@@ -50,6 +52,7 @@ const { installSessionCleanup } = require('./session-cleanup.cjs')
50
52
  const statsScheduler = require('./mobile/stats-scheduler.cjs')
51
53
  // 扩展卡的宿主侧:`extension_present` 工具与卡状态账本(见 mobile/extensions.js)。
52
54
  const { installExtensions } = require('./mobile/extensions.js')
55
+ const { mountPhoneControl } = require('./mobile/phone/index.js') // 「让 AI 操作手机」,见 mobile/phone/
53
56
  const { activeGuard } = require('./host-guard.cjs')
54
57
  // 桌面端侧栏展开态的快照存储。桌面把这份状态只记在浏览器 localStorage 里,宿主必须
55
58
  // 有一个落盘处:客户端半边(lib/client.js)上报 → 这里 → 移动端读取。
@@ -360,6 +363,7 @@ function apply(ctx) {
360
363
  return undefined
361
364
  }, `${name}: extension tool`)
362
365
  mount('统计采集', () => ctx.effect(() => statsScheduler.start(ctx), `${name}: stats collect`))
366
+ mount('过程播报', () => ctx.effect(() => installNarration(ctx), `${name}: process narration`))
363
367
 
364
368
  const status = createStatusView({
365
369
  state,
@@ -487,6 +491,8 @@ function apply(ctx) {
487
491
  // 一样必须包在 ctx.effect 里,否则插件重载时重复注册会直接抛错。
488
492
  registerUpgradeRoute: (route) => mountRoute(route, true),
489
493
  preview,
494
+ terminalService: mountTerminal(ctx, { authorized, name }), cliService: mountCli(ctx, { authorized, name }),
495
+ phoneService: mountPhoneControl(ctx, { authorized, credentials, instanceId: state.instanceId, name }),
490
496
  // 网页版登录令牌那条:handler 在这里造好、按依赖交给路由表,理由与 preview 一样——
491
497
  // 它要读的是本进程的 `ctx.webServer` 与 `ctx.get('connection')`。
492
498
  hostWeb: hostWeb.handleHostWeb,
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 一个 ACP 适配器子进程 = 一条 CLI 会话的**连接层**。
5
+ *
6
+ * 协议实现用官方库 `@agentclientprotocol/sdk`(稳定 v1 入口),不自己写 JSON-RPC:
7
+ * 那正是这次不做「逐家适配」的关键 —— 四家 CLI 的差别全被适配器吃掉了,这里只认一种协议。
8
+ *
9
+ * 为什么用动态 `import()`:插件这一层是 CJS(`lib/mobile/package.json`),而 SDK 是纯 ESM。
10
+ * 稳定入口**零运行时依赖**(zod 只是可选 peer,稳定入口没 import 它),所以这个 import 不
11
+ * 会拖出额外的东西。
12
+ *
13
+ * `session/update` 一律走 `onNotification` 自己接,**不用** SDK 的 `ActiveSession`:后者会把
14
+ * 每条更新再排进一个内部队列供 `nextUpdate()` 取,我们从不取,等于给每个会话攒一个只涨不
15
+ * 落的数组。
16
+ */
17
+
18
+ const { spawn } = require('node:child_process');
19
+ const fs = require('node:fs');
20
+ const { Readable, Writable } = require('node:stream');
21
+
22
+ const { childEnv } = require('./agents.js');
23
+ const { openImage } = require('./images.js');
24
+
25
+ /** 握手超时:适配器第一次要 npx 下载,给足;再久就当它起不来了。 */
26
+ const START_TIMEOUT_MS = 180000;
27
+
28
+ /**
29
+ * 报给适配器的客户端身份。
30
+ *
31
+ * `version` **不能省**:ACP 的 `Implementation` 把它标成可选,但 opencode 那边的校验是
32
+ * 必填的 —— 少这一格整条 `initialize` 会回 `-32602 Invalid params`,表现就是界面上一直
33
+ * 「正在启动」(适配器根本没接上)。codex / claude 不挑,所以只有 opencode 会露出来。
34
+ */
35
+ const CLIENT_INFO = { name: 'dsh-mobile', version: pluginVersion() };
36
+
37
+ function pluginVersion() {
38
+ try {
39
+ const version = require('../../package.json').version;
40
+ return typeof version === 'string' && version ? version : '0.0.0';
41
+ } catch {
42
+ return '0.0.0';
43
+ }
44
+ }
45
+
46
+ let sdkPromise = null;
47
+
48
+ function loadSdk() {
49
+ if (!sdkPromise) sdkPromise = import('@agentclientprotocol/sdk');
50
+ return sdkPromise;
51
+ }
52
+
53
+ function noop() {}
54
+
55
+ /**
56
+ * 起适配器并完成握手,返回一个会话句柄。
57
+ *
58
+ * 失败一律抛错,`message` 是要给用户看的人话(服务层把它变成一条 notice 块 + exit 帧)。
59
+ */
60
+ async function startAgent({ spec, cwd, env, onUpdate, onPermission, onStderr, onExit }) {
61
+ const sdk = await loadSdk();
62
+ const child = spawn(spec.command, spec.args, {
63
+ cwd,
64
+ env: env || childEnv(spec.command),
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ });
67
+
68
+ let exited = false;
69
+ child.stdin.on('error', noop);
70
+ child.stdout.on('error', noop);
71
+ child.stderr.on('error', noop);
72
+
73
+ let stderrTail = '';
74
+ if (onStderr) {
75
+ child.stderr.on('data', (chunk) => {
76
+ const text = String(chunk);
77
+ stderrTail = (stderrTail + text).slice(-2000);
78
+ onStderr(text);
79
+ });
80
+ }
81
+
82
+ child.on('error', (error) => {
83
+ if (exited) return;
84
+ exited = true;
85
+ onExit?.(error && error.message ? error.message : '适配器起不来');
86
+ });
87
+
88
+ const app = sdk.client({ name: 'dsh-mobile' });
89
+ app.onRequest(sdk.methods.client.session.requestPermission, async (ctx) => {
90
+ // 适配器等这一个 promise 决定「放不放行」,所以它必须**只有一条** resolve 路径:
91
+ if (!onPermission) return { outcome: { outcome: 'cancelled' } };
92
+ return onPermission(ctx.params);
93
+ });
94
+ app.onNotification(sdk.methods.client.session.update, (ctx) => {
95
+ try {
96
+ onUpdate?.(ctx.params && ctx.params.update);
97
+ } catch { /* 一条更新解析失败不该掐断整条流 */ }
98
+ });
99
+
100
+ const stream = sdk.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
101
+ const conn = app.connect(stream);
102
+
103
+ conn.closed.then(() => {
104
+ if (exited) return;
105
+ exited = true;
106
+ onExit?.(stderrTail.trim().split('\n').slice(-1)[0] || '适配器退出了');
107
+ }, noop);
108
+
109
+ /** 握手整体超时。 */
110
+ const deadline = new Promise((_resolve, reject) => {
111
+ setTimeout(() => {
112
+ reject(new Error('适配器启动超时,可能卡在下载或登录上'));
113
+ }, START_TIMEOUT_MS).unref?.();
114
+ });
115
+
116
+ let promptCapabilities = null;
117
+ const session = await Promise.race([
118
+ (async () => {
119
+ const initialized = await conn.agent.request(sdk.methods.agent.initialize, {
120
+ protocolVersion: sdk.PROTOCOL_VERSION,
121
+ // 不认领文件系统与终端能力:四家 CLI 都有自己的文件访问,我们替它读写反而要多做
122
+ // 一套实现,做漏了就是一条静默失败的路。
123
+ clientCapabilities: { terminal: false },
124
+ clientInfo: CLIENT_INFO,
125
+ });
126
+ // 这家认不认图片输入:握手时它自己说。不说就按「不支持」处理 —— 硬塞 image 块过去,
127
+ // 有的适配器会直接回一条参数错误,把整轮提问连正文一起废掉。
128
+ promptCapabilities = (initialized && initialized.agentCapabilities && initialized.agentCapabilities.promptCapabilities) || null;
129
+ const created = await conn.agent.request(sdk.methods.agent.session.new, {
130
+ cwd,
131
+ mcpServers: [],
132
+ });
133
+ return { sessionId: created.sessionId, created };
134
+ })(),
135
+ deadline,
136
+ ]);
137
+
138
+ function kill() {
139
+ try { conn.close(); } catch { /* 已经关了 */ }
140
+ try { child.kill('SIGTERM'); } catch { /* 已经退出 */ }
141
+ }
142
+
143
+ return {
144
+ child,
145
+ sessionId: session.sessionId,
146
+ created: session.created,
147
+ /**
148
+ * 会话创建时给出的可配置项(模型 / 思考档 / 权限模式…)与模式表。
149
+ *
150
+ * ACP 把它们定成「客户端画一组下拉、选中就回一条 set」,各家 CLI 的差别全被吃掉:
151
+ * opencode 把模型放在 `configOptions`,claude 把模式放在 `modes`,codex 两者都给。
152
+ * 所以手机上的「选模型」不需要为每家写一套,照着这两张表画就行。
153
+ */
154
+ configOptions: Array.isArray(session.created.configOptions) ? session.created.configOptions : [],
155
+ modes: session.created.modes ?? null,
156
+ /** 这家认不认图片输入(握手时它自己声明的 `promptCapabilities.image`)。 */
157
+ get supportsImages() { return Boolean(promptCapabilities && promptCapabilities.image); },
158
+ get alive() { return !exited; },
159
+ /**
160
+ * 发一轮提问;resolve 时这一轮结束(ACP 的 `session/prompt` 就是这么定的)。
161
+ *
162
+ * `images` 是已经存好的图片元数据(`{ attachmentId, mediaType }`):按 ACP 的 image 块
163
+ * 发过去 —— `{type:'image', data, mimeType}`,字节在这一次请求里内联(ACP 只有这一种
164
+ * 传法,没有「你自己去读这个路径」的入口,我们也没认领 fs 能力)。
165
+ */
166
+ prompt(text_, images) {
167
+ const blocks = [];
168
+ for (const image of Array.isArray(images) ? images : []) {
169
+ const stored = openImage(image && image.attachmentId);
170
+ if (!stored) continue;
171
+ try {
172
+ blocks.push({
173
+ type: 'image',
174
+ data: fs.readFileSync(stored.path).toString('base64'),
175
+ mimeType: stored.mediaType || image.mediaType || 'image/png',
176
+ });
177
+ } catch { /* 读不出来就只丢这一张,正文照发 */ }
178
+ }
179
+ if (text_) blocks.push({ type: 'text', text: text_ });
180
+ if (blocks.length === 0) blocks.push({ type: 'text', text: '' });
181
+ return conn.agent.request(sdk.methods.agent.session.prompt, {
182
+ sessionId: session.sessionId,
183
+ prompt: blocks,
184
+ });
185
+ },
186
+ cancel() {
187
+ return conn.agent.notify(sdk.methods.agent.session.cancel, { sessionId: session.sessionId });
188
+ },
189
+ /** 改一项配置(选模型 / 换思考档)。`configId` 就是 `configOptions[].id`。 */
190
+ setConfigOption(configId, value) {
191
+ return conn.agent.request(sdk.methods.agent.session.setConfigOption, {
192
+ sessionId: session.sessionId,
193
+ configId,
194
+ value,
195
+ });
196
+ },
197
+ /** 换模式(claude 的 Manual / Accept edits / Plan 那一组)。 */
198
+ setMode(modeId) {
199
+ return conn.agent.request(sdk.methods.agent.session.setMode, {
200
+ sessionId: session.sessionId,
201
+ modeId,
202
+ });
203
+ },
204
+ close() {
205
+ // 会话收尾是尽力而为:适配器不支持 `session/close` 就直接断进程。
206
+ try {
207
+ conn.agent.request(sdk.methods.agent.session.close, { sessionId: session.sessionId }).catch(noop);
208
+ } catch { /* 同上 */ }
209
+ kill();
210
+ setTimeout(kill, 1500).unref?.();
211
+ },
212
+ kill,
213
+ };
214
+ }
215
+
216
+ module.exports = { startAgent, loadSdk, START_TIMEOUT_MS };
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CLI 对话的适配器白名单 —— `/api/mobile/v1/cli/*` 能起什么进程的**全部**依据。
5
+ *
6
+ * 与远程终端那份白名单(`mobile/terminal.js` 的 `AGENTS`)是同一档边界,只是走的口子不同:
7
+ * 终端把 CLI 的 TUI 原样搬给手机,这里让 CLI 以 **ACP(Agent Client Protocol)** 的方式跑,
8
+ * 手机收到的是结构化的「消息 / 工具调用 / 审批请求」,于是对话页能像正常对话一样渲染。
9
+ *
10
+ * 四种 CLI 起法有两种:
11
+ *
12
+ * - `gemini --acp`、`opencode acp`:CLI **自己**就实现了 ACP 服务端,直接起。
13
+ * - codex / claude:CLI 本身没有 ACP,靠官方 npm 桥(`@agentclientprotocol/*`)。桥包自带
14
+ * 对应的 CLI 依赖、并复用本机登录态,所以本机装没装 `codex` / `claude` 不影响。
15
+ *
16
+ * **这张表是兜底,不是清单本身**:真正「能起哪些」来自官方 ACP 注册表(见 `cli/registry.js`),
17
+ * 注册表里走 npx 分发的都能起,新 CLI 上线不必我们发版。下面这四条是它的降级档 —— 离线、
18
+ * 注册表拉不到、盘上也没有缓存时,仍然有四家用,而不是一个空清单。
19
+ *
20
+ * 内置这四条**永远出现在清单里**(它们是本仓维护的那一档,桥包由 npx 按需拉);注册表补进来
21
+ * 的那些则要求「这台电脑上真的装了」——见 `installedPackages`。
22
+ *
23
+ * `cursor-agent` / `aider` 没有 ACP 实现,所以在这一侧根本不存在 —— 它们仍然只能从终端页进
24
+ * (那一份表在 `terminal.js`,两边各管各的能力面)。
25
+ */
26
+
27
+ const fs = require('node:fs');
28
+ const os = require('node:os');
29
+ const path = require('node:path');
30
+
31
+ const { findExecutable } = require('../terminal.js');
32
+
33
+ /**
34
+ * `transport` 只描述**怎么起**,不代表能力差别:
35
+ * - `native`:CLI 自带的 ACP 子命令,参数写在 `args`。
36
+ * - `npx` :官方桥包。本机已经全局装了同名可执行就直接用,否则 `npx -y <包名>`。
37
+ *
38
+ * `package` 是 npx 分发那一档的包名(注册表里可能带版本,原样收着:版本由注册表钉住)。
39
+ */
40
+ const AGENTS = [
41
+ {
42
+ id: 'codex',
43
+ label: 'Codex',
44
+ transport: 'npx',
45
+ package: '@agentclientprotocol/codex-acp',
46
+ bin: 'codex-acp',
47
+ },
48
+ {
49
+ id: 'claude',
50
+ label: 'Claude Code',
51
+ transport: 'npx',
52
+ package: '@agentclientprotocol/claude-agent-acp',
53
+ bin: 'claude-agent-acp',
54
+ },
55
+ {
56
+ id: 'gemini',
57
+ label: 'Gemini CLI',
58
+ transport: 'native',
59
+ bin: 'gemini',
60
+ args: ['--acp'],
61
+ },
62
+ {
63
+ id: 'opencode',
64
+ label: 'OpenCode',
65
+ transport: 'native',
66
+ bin: 'opencode',
67
+ args: ['acp'],
68
+ },
69
+ ];
70
+
71
+ function findAgent(id) {
72
+ return AGENTS.find((agent) => agent.id === id) || null;
73
+ }
74
+
75
+ /**
76
+ * 子进程的 PATH。
77
+ *
78
+ * 宿主可能是从 Finder / 应用图标起的,那种进程的 PATH 常常只有 `/usr/bin:/bin`,`npx` 的
79
+ * shebang 是 `#!/usr/bin/env node`,找不着 node 就直接退出。所以把「当前跑宿主的那个 node」
80
+ * 和「适配器所在目录」垫到最前面 —— 只补这两处,不去猜用户 shell 的 PATH。
81
+ */
82
+ function childEnv(command, env = process.env) {
83
+ const key = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path';
84
+ const base = env[key] || '';
85
+ const seen = new Set();
86
+ const merged = [path.dirname(process.execPath), path.dirname(command), ...base.split(path.delimiter)]
87
+ .filter((dir) => {
88
+ if (!dir || seen.has(dir)) return false;
89
+ seen.add(dir);
90
+ return true;
91
+ });
92
+ return { ...env, [key]: merged.join(path.delimiter) };
93
+ }
94
+
95
+ /** 去掉 `@scope/name@1.2.3` 尾部的版本,得到 npm 认的包名。 */
96
+ function barePackage(pkg) {
97
+ const name = String(pkg || '').trim();
98
+ if (!name) return '';
99
+ // `@scope/name@1.2.3` / `name@1.2.3`:最后一个 `@` 在首位之后才是版本分隔。
100
+ const at = name.lastIndexOf('@');
101
+ return at > 0 ? name.slice(0, at) : name;
102
+ }
103
+
104
+ /** 一个 `node_modules` 目录下有没有这个包(两段式名字按第一段进一层)。 */
105
+ function hasPackageIn(dir, name) {
106
+ try {
107
+ const stat = fs.statSync(path.join(dir, name));
108
+ return stat.isDirectory();
109
+ } catch {
110
+ try {
111
+ return fs.statSync(path.join(dir, name, 'package.json')).isFile();
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * 这台电脑上已经装了的 npm 包(只认名字,一份 Set)。
120
+ *
121
+ * 为什么要它:注册表里有二十来家能 npx 拉起的 ACP 适配器,全列出来等于给用户看一屏他从没
122
+ * 装过的东西 —— 点下去要么下载几十秒,要么卡在登录上。手机上要的是「这台电脑现在能聊哪些」。
123
+ *
124
+ * 三个来源,全是读目录(不 spawn npm,列一次清单不该等一个子进程):
125
+ *
126
+ * 1. 跑宿主的那个 node 旁边的 `lib/node_modules`(Homebrew / 官方安装包的路子);
127
+ * 2. `NPM_CONFIG_PREFIX` 指的全局根(用户自己配过的);
128
+ * 3. `~/.npm/_npx` 下每一份 `node_modules`(npx 拉过一次就留在那儿,桥包通常就在这一档)。
129
+ *
130
+ * 探不到就返回空集 —— 那种情况下注册表那一条当「没装」处理,内置四条照旧在。
131
+ */
132
+ function installedPackages(env = process.env) {
133
+ const roots = [];
134
+ if (env.NPM_CONFIG_PREFIX) roots.push(path.join(env.NPM_CONFIG_PREFIX, 'lib', 'node_modules'));
135
+ if (env.NPM_GLOBAL_ROOT) roots.push(env.NPM_GLOBAL_ROOT);
136
+ roots.push(path.join(path.dirname(process.execPath), '..', 'lib', 'node_modules'));
137
+ roots.push(path.join(os.homedir(), '.npm-global', 'lib', 'node_modules'));
138
+ const npxCache = path.join(os.homedir(), '.npm', '_npx');
139
+ try {
140
+ for (const entry of fs.readdirSync(npxCache)) {
141
+ roots.push(path.join(npxCache, entry, 'node_modules'));
142
+ }
143
+ } catch {
144
+ // 没有 npx 缓存目录就是没走过 npx:不影响别的来源。
145
+ }
146
+
147
+ const names = new Set();
148
+ for (const dir of roots) {
149
+ let entries = [];
150
+ try { entries = fs.readdirSync(dir); } catch { continue; }
151
+ for (const entry of entries) {
152
+ if (entry.startsWith('.')) continue;
153
+ if (entry.startsWith('@')) {
154
+ let scoped = [];
155
+ try { scoped = fs.readdirSync(path.join(dir, entry)); } catch { continue; }
156
+ for (const child of scoped) names.add(`${entry}/${child}`);
157
+ continue;
158
+ }
159
+ names.add(entry);
160
+ }
161
+ }
162
+ return names;
163
+ }
164
+
165
+ /** 这个包在这台电脑上装了没有(`bin` 在 PATH 里也算装了 —— 那是同一件事的另一半)。 */
166
+ function isInstalled(agent, installed, env = process.env) {
167
+ if (!agent) return false;
168
+ if (agent.bin && findExecutable(agent.bin, env)) return true;
169
+ const name = barePackage(agent.package);
170
+ return Boolean(name && installed && installed.has(name));
171
+ }
172
+
173
+ /**
174
+ * 这台电脑上能不能起这个 CLI。
175
+ *
176
+ * 回 `{ ok: true, command, args, via }` 或 `{ ok: false, reason }`;`reason` 直接给界面用,
177
+ * 所以是一句人话,不是错误码。
178
+ *
179
+ * `ok` 说的是「现在点得下去、起得来」:`npx` 那一档只要本机有 npx 就成立(首次会下载桥包,
180
+ * 之后走缓存)。要不要把它列进选择卡是另一件事,由 `isInstalled` 判(见 `service.listAgents`)。
181
+ *
182
+ * `npx` 那一档的参数要跟着一起给:注册表里不少包本身不是 ACP 服务端,得靠自己的子命令进
183
+ * ACP 模式(`cline --acp`、`bob acp`、`droid exec --output-format acp-daemon`)。只给包名
184
+ * 的话进程起来就是一个普通 CLI,握手永远等不到 —— 表现是「一直在启动」。
185
+ */
186
+ function resolveAgent(agent, env = process.env) {
187
+ if (!agent) return { ok: false, reason: '不认识这个 CLI' };
188
+ try {
189
+ const extra = Array.isArray(agent.args) ? agent.args.filter((arg) => typeof arg === 'string') : [];
190
+ if (agent.transport === 'native') {
191
+ const bin = findExecutable(agent.bin, env);
192
+ if (!bin) return { ok: false, reason: `这台电脑上没有装 ${agent.label}`, via: 'native' };
193
+ return { ok: true, command: bin, args: extra, via: 'native' };
194
+ }
195
+ // 桥包已经全局装过就省掉一次 npx 解析;没有就交给 npx 拉(首次会下载,之后走缓存)。
196
+ const installedBin = agent.bin ? findExecutable(agent.bin, env) : null;
197
+ if (installedBin) return { ok: true, command: installedBin, args: extra, via: 'installed' };
198
+ const npx = findExecutable('npx', env) || findExecutable('npm', env);
199
+ if (!npx) return { ok: false, reason: `这台电脑上没有 npx,起不了 ${agent.label}` };
200
+ const args = path.basename(npx) === 'npx'
201
+ ? ['-y', agent.package, ...extra]
202
+ : ['exec', '-y', '--', agent.package, ...extra];
203
+ return { ok: true, command: npx, args, via: 'npx' };
204
+ } catch {
205
+ return { ok: false, reason: `探测 ${agent.label} 的时候出错了` };
206
+ }
207
+ }
208
+
209
+ module.exports = {
210
+ AGENTS,
211
+ findAgent,
212
+ resolveAgent,
213
+ childEnv,
214
+ installedPackages,
215
+ isInstalled,
216
+ barePackage,
217
+ };