dsh-mobile-plugin 0.5.20 → 0.5.22
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 +3 -12
- package/assets/pets/README.md +9 -0
- package/assets/pets/deepy-whale/attention.gif +0 -0
- package/assets/pets/deepy-whale/done.gif +0 -0
- package/assets/pets/deepy-whale/error.gif +0 -0
- package/assets/pets/deepy-whale/idle.gif +0 -0
- package/assets/pets/deepy-whale/pack.json +54 -0
- package/assets/pets/deepy-whale/sleeping.gif +0 -0
- package/assets/pets/deepy-whale/working-3.gif +0 -0
- package/assets/pets/deepy-whale/working.gif +0 -0
- package/extensions/README.md +64 -0
- package/extensions/catalog.json +1 -0
- package/lib/client.js +318 -27
- package/lib/impl.js +21 -1
- package/lib/mobile/approvals.js +22 -2
- package/lib/mobile/cli/acp.js +34 -20
- package/lib/mobile/cli/mobile.js +8 -1
- package/lib/mobile/cli/service.js +151 -10
- package/lib/mobile/extension-catalog.js +244 -0
- package/lib/mobile/local-images.js +10 -5
- package/lib/mobile/monitor.js +5 -2
- package/lib/mobile/pet-tools.js +215 -0
- package/lib/mobile/pets.js +559 -0
- package/lib/mobile/session-flags.js +61 -8
- package/lib/mobile/session-local-image.js +9 -1
- package/lib/mobile/stats-collect.cjs +15 -7
- package/lib/mobile/stats-ledger.cjs +44 -5
- package/lib/mobile/wechat-tools.js +257 -0
- package/lib/mobile/wechat.js +489 -0
- package/lib/panel/index.html +12 -11
- package/lib/relay/pairing-payload.cjs +13 -5
- package/lib/relay/snapshot-auth.js +82 -0
- package/lib/relay/status.js +3 -0
- package/lib/routes/control-plane.js +74 -3
- package/lib/routes/mobile-api.js +54 -1
- package/lib/session-cleanup.cjs +22 -7
- package/lib/sidecar-facade-worker.cjs +8 -0
- package/lib/tailcat/host-process.cjs +266 -0
- package/lib/tailcat/ingress.cjs +98 -0
- package/lib/tailcat/session.cjs +264 -0
- package/lib/tailcat/wiring.cjs +96 -0
- package/package.json +10 -1
package/lib/mobile/cli/acp.js
CHANGED
|
@@ -114,32 +114,46 @@ async function startAgent({ spec, cwd, env, onUpdate, onPermission, onStderr, on
|
|
|
114
114
|
});
|
|
115
115
|
|
|
116
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
117
|
|
|
118
|
+
/**
|
|
119
|
+
* 收掉这条连接与它的子进程。
|
|
120
|
+
*
|
|
121
|
+
* 必须定义在握手**之前**:握手失败或超时时这里直接抛错,而那时子进程已经起来了 —— 不在这
|
|
122
|
+
* 条路径上关掉,它就成了一个没人管的孤儿进程(卡在登录界面、还在等输入)。探配置那条路会
|
|
123
|
+
* 反复进来,漏一次就是攒一个进程。
|
|
124
|
+
*/
|
|
138
125
|
function kill() {
|
|
139
126
|
try { conn.close(); } catch { /* 已经关了 */ }
|
|
140
127
|
try { child.kill('SIGTERM'); } catch { /* 已经退出 */ }
|
|
141
128
|
}
|
|
142
129
|
|
|
130
|
+
let session;
|
|
131
|
+
try {
|
|
132
|
+
session = await Promise.race([
|
|
133
|
+
(async () => {
|
|
134
|
+
const initialized = await conn.agent.request(sdk.methods.agent.initialize, {
|
|
135
|
+
protocolVersion: sdk.PROTOCOL_VERSION,
|
|
136
|
+
// 不认领文件系统与终端能力:四家 CLI 都有自己的文件访问,我们替它读写反而要多做
|
|
137
|
+
// 一套实现,做漏了就是一条静默失败的路。
|
|
138
|
+
clientCapabilities: { terminal: false },
|
|
139
|
+
clientInfo: CLIENT_INFO,
|
|
140
|
+
});
|
|
141
|
+
// 这家认不认图片输入:握手时它自己说。不说就按「不支持」处理 —— 硬塞 image 块过去,
|
|
142
|
+
// 有的适配器会直接回一条参数错误,把整轮提问连正文一起废掉。
|
|
143
|
+
promptCapabilities = (initialized && initialized.agentCapabilities && initialized.agentCapabilities.promptCapabilities) || null;
|
|
144
|
+
const created = await conn.agent.request(sdk.methods.agent.session.new, {
|
|
145
|
+
cwd,
|
|
146
|
+
mcpServers: [],
|
|
147
|
+
});
|
|
148
|
+
return { sessionId: created.sessionId, created };
|
|
149
|
+
})(),
|
|
150
|
+
deadline,
|
|
151
|
+
]);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
kill();
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
|
|
143
157
|
return {
|
|
144
158
|
child,
|
|
145
159
|
sessionId: session.sessionId,
|
package/lib/mobile/cli/mobile.js
CHANGED
|
@@ -46,6 +46,7 @@ const { createFrameSealer } = require('../stream.js')
|
|
|
46
46
|
const { imageBlockOf, metaOf, openImage, saveImage, MAX_IMAGE_BYTES, MEDIA_EXT } = require('./images.js')
|
|
47
47
|
const { resolveFinishedUpload } = require('../uploads.js')
|
|
48
48
|
const { rememberModel } = require('./model-prefs.js')
|
|
49
|
+
const { setValue } = require('./config-prefs.js')
|
|
49
50
|
const { statsOf } = require('./usage.js')
|
|
50
51
|
const { hiddenOf, setVisible } = require('./model-visibility.js')
|
|
51
52
|
const { lookupModelMeta } = require('../model-meta.js')
|
|
@@ -1197,7 +1198,12 @@ function createCliMobile(deps) {
|
|
|
1197
1198
|
const effort = textOf(body.reasoningEffort)
|
|
1198
1199
|
if (effort) {
|
|
1199
1200
|
const effortOption = thoughtOptionOf(options)
|
|
1200
|
-
if (effortOption)
|
|
1201
|
+
if (effortOption) {
|
|
1202
|
+
await session.handle.setConfigOption(effortOption.id, effort)
|
|
1203
|
+
// 在对话里调过的档位也变成「以后新开的默认」—— 与配置页写的是同一本账
|
|
1204
|
+
// (`config-prefs`),两条路不分家(见 A3-80)。
|
|
1205
|
+
setValue(session.agentId, effortOption.id, effort)
|
|
1206
|
+
}
|
|
1201
1207
|
}
|
|
1202
1208
|
} catch { /* 选不动就维持原样:下一次读 models 会回真实值 */ }
|
|
1203
1209
|
}
|
|
@@ -1213,6 +1219,7 @@ function createCliMobile(deps) {
|
|
|
1213
1219
|
if (updated && Array.isArray(updated.configOptions)) session.configOptions = updated.configOptions
|
|
1214
1220
|
const option = (session.configOptions || []).find((item) => item && item.id === optionId)
|
|
1215
1221
|
if (option && option.category === 'model') rememberModel(session.agentId, optionId, textOf(body.value))
|
|
1222
|
+
else if (option) setValue(session.agentId, optionId, textOf(body.value))
|
|
1216
1223
|
} catch { /* 同上 */ }
|
|
1217
1224
|
}
|
|
1218
1225
|
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* 带全量块重放,页面回到原样。
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
const { homedir } = require('node:os');
|
|
22
23
|
const { WebSocketServer } = require('ws');
|
|
23
24
|
|
|
24
25
|
const { readJsonBody, sendJson } = require('../../shared/http-util');
|
|
@@ -55,6 +56,14 @@ const FLUSH_MS = 60;
|
|
|
55
56
|
/** 标题兜底截断长度(宿主没给 `session_info_update.title` 时用首条提问)。 */
|
|
56
57
|
const TITLE_MAX = 40;
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* 「探一次配置」的失败重试间隔。
|
|
61
|
+
*
|
|
62
|
+
* 探一次要起一个适配器(首次还可能要 npx 下载),不能每次进配置页都重来一遍;但也不该把
|
|
63
|
+
* 一次失败记一辈子 —— 用户多半是去装了 CLI / 登录了才回来的。
|
|
64
|
+
*/
|
|
65
|
+
const PROBE_RETRY_MS = 60 * 1000;
|
|
66
|
+
|
|
58
67
|
class CliRequestError extends Error {
|
|
59
68
|
constructor(code, message) {
|
|
60
69
|
super(message);
|
|
@@ -93,6 +102,8 @@ function pendingPrefsOf(session) {
|
|
|
93
102
|
for (const option of options) {
|
|
94
103
|
if (option && option.category === 'model') skip.add(option.id);
|
|
95
104
|
}
|
|
105
|
+
// 模型那一项记在 `model-prefs` 那本账里(`applyPreferredModel` 负责),这里一律跳过 ——
|
|
106
|
+
// 包括 id 不叫 `model` 的那些(claude 的 `agent` 子代理、opencode 的分组模型)。
|
|
96
107
|
const out = [];
|
|
97
108
|
for (const [configId, value] of Object.entries(recorded)) {
|
|
98
109
|
if (skip.has(configId)) continue;
|
|
@@ -112,6 +123,38 @@ async function bodyJson(req) {
|
|
|
112
123
|
return body;
|
|
113
124
|
}
|
|
114
125
|
|
|
126
|
+
/**
|
|
127
|
+
* 这一项是不是「模型」。
|
|
128
|
+
*
|
|
129
|
+
* 判据是 ACP 给的 `category`,不是 id 的字面值:codex/opencode 叫 `model`,claude 的模型
|
|
130
|
+
* 项也叫 `model`,但换一家可能就是别的名字 —— 按 id 猜就会漏掉一家,而漏掉的表现正是
|
|
131
|
+
* 「配置页勾上了、开新对话还是原来的模型」。认不出(配置页还没拿到选项表)就按 id 兜底。
|
|
132
|
+
*/
|
|
133
|
+
function isModelOption(options, configId) {
|
|
134
|
+
const list = Array.isArray(options) ? options : [];
|
|
135
|
+
const option = list.find((item) => item && item.id === configId);
|
|
136
|
+
if (option) return option.category === 'model';
|
|
137
|
+
return configId === 'model';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 配置页要画的「记着的那一套」。
|
|
142
|
+
*
|
|
143
|
+
* 两本账合到一处:模型记在 `model-prefs`(新会话开局由 `applyPreferredModel` 套),其余记在
|
|
144
|
+
* `config-prefs`。配置页只认一张表,所以在这里并起来 —— 不并的话,配置页里模型那一格永远
|
|
145
|
+
* 显示「跟 CLI 自己的一样」,用户选过的模型看不见。
|
|
146
|
+
*/
|
|
147
|
+
function valuesBodyOf(agentId, options) {
|
|
148
|
+
const values = { ...prefsOf(agentId).values };
|
|
149
|
+
const preferred = preferredModel(agentId);
|
|
150
|
+
if (preferred) values[preferred.configId] = preferred.value;
|
|
151
|
+
// 两本账里都不该有「模型」,但真出现过(旧版本写进去的)就别让它冒充生效值。
|
|
152
|
+
for (const key of Object.keys(values)) {
|
|
153
|
+
if (isModelOption(options, key) && !(preferred && preferred.configId === key)) delete values[key];
|
|
154
|
+
}
|
|
155
|
+
return values;
|
|
156
|
+
}
|
|
157
|
+
|
|
115
158
|
/** 会话表与它的所有出口。一个宿主进程一份(装配层建、装配层关)。 */
|
|
116
159
|
function createCliService({ authorized, onLog } = {}) {
|
|
117
160
|
/** sessionId → 会话。 */
|
|
@@ -142,6 +185,72 @@ function createCliService({ authorized, onLog } = {}) {
|
|
|
142
185
|
return agentsPromise;
|
|
143
186
|
}
|
|
144
187
|
|
|
188
|
+
/** agentId → `{ running, at, error, done }`:这一家探配置的状态(同时只允许一次)。 */
|
|
189
|
+
const probes = new Map();
|
|
190
|
+
|
|
191
|
+
function probeState(agentId) {
|
|
192
|
+
return probes.get(agentId) || null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 现去问一次这个 CLI 有哪些可调项。
|
|
197
|
+
*
|
|
198
|
+
* 为什么要这一步:选项表是 CLI 自己在握手之后给的,缓存里只有「开过对话的那几家」。用户新
|
|
199
|
+
* 装一个 CLI、想先把权限档配好再去用,正是最需要配置页的时候,而那时缓存是空的 —— 配置页
|
|
200
|
+
* 就是一句「没有可调的项」,四个配置入口点下去长得一模一样。
|
|
201
|
+
*
|
|
202
|
+
* 代价是起一次适配器(首次还要 npx 下载),所以:同一个 CLI 同时只探一次;探到手就落缓存,
|
|
203
|
+
* 之后的配置页直接读;失败记一段时间(`PROBE_RETRY_MS`),别让用户每次进来都等一轮下载。
|
|
204
|
+
* 它不进 `sessions` 那张表 —— 这是一次性的探测进程,拿到表就关,侧栏里不该多出一行。
|
|
205
|
+
*/
|
|
206
|
+
function startProbe(ctx, agentId) {
|
|
207
|
+
if (probes.has(agentId) && probes.get(agentId).running) return;
|
|
208
|
+
const entry = { running: true, at: Date.now(), error: '', done: false };
|
|
209
|
+
probes.set(agentId, entry);
|
|
210
|
+
void (async () => {
|
|
211
|
+
let handle = null;
|
|
212
|
+
try {
|
|
213
|
+
const agent = (await listAgents()).find((item) => item && item.id === agentId) || null;
|
|
214
|
+
if (!agent) throw new Error('这台电脑上没有这个 CLI');
|
|
215
|
+
const spec = resolveAgent(agent);
|
|
216
|
+
if (!spec.ok) throw new Error(spec.reason || '这个 CLI 起不来');
|
|
217
|
+
// 起在哪:配置项与目录无关,随便借一个已注册工作区(没有工作区就借家目录 —— 有些适配器
|
|
218
|
+
// 对 cwd 有要求,给个存在且可写的路径总比空串安全)。
|
|
219
|
+
const cwd = await pickProbeCwd(ctx);
|
|
220
|
+
handle = await startAgent({ spec, cwd, env: childEnv(spec.command) });
|
|
221
|
+
if (closed) return;
|
|
222
|
+
// 探到了就记下来(这家确实一项都不给时记的是空表,配置页据此说「没有可调的项」
|
|
223
|
+
// 而不是一直转圈)。
|
|
224
|
+
rememberOptions(agentId, handle.configOptions);
|
|
225
|
+
entry.error = '';
|
|
226
|
+
// 这一家**问过了**:哪怕它一项配置都不给,也不许再探第二次 —— 那等于每进一次配置页
|
|
227
|
+
// 就起一个适配器进程,而结果永远是同一张空表。
|
|
228
|
+
entry.done = true;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
entry.error = error && error.message ? error.message : '问不到这个 CLI 的配置';
|
|
231
|
+
log(`cli 探配置失败(${agentId}):${entry.error}`);
|
|
232
|
+
} finally {
|
|
233
|
+
entry.running = false;
|
|
234
|
+
entry.at = Date.now();
|
|
235
|
+
try { handle?.close(); } catch { /* 已经退了 */ }
|
|
236
|
+
}
|
|
237
|
+
})();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 探测进程的工作目录:优先借一个已注册工作区,取不到就用家目录。 */
|
|
241
|
+
async function pickProbeCwd(ctx) {
|
|
242
|
+
try {
|
|
243
|
+
const listed = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
|
|
244
|
+
const { value } = unwrap(listed);
|
|
245
|
+
const items = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : []);
|
|
246
|
+
for (const item of items) {
|
|
247
|
+
const dir = text(item && item.path);
|
|
248
|
+
if (dir) return dir;
|
|
249
|
+
}
|
|
250
|
+
} catch { /* 取不到就退到家目录 */ }
|
|
251
|
+
return homedir();
|
|
252
|
+
}
|
|
253
|
+
|
|
145
254
|
function send(ws, frame) {
|
|
146
255
|
if (ws.readyState !== ws.OPEN) return;
|
|
147
256
|
try { ws.send(JSON.stringify(frame)); } catch { /* 已经断了 */ }
|
|
@@ -664,11 +773,11 @@ function createCliService({ authorized, onLog } = {}) {
|
|
|
664
773
|
const updated = await session.handle.setConfigOption(configId, body.value);
|
|
665
774
|
if (updated && Array.isArray(updated.configOptions)) session.configOptions = updated.configOptions;
|
|
666
775
|
// 选过的模型要记住:下一次在这个 CLI 上开新会话时自动套上(见 applyPreferredModel)。
|
|
667
|
-
//
|
|
776
|
+
// 模型只记在 `model-prefs` 那本账里;其余每一项记进 `config-prefs.js` —— 那是「下次
|
|
777
|
+
// 开局自动套上」的唯一来源,两本账各记一份的话生效的那本必须是被写的那本。
|
|
668
778
|
const option = (session.configOptions || []).find((item) => item && item.id === configId);
|
|
669
779
|
if (option && option.category === 'model') rememberModel(session.agentId, configId, body.value);
|
|
670
|
-
|
|
671
|
-
if (option) setValue(session.agentId, configId, text(body.value));
|
|
780
|
+
else if (option) setValue(session.agentId, configId, text(body.value));
|
|
672
781
|
} else throw fail('BAD_REQUEST', '需要 configId 或 modeId');
|
|
673
782
|
} catch (error) {
|
|
674
783
|
if (error && error.code && STATUS_BY_CODE[error.code]) throw error;
|
|
@@ -680,11 +789,15 @@ function createCliService({ authorized, onLog } = {}) {
|
|
|
680
789
|
/**
|
|
681
790
|
* GET /cli/prefs?agent=:一个 CLI 的配置全貌(配置页读它)。
|
|
682
791
|
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
792
|
+
* 优先顺序:正开着的那条会话手上的表(最新)→ 记忆里那份(CLI 握过手就记下)→ **现去问一次**。
|
|
793
|
+
* 最后一档是必须的:选项表是 CLI 自己给的,一家从没在这台电脑上开过对话的 CLI(全新装的
|
|
794
|
+
* Gemini CLI 之类)记忆里一条都没有,配置页就成了一句「没有可调的项」—— 而「提前把权限配
|
|
795
|
+
* 好」正是最需要它的时候。问的那一次在后台跑(起一个适配器、拿到 `configOptions` 就关掉),
|
|
796
|
+
* 回来之前回 `pending`,App 按退避重问。
|
|
797
|
+
*
|
|
798
|
+
* `live` 说的是这份表是从跑着的会话读的;`pending` 说的是正在问。
|
|
686
799
|
*/
|
|
687
|
-
function handlePrefs(
|
|
800
|
+
async function handlePrefs(ctx, req, res) {
|
|
688
801
|
const agentId = text(query(req).get('agent'));
|
|
689
802
|
if (!agentId) throw fail('BAD_REQUEST', '需要 agent');
|
|
690
803
|
const recorded = prefsOf(agentId);
|
|
@@ -700,12 +813,29 @@ function createCliService({ authorized, onLog } = {}) {
|
|
|
700
813
|
live = true;
|
|
701
814
|
break;
|
|
702
815
|
}
|
|
816
|
+
let pending = false;
|
|
817
|
+
let probeError = '';
|
|
818
|
+
// `force` 是配置页那行「重试」:探测失败之后,用户去装了 CLI / 登了录才回来,等
|
|
819
|
+
// `PROBE_RETRY_MS` 到点是不对的 —— 他这一下就是要现在再问一次。
|
|
820
|
+
const force = query(req).get('probe') === '1';
|
|
821
|
+
if (options.length === 0 && !live) {
|
|
822
|
+
const state = probeState(agentId);
|
|
823
|
+
if (state && state.running) pending = true;
|
|
824
|
+
else if (!state || force || (!state.done && (!state.error || Date.now() - state.at > PROBE_RETRY_MS))) {
|
|
825
|
+
startProbe(ctx, agentId);
|
|
826
|
+
pending = true;
|
|
827
|
+
} else if (!state.done && state.error) {
|
|
828
|
+
probeError = state.error;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
703
831
|
sendJson(res, 200, {
|
|
704
832
|
ok: true,
|
|
705
833
|
agent: agentId,
|
|
706
834
|
options,
|
|
707
|
-
values:
|
|
835
|
+
values: valuesBodyOf(agentId, options),
|
|
708
836
|
live,
|
|
837
|
+
pending,
|
|
838
|
+
...(probeError ? { probeError } : {}),
|
|
709
839
|
});
|
|
710
840
|
}
|
|
711
841
|
|
|
@@ -724,14 +854,21 @@ function createCliService({ authorized, onLog } = {}) {
|
|
|
724
854
|
* 那几条」。配置页在没有会话时用它,所以它也不启动任何进程。
|
|
725
855
|
*
|
|
726
856
|
* `value` 空 = 撤掉这一项,回到 CLI 自己的默认。
|
|
857
|
+
*
|
|
858
|
+
* **模型那一项记在 `model-prefs` 那本账上**:新会话开局是 `applyPreferredModel` 在读它,
|
|
859
|
+
* 记进 `config-prefs` 只会出现「配置页勾上了、开新对话却是别的」—— 两本账各记一份时,
|
|
860
|
+
* 生效的那本必须是被写的那本。
|
|
727
861
|
*/
|
|
728
862
|
async function handleSetPref(_ctx, req, res) {
|
|
729
863
|
const body = await bodyJson(req);
|
|
730
864
|
const agentId = text(body.agent);
|
|
731
865
|
const configId = text(body.configId);
|
|
732
866
|
if (!agentId || !configId) throw fail('BAD_REQUEST', '需要 agent 与 configId');
|
|
733
|
-
|
|
734
|
-
|
|
867
|
+
const value = text(body.value);
|
|
868
|
+
const options = prefsOf(agentId).options;
|
|
869
|
+
if (isModelOption(options, configId)) rememberModel(agentId, configId, value);
|
|
870
|
+
else setValue(agentId, configId, value);
|
|
871
|
+
sendJson(res, 200, { ok: true, agent: agentId, values: valuesBodyOf(agentId, options) });
|
|
735
872
|
}
|
|
736
873
|
|
|
737
874
|
/** POST cli/close { sessionId }:主动收掉一个会话(用户点「结束」)。 */
|
|
@@ -897,5 +1034,9 @@ module.exports = {
|
|
|
897
1034
|
mountCli,
|
|
898
1035
|
createCliService,
|
|
899
1036
|
pendingPrefsOf,
|
|
1037
|
+
// 配置页要画的那张表与「哪一项是模型」的判据:两条都在这里,测试直接钉这两份真函数
|
|
1038
|
+
// (照抄一份等价实现的话,实现写错测试照样绿)。
|
|
1039
|
+
isModelOption,
|
|
1040
|
+
valuesBodyOf,
|
|
900
1041
|
MAX_SESSIONS,
|
|
901
1042
|
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 已审核扩展清单:随 dsh-mobile-plugin 的 npm 包一起发布的第三方扩展(包内 `extensions/`)。
|
|
5
|
+
*
|
|
6
|
+
* 与开发包那条路的分界:开发包读作者电脑上的 `$DSH_HOME/extension-dev`(边写边看),这条读
|
|
7
|
+
* 插件包自带的只读目录 —— 只有审过、登记进 `extensions/catalog.json` 的包才下发,目录里有但
|
|
8
|
+
* 没登记的一律当不存在。审核闸门在清单,不在目录。
|
|
9
|
+
*
|
|
10
|
+
* 能力面只多「读这一个固定目录」:根由 `__dirname` 定位,不依赖进程 cwd,也不建目录;每个
|
|
11
|
+
* 文件的 size 与 sha256 由宿主扫目录现算,不信清单里写的数字。取文件先挡绝对路径与 `..` 段,
|
|
12
|
+
* 再 resolve 之后判前缀(带分隔符),不接受任意路径。仍然不暴露 shell、系统设置、任意文件读写。
|
|
13
|
+
*
|
|
14
|
+
* 坏包一律静默跳过:没登记、目录不在、manifest 读不出、超限(20MB / 200 个文件)都不该让整张
|
|
15
|
+
* 列表变红;catalog.json 不在或坏了就是一份空清单,不抛。取文件那条才逐个回错误码。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const crypto = require('node:crypto');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const { sendJson } = require('../shared/http-util');
|
|
23
|
+
|
|
24
|
+
/** 审核清单文件名。只有列进来的 slug 才下发。 */
|
|
25
|
+
const CATALOG_FILE = 'catalog.json';
|
|
26
|
+
/** slug 直接当包目录名,所以只认这套字符;别的名字连读都不读。 */
|
|
27
|
+
const SLUG_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
28
|
+
/** 整包上限:与开发包同一档,太重的包手机也不该整份拉。 */
|
|
29
|
+
const MAX_PACKAGE_BYTES = 20 * 1024 * 1024;
|
|
30
|
+
const MAX_PACKAGE_FILES = 200;
|
|
31
|
+
/** 单个文件上限:一条 base64 响应要塞进手机内存,再大就不该走这条路。 */
|
|
32
|
+
const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
/** 包内固定目录:从 lib/mobile/ 往上两级就是包根。不依赖 cwd、不建目录(随 npm 包发布)。 */
|
|
35
|
+
function catalogRoot() {
|
|
36
|
+
return path.join(__dirname, '..', '..', 'extensions');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 400 信封:与其余移动端接口同形,App 按 code 翻人话。 */
|
|
40
|
+
function fail(res, status, code, message) {
|
|
41
|
+
return sendJson(res, status, { ok: false, error: { code, message } });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** manifest 里取一个非空字符串,缺了 / 类型不对 / 空串都退回默认值。 */
|
|
45
|
+
function textField(value, fallback) {
|
|
46
|
+
return typeof value === 'string' && value ? value : fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 清单里的可选文案:非空字符串才收,其余一律 null。 */
|
|
50
|
+
function textOrNull(value) {
|
|
51
|
+
return typeof value === 'string' && value.trim() ? value : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 能力声明收窄成字符串数组:表外的类型与空串丢掉,重复的不给第二份。 */
|
|
55
|
+
function capabilitiesOf(value) {
|
|
56
|
+
if (!Array.isArray(value)) return [];
|
|
57
|
+
const out = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const item of value) {
|
|
60
|
+
if (typeof item !== 'string' || !item.trim() || seen.has(item)) continue;
|
|
61
|
+
seen.add(item);
|
|
62
|
+
out.push(item);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sha256Hex(raw) {
|
|
68
|
+
return crypto.createHash('sha256').update(raw).digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 读审核清单。文件不在、读坏、形状不对都当空清单 —— 空的插件包本来就没有第三方扩展。
|
|
73
|
+
* 只收 slug / description / author / releaseNotes 四样,其余字段(包括任何 size / sha256)
|
|
74
|
+
* 一概不认:清单只负责「谁审过了」,文件事实由扫目录算。
|
|
75
|
+
*/
|
|
76
|
+
function readCatalog(root) {
|
|
77
|
+
let text;
|
|
78
|
+
try { text = fs.readFileSync(path.join(root, CATALOG_FILE), 'utf8'); } catch { return []; }
|
|
79
|
+
let parsed;
|
|
80
|
+
try { parsed = JSON.parse(text); } catch { return []; }
|
|
81
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Array.isArray(parsed.packages)) return [];
|
|
82
|
+
const rows = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const raw of parsed.packages) {
|
|
85
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
|
86
|
+
const slug = typeof raw.slug === 'string' ? raw.slug.trim() : '';
|
|
87
|
+
if (!SLUG_PATTERN.test(slug) || seen.has(slug)) continue;
|
|
88
|
+
seen.add(slug);
|
|
89
|
+
rows.push({
|
|
90
|
+
slug,
|
|
91
|
+
description: textOrNull(raw.description),
|
|
92
|
+
author: textOrNull(raw.author),
|
|
93
|
+
releaseNotes: textOrNull(raw.releaseNotes),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 递归收集包内的普通文件,`path` 是相对包目录的 POSIX 路径。与 extension-dev.js 同一套口径:
|
|
101
|
+
* 点开头的文件与目录不看,符号链接不是普通文件,超限当场收手返回 false(先收完再判等于把
|
|
102
|
+
* 500MB 的目录整个哈希一遍)。
|
|
103
|
+
*/
|
|
104
|
+
function collectFiles(directory, acc) {
|
|
105
|
+
let entries;
|
|
106
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return false; }
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (entry.name.startsWith('.')) continue;
|
|
109
|
+
const full = path.join(directory, entry.name);
|
|
110
|
+
if (entry.isDirectory()) {
|
|
111
|
+
if (!collectFiles(full, acc)) return false;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!entry.isFile()) continue;
|
|
115
|
+
let raw;
|
|
116
|
+
try { raw = fs.readFileSync(full); } catch { continue; }
|
|
117
|
+
acc.bytes += raw.length;
|
|
118
|
+
acc.files.push({
|
|
119
|
+
path: path.relative(acc.root, full).split(path.sep).join('/'),
|
|
120
|
+
size: raw.length,
|
|
121
|
+
sha256: sha256Hex(raw),
|
|
122
|
+
});
|
|
123
|
+
if (acc.bytes > MAX_PACKAGE_BYTES || acc.files.length > MAX_PACKAGE_FILES) return false;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 扫审核清单,把每个登记过、且目录与 manifest 都在的包读成一份 Package。
|
|
130
|
+
*
|
|
131
|
+
* 入口页固定是包目录下那层 `index.html`:App 侧 `extensionEntryPath` 找的就是这个名字,
|
|
132
|
+
* `manifest.json` 里写 `main` 不生效。所以这里在列包时先确认 `index.html` 真的在文件清单里,
|
|
133
|
+
* 缺了就把整包跳过 —— 装到手机上再报「需要重新安装」,比根本不出现在清单里难查得多。
|
|
134
|
+
*
|
|
135
|
+
* 纯函数、root 由调用方给:守卫拿假目录跑,线上 handler 给包内那个固定目录。
|
|
136
|
+
*/
|
|
137
|
+
function listPackages(root) {
|
|
138
|
+
const packages = [];
|
|
139
|
+
for (const entry of readCatalog(root)) {
|
|
140
|
+
const directory = path.join(root, entry.slug);
|
|
141
|
+
let stats;
|
|
142
|
+
try { stats = fs.statSync(directory); } catch { continue; }
|
|
143
|
+
if (!stats.isDirectory()) continue;
|
|
144
|
+
let manifest;
|
|
145
|
+
try {
|
|
146
|
+
manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
|
|
147
|
+
} catch { continue; }
|
|
148
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) continue;
|
|
149
|
+
const acc = { root: directory, files: [], bytes: 0 };
|
|
150
|
+
if (!collectFiles(directory, acc) || acc.files.length === 0) continue;
|
|
151
|
+
if (!acc.files.some((file) => file.path === 'index.html')) continue;
|
|
152
|
+
packages.push({
|
|
153
|
+
slug: entry.slug,
|
|
154
|
+
name: textField(manifest.name, entry.slug),
|
|
155
|
+
description: entry.description,
|
|
156
|
+
author: entry.author,
|
|
157
|
+
versionName: textField(manifest.versionName, textField(manifest.version, '0.0.0')),
|
|
158
|
+
apiVersion: textField(manifest.apiVersion, textField(manifest.api_version, '1')),
|
|
159
|
+
releaseNotes: entry.releaseNotes,
|
|
160
|
+
capabilities: capabilitiesOf(manifest.capabilities),
|
|
161
|
+
size: acc.bytes,
|
|
162
|
+
fileCount: acc.files.length,
|
|
163
|
+
files: acc.files,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return packages;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* GET /api/mobile/v1/extension/catalog(token)
|
|
171
|
+
*
|
|
172
|
+
* 回审核过的第三方扩展清单。`root` 只是给守卫传假目录的可选参数,线上由 route() 调用时
|
|
173
|
+
* 不传,永远走包内那个固定目录。
|
|
174
|
+
*/
|
|
175
|
+
async function handleCatalog(_ctx, _req, res, root = catalogRoot()) {
|
|
176
|
+
sendJson(res, 200, { ok: true, packages: listPackages(root) });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* GET /api/mobile/v1/extension/catalog/file?slug=…&path=…(token)
|
|
181
|
+
*
|
|
182
|
+
* 取一个已审核包里的一个文件,回 base64 与 sha256(与列表里那条同一份算法)。审核闸门对取文件
|
|
183
|
+
* 同样生效:没登记进 catalog.json 的 slug 一律 404,免得绕过清单直接把目录里的字节读走。
|
|
184
|
+
*
|
|
185
|
+
* 路径检查两道:先挡明显不该出现的(绝对路径、`..` 段),再 resolve 之后判前缀 —— 后者才是
|
|
186
|
+
* 判据,前缀比较必须带分隔符,否则 `/x/extensions/foobar` 会被 `/x/extensions/foo` 认成子路径。
|
|
187
|
+
*/
|
|
188
|
+
async function handleCatalogFile(_ctx, req, res, root = catalogRoot()) {
|
|
189
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
190
|
+
const slug = (url.searchParams.get('slug') || '').trim();
|
|
191
|
+
const requested = (url.searchParams.get('path') || '').trim();
|
|
192
|
+
if (!slug || !requested || !SLUG_PATTERN.test(slug)) {
|
|
193
|
+
return fail(res, 400, 'bad-request', '需要合法的 slug 与 path(path 是相对包目录的路径)');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!readCatalog(root).some((entry) => entry.slug === slug)) {
|
|
197
|
+
return fail(res, 404, 'package-missing', `审核清单里没有 ${slug} 这个包`);
|
|
198
|
+
}
|
|
199
|
+
const directory = path.join(root, slug);
|
|
200
|
+
if (!fs.existsSync(path.join(directory, 'manifest.json'))) {
|
|
201
|
+
return fail(res, 404, 'package-missing', `${slug} 没有可下发的包(缺 manifest.json)`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (path.isAbsolute(requested) || requested.split(/[\\/]/).includes('..')) {
|
|
205
|
+
return fail(res, 400, 'bad-request', 'path 必须是包目录内的相对路径');
|
|
206
|
+
}
|
|
207
|
+
const target = path.resolve(directory, requested);
|
|
208
|
+
if (!target.startsWith(`${directory}${path.sep}`)) {
|
|
209
|
+
return fail(res, 400, 'bad-request', 'path 必须是包目录内的相对路径');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let stats;
|
|
213
|
+
try { stats = fs.statSync(target); } catch { stats = null; }
|
|
214
|
+
if (!stats || !stats.isFile()) {
|
|
215
|
+
return fail(res, 404, 'file-missing', `这个包里没有 ${requested}`);
|
|
216
|
+
}
|
|
217
|
+
if (stats.size > MAX_FILE_BYTES) {
|
|
218
|
+
return fail(res, 413, 'file-too-large', `单个文件上限 ${MAX_FILE_BYTES} 字节,这个超了`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let raw;
|
|
222
|
+
try { raw = fs.readFileSync(target); } catch {
|
|
223
|
+
return fail(res, 404, 'file-missing', `这个包里没有 ${requested}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const relative = path.relative(directory, target).split(path.sep).join('/');
|
|
227
|
+
sendJson(res, 200, {
|
|
228
|
+
ok: true,
|
|
229
|
+
path: relative,
|
|
230
|
+
size: raw.length,
|
|
231
|
+
sha256: sha256Hex(raw),
|
|
232
|
+
content: raw.toString('base64'),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
catalogRoot,
|
|
238
|
+
listPackages,
|
|
239
|
+
handleCatalog,
|
|
240
|
+
handleCatalogFile,
|
|
241
|
+
MAX_PACKAGE_BYTES,
|
|
242
|
+
MAX_PACKAGE_FILES,
|
|
243
|
+
MAX_FILE_BYTES,
|
|
244
|
+
};
|
|
@@ -44,7 +44,7 @@ const MEDIA_TYPES = {
|
|
|
44
44
|
'.gif': 'image/gif',
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
/**
|
|
47
|
+
/** 单张图上限:再大的东西不该整包回给手机。 */
|
|
48
48
|
const MAX_IMAGE_BYTES = 2.5 * 1024 * 1024;
|
|
49
49
|
/** 读宽高只需要文件头:JPEG 的 SOF 段可能在几百字节之后,64KB 足够覆盖绝大多实拍图。 */
|
|
50
50
|
const HEAD_BYTES = 64 * 1024;
|
|
@@ -246,6 +246,10 @@ async function readHead(filePath, size) {
|
|
|
246
246
|
*
|
|
247
247
|
* 编号取「路径 + 大小 + 修改时间」的短哈希:同一张图在同一轮历史里会被反复解析,
|
|
248
248
|
* 这个组合稳定,且改过之后自然换号(图片内容变了本来就该换)。
|
|
249
|
+
*
|
|
250
|
+
* **超过单张上限的图也登记,只是标一句 `tooLarge`**:不登记的话正文里那一段就退回字面
|
|
251
|
+
* markdown,用户在手机上看到的是一串 `` 源码 —— 既不知道发生过什么,也没有任何
|
|
252
|
+
* 入口。标了之后取图口如实回 413,App 画成「图片太大」的占位块,这才是用户能读懂的一句话。
|
|
249
253
|
*/
|
|
250
254
|
async function registerLocalImage(filePath) {
|
|
251
255
|
let stat;
|
|
@@ -254,7 +258,7 @@ async function registerLocalImage(filePath) {
|
|
|
254
258
|
} catch {
|
|
255
259
|
return null;
|
|
256
260
|
}
|
|
257
|
-
if (!stat.isFile() || stat.size <= 0
|
|
261
|
+
if (!stat.isFile() || stat.size <= 0) return null;
|
|
258
262
|
const id = crypto.createHash('sha1').update(`${filePath}:${stat.size}:${stat.mtimeMs}`).digest('hex').slice(0, 16);
|
|
259
263
|
const known = registry.get(id);
|
|
260
264
|
if (known) return { id, ...known };
|
|
@@ -262,11 +266,12 @@ async function registerLocalImage(filePath) {
|
|
|
262
266
|
if (!head) return null;
|
|
263
267
|
const mediaType = sniffMediaType(head);
|
|
264
268
|
if (!mediaType) return null;
|
|
265
|
-
const
|
|
269
|
+
const over = stat.size > MAX_IMAGE_BYTES;
|
|
270
|
+
const parsed = over ? null : imageSizeOf(head);
|
|
266
271
|
const sane = parsed && parsed.width > 0 && parsed.height > 0
|
|
267
272
|
&& parsed.width <= 30000 && parsed.height <= 30000;
|
|
268
273
|
const size = sane ? parsed : {};
|
|
269
|
-
const entry = { filePath, mediaType, ...size };
|
|
274
|
+
const entry = { filePath, mediaType, ...size, ...(over ? { tooLarge: true } : {}) };
|
|
270
275
|
registry.set(id, entry);
|
|
271
276
|
while (registry.size > MAX_REGISTRY_ENTRIES) registry.delete(registry.keys().next().value);
|
|
272
277
|
return { id, ...entry };
|
|
@@ -467,4 +472,4 @@ function inRanges(index, ranges) {
|
|
|
467
472
|
return false;
|
|
468
473
|
}
|
|
469
474
|
|
|
470
|
-
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages };
|
|
475
|
+
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages, MAX_IMAGE_BYTES };
|
package/lib/mobile/monitor.js
CHANGED
|
@@ -26,7 +26,7 @@ const {
|
|
|
26
26
|
const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
|
|
27
27
|
const { lastTurnOf } = require('./session-last-turn.js');
|
|
28
28
|
const { sessionPreviewFor } = require('./session-preview.js');
|
|
29
|
-
const {
|
|
29
|
+
const { archivedSessionIdsForMonitor, pendingInteractionOf, pendingCardOf } = require('./session-flags.js');
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* 一条会话在监控视图里的那几格。
|
|
@@ -114,7 +114,10 @@ async function buildMonitorState(ctx, deviceKey, extraItems = null) {
|
|
|
114
114
|
|
|
115
115
|
const generation = completionGenerationOf(ctx, deviceKey);
|
|
116
116
|
const deleted = deletedSessionIdsOf(ctx);
|
|
117
|
-
|
|
117
|
+
// 归档集合用「记住上一次真读到的那份」那个口径,不用列表那条 fail-open:
|
|
118
|
+
// 这一条决定要不要在后台叫醒人,一次读不到就按「没归档」画会让已归档的会话重新进清单、
|
|
119
|
+
// 被当成刚跑完再响一遍(见 session-flags.js 的 archivedSessionIdsForMonitor)。
|
|
120
|
+
const archived = await archivedSessionIdsForMonitor(ctx);
|
|
118
121
|
// 子代理会话不参与后台提醒:它们是主轮内部的执行单元,App 侧栏也不列;把它们算进来
|
|
119
122
|
// 会在后台弹一堆「子代理跑完了」。
|
|
120
123
|
const live = summaries.filter((s) => s && typeof s.sessionId === 'string' && s.sessionId
|