dsh-mobile-plugin 0.5.17 → 0.5.19
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/config/agent-presets/standard/agent.cordis.yml +9 -0
- package/host.js +26 -4
- package/lib/host-compat.cjs +51 -4
- package/lib/impl.js +6 -0
- package/lib/mobile/cli/acp.js +216 -0
- package/lib/mobile/cli/agents.js +217 -0
- package/lib/mobile/cli/blocks.js +228 -0
- package/lib/mobile/cli/config-prefs.js +155 -0
- package/lib/mobile/cli/icons.js +138 -0
- package/lib/mobile/cli/images.js +132 -0
- package/lib/mobile/cli/mobile.js +1552 -0
- package/lib/mobile/cli/model-prefs.js +77 -0
- package/lib/mobile/cli/model-visibility.js +92 -0
- package/lib/mobile/cli/registry.js +207 -0
- package/lib/mobile/cli/service.js +901 -0
- package/lib/mobile/cli/usage.js +133 -0
- package/lib/mobile/monitor.js +17 -6
- package/lib/mobile/narration.js +205 -0
- package/lib/mobile/phone/binding.js +247 -0
- package/lib/mobile/phone/index.js +63 -0
- package/lib/mobile/phone/routes.js +168 -0
- package/lib/mobile/phone/service.js +327 -0
- package/lib/mobile/phone/tools.js +565 -0
- package/lib/mobile/questions.js +9 -3
- package/lib/mobile/services.js +16 -0
- package/lib/mobile/session-send.js +1 -0
- package/lib/mobile/stats-ledger.cjs +8 -23
- package/lib/mobile/terminal.js +574 -0
- package/lib/routes/mobile-api.js +139 -3
- package/package.json +2 -1
|
@@ -110,14 +110,23 @@
|
|
|
110
110
|
config:
|
|
111
111
|
- id: tool-subagent-control
|
|
112
112
|
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
113
|
+
# 独立的 `list_agents` 入口只在 0.2.1 及更早存在;0.2.2 把它并进了主包,
|
|
114
|
+
# 子路径没了。这一行保留是为了老运行时,启动时宿主会按「这一代认不认这个
|
|
115
|
+
# 子路径」把副本里的这一行整行拿掉(见 lib/host-compat.cjs 的 dropRows)。
|
|
113
116
|
- id: tool-subagent-list-agents
|
|
114
117
|
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
|
118
|
+
# 子代理挑模型的开关,两代运行时键名不同:0.2.1 及更早读
|
|
119
|
+
# `modelSelectionSettings`,0.2.2 起改成 `modelSelection`(另加
|
|
120
|
+
# `modelDiscovery` 才让模型查得到可选模型)。两个键都写:各自的 schema 只认
|
|
121
|
+
# 自己那个、另一个原样忽略,所以同一份预设挂到哪一代都对。
|
|
115
122
|
- id: tool-subagent
|
|
116
123
|
name: '@deepseek-ai/dsh-tool-subagent'
|
|
117
124
|
config:
|
|
118
125
|
provider: spawn
|
|
119
126
|
toolName: subagent
|
|
120
127
|
modelSelectionSettings: true
|
|
128
|
+
modelSelection: true
|
|
129
|
+
modelDiscovery: true
|
|
121
130
|
backgroundMode: continuable
|
|
122
131
|
- id: tool-subagent-fork
|
|
123
132
|
name: '@deepseek-ai/dsh-tool-subagent'
|
package/host.js
CHANGED
|
@@ -20,7 +20,7 @@ import { installProcessGuard } from './lib/host-guard.cjs'
|
|
|
20
20
|
import { DIRECTORY_PICKER_ROW, TOOL_ROWS, pinInsertedRows, syncPresetDir } from './lib/host-compat.cjs'
|
|
21
21
|
import { dropBareNameSelfMounts } from './lib/host-patches.cjs'
|
|
22
22
|
import {
|
|
23
|
-
assertEntriesActivated,
|
|
23
|
+
auditStartupEntries as assertEntriesActivated,
|
|
24
24
|
boot,
|
|
25
25
|
evaluatePluginCompatibility,
|
|
26
26
|
getDshRuntimeVersion,
|
|
@@ -29,11 +29,19 @@ import {
|
|
|
29
29
|
loadLayeredEnv,
|
|
30
30
|
loadOverlayPatches,
|
|
31
31
|
loadProfile,
|
|
32
|
-
PROFILE_TEMPLATES,
|
|
33
32
|
readProfileCompatibility,
|
|
34
33
|
resolveProfileDir,
|
|
35
34
|
} from '@deepseek-ai/dsh-app-boot'
|
|
36
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 新建 profile 时的初始组合层。
|
|
38
|
+
*
|
|
39
|
+
* 不从 `dsh-app-boot` 取 `PROFILE_TEMPLATES`:0.2.2 把这份清单挪进了
|
|
40
|
+
* `@deepseek-ai/dsh-official-bundles`,旧位置不再导出,而静态导入一个不存在的
|
|
41
|
+
* 命名导出是让整个宿主起不来(不是降级)。这里本来就只要 web 那一档的两行。
|
|
42
|
+
*/
|
|
43
|
+
const WEB_PROFILE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
|
|
44
|
+
|
|
37
45
|
const BIN_NAME = 'dsh-mobile-plugin'
|
|
38
46
|
const PROFILE_NAME = process.env.DSH_MOBILE_PROFILE || 'mobile'
|
|
39
47
|
const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url))
|
|
@@ -58,7 +66,7 @@ try {
|
|
|
58
66
|
const home = process.env.DSH_HOME
|
|
59
67
|
const profileDir = resolveProfileDir(PROFILE_NAME, home)
|
|
60
68
|
// Idempotent: an already-initialized profile keeps its own patch layer.
|
|
61
|
-
initProfile(profileDir,
|
|
69
|
+
initProfile(profileDir, WEB_PROFILE_BUNDLES)
|
|
62
70
|
const profile = loadProfile(BIN_NAME, PROFILE_NAME, INSTALL_ANCHOR, home, { userLayer: true })
|
|
63
71
|
|
|
64
72
|
/**
|
|
@@ -142,6 +150,19 @@ try {
|
|
|
142
150
|
)
|
|
143
151
|
}
|
|
144
152
|
|
|
153
|
+
// 预设里「列子代理」那一行挂的是 `dsh-tool-subagent-control` 的一个子入口。
|
|
154
|
+
// 0.2.2 把那个工具并进了主包,子路径没有了 —— 留着它,每建一个会话都会有一行
|
|
155
|
+
// 起不来,而能力本身还在。判据是**这一代运行时认不认这个子路径**,不按版本号猜。
|
|
156
|
+
const listAgentsEntry = '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
|
157
|
+
let listAgentsRowGone = false
|
|
158
|
+
try {
|
|
159
|
+
runtimeRequire?.resolve(listAgentsEntry)
|
|
160
|
+
} catch {
|
|
161
|
+
listAgentsRowGone = true
|
|
162
|
+
process.stderr.write(`${BIN_NAME}: 这一代运行时没有 ${listAgentsEntry}(该工具已并在主包里),预设副本里那一行不再挂\n`)
|
|
163
|
+
}
|
|
164
|
+
const dropPresetIds = listAgentsRowGone ? ['tool-subagent-list-agents'] : []
|
|
165
|
+
|
|
145
166
|
// 预设里的两行默认按裸名写:Loader 从它自己的位置解析,命中哪一份不由我们决定。
|
|
146
167
|
// 解析到了同代的那份就改写一份副本给它,路径写死成那一份的入口。
|
|
147
168
|
const presetRoots = {}
|
|
@@ -149,11 +170,12 @@ try {
|
|
|
149
170
|
const hit = pinnedRows.get(id)
|
|
150
171
|
if (hit) presetRoots[id] = hit.entry
|
|
151
172
|
}
|
|
152
|
-
const presetDir = TOOL_ROWS.some(({ id }) => presetRoots[id] !== undefined)
|
|
173
|
+
const presetDir = TOOL_ROWS.some(({ id }) => presetRoots[id] !== undefined) || dropPresetIds.length > 0
|
|
153
174
|
? syncPresetDir(
|
|
154
175
|
join(PACKAGE_DIR, 'config', 'agent-presets'),
|
|
155
176
|
join(profileDir, '.mobile-agent-presets'),
|
|
156
177
|
presetRoots,
|
|
178
|
+
{ dropIds: dropPresetIds },
|
|
157
179
|
)
|
|
158
180
|
: join(PACKAGE_DIR, 'config', 'agent-presets')
|
|
159
181
|
|
package/lib/host-compat.cjs
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* 所以启动时先问运行时那棵树要同代的那一份,拿到就把行的 `name` 换成它的入口绝对路径,
|
|
9
9
|
* 绕开按裸名解析;拿不到就保持原样,让官方检查去禁用并打印原因。
|
|
10
10
|
*
|
|
11
|
+
* 还有另一种形状:入口本身没了(0.2.2 把 `list_agents` 并进主包,子路径随之下线)。
|
|
12
|
+
* 那一条换成哪一份都没用,只能整行从预设副本里拿掉 —— 见 `dropRows`。
|
|
13
|
+
*
|
|
11
14
|
* 这里只放纯函数:`host.js` 一 import 就 `boot()` 整个宿主,测试碰不得。
|
|
12
15
|
*/
|
|
13
16
|
|
|
@@ -76,21 +79,64 @@ function rewriteToolNames(source, targets) {
|
|
|
76
79
|
* @param {string} presetDir 包内 `config/agent-presets`
|
|
77
80
|
* @param {string} outDir 宿主可写的目标目录
|
|
78
81
|
* @param {Record<string, string>} targets 行 id → 入口绝对路径
|
|
82
|
+
* @param {{ dropIds?: string[] }} [options] 这一代运行时里不再有独立入口的行
|
|
79
83
|
* @returns {string} 真正该交给 `agent-presets` roots 的目录
|
|
80
84
|
*/
|
|
81
|
-
function syncPresetDir(presetDir, outDir, targets) {
|
|
85
|
+
function syncPresetDir(presetDir, outDir, targets, options = {}) {
|
|
82
86
|
fs.rmSync(outDir, { recursive: true, force: true })
|
|
83
87
|
fs.cpSync(presetDir, outDir, { recursive: true })
|
|
84
|
-
|
|
88
|
+
const dropIds = options.dropIds ?? []
|
|
89
|
+
if (Object.keys(targets).length === 0 && dropIds.length === 0) return outDir
|
|
85
90
|
for (const entry of fs.readdirSync(outDir, { recursive: true, withFileTypes: true })) {
|
|
86
91
|
if (!entry.isFile() || !entry.name.endsWith('.yml')) continue
|
|
87
92
|
const file = path.join(entry.parentPath ?? entry.path, entry.name)
|
|
88
|
-
const
|
|
89
|
-
|
|
93
|
+
const original = fs.readFileSync(file, 'utf8')
|
|
94
|
+
const trimmed = dropRows(original, dropIds)
|
|
95
|
+
const { text, changed } = rewriteToolNames(trimmed, targets)
|
|
96
|
+
if (changed.length > 0 || trimmed !== original) fs.writeFileSync(file, text)
|
|
90
97
|
}
|
|
91
98
|
return outDir
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/**
|
|
102
|
+
* 去掉预设里指定的整行(含它下面缩进更深的配置)。
|
|
103
|
+
*
|
|
104
|
+
* 给「这一代运行时里已经没有这个独立入口」的行用:留着它,Loader 会按裸名去 import
|
|
105
|
+
* 一个不存在的子路径,那一行起不来;能力本身可能已经并进主包,所以不是缺工具,
|
|
106
|
+
* 只是多了一条注定失败的行。
|
|
107
|
+
*
|
|
108
|
+
* @param {string} source 预设文件内容
|
|
109
|
+
* @param {string[]} ids 要整行删掉的入口 id
|
|
110
|
+
* @returns {string} 删完的文本(没命中就原样返回)
|
|
111
|
+
*/
|
|
112
|
+
function dropRows(source, ids) {
|
|
113
|
+
if (ids.length === 0) return source
|
|
114
|
+
const targets = new Set(ids)
|
|
115
|
+
const lines = source.split('\n')
|
|
116
|
+
const indentOf = (line) => line.length - line.trimStart().length
|
|
117
|
+
const kept = []
|
|
118
|
+
let dropping = false
|
|
119
|
+
let dropIndent = 0
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
const id = ROW_ID.exec(line)
|
|
122
|
+
if (dropping) {
|
|
123
|
+
// 同级的下一行或更浅的一行就是这条的边界;更深的仍是它的配置。
|
|
124
|
+
if (id || (line.trim() !== '' && indentOf(line) <= dropIndent)) dropping = false
|
|
125
|
+
}
|
|
126
|
+
if (id && targets.has(id[2])) {
|
|
127
|
+
dropIndent = indentOf(line)
|
|
128
|
+
// 紧挨着这一行上方的注释块是在说它,跟着一起删;留下会说反话的说明。
|
|
129
|
+
while (kept.length > 0 && kept[kept.length - 1].trim().startsWith('#') && indentOf(kept[kept.length - 1]) >= dropIndent) {
|
|
130
|
+
kept.pop()
|
|
131
|
+
}
|
|
132
|
+
dropping = true
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
if (!dropping) kept.push(line)
|
|
136
|
+
}
|
|
137
|
+
return kept.join('\n')
|
|
138
|
+
}
|
|
139
|
+
|
|
94
140
|
/**
|
|
95
141
|
* 把 patch 层里命中目标的 `name` 换成入口绝对路径。
|
|
96
142
|
*
|
|
@@ -115,6 +161,7 @@ module.exports = {
|
|
|
115
161
|
TOOL_ROWS,
|
|
116
162
|
DIRECTORY_PICKER_ROW,
|
|
117
163
|
rewriteToolNames,
|
|
164
|
+
dropRows,
|
|
118
165
|
syncPresetDir,
|
|
119
166
|
pinInsertedRows,
|
|
120
167
|
}
|
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
|
+
};
|