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 +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 +172 -0
- package/lib/mobile/cli/icons.js +138 -0
- package/lib/mobile/cli/images.js +132 -0
- package/lib/mobile/cli/mobile.js +1520 -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 +471 -0
- package/lib/mobile/services.js +16 -0
- package/lib/mobile/session-send.js +1 -0
- package/lib/mobile/terminal.js +574 -0
- package/lib/routes/mobile-api.js +139 -3
- package/package.json +2 -1
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 移动端「CLI 对话」:`/api/mobile/v1/cli/*` 的 handler、会话表与 WebSocket 流。
|
|
5
|
+
*
|
|
6
|
+
* 与远程终端(`mobile/terminal.js`)是同一个能力的两种呈现,边界也同一条:可执行文件只能
|
|
7
|
+
* 是 `agents.js` 那张表里的名字,工作目录只能来自这台设备已注册的工作区,没有「执行任意命令」
|
|
8
|
+
* 的路由。区别是终端把 TUI 字节原样搬给手机,这里让 CLI 以 ACP 的方式跑、把结构化事件
|
|
9
|
+
* 交给对话页 —— 手机因此能像正常对话一样看正文、看工具调用、点审批。
|
|
10
|
+
*
|
|
11
|
+
* 三个设计点:
|
|
12
|
+
*
|
|
13
|
+
* 1. **建会话立刻返回**。适配器第一次要 `npx` 下载桥包、之后还要握手,几十秒都可能;把它
|
|
14
|
+
* 压在一次 HTTP 里,App 那边的请求会先超时。所以 create 只建记录、回 `starting`,真正的
|
|
15
|
+
* 启动在后台跑,进度与失败都从 WS 帧上报(`notice` / `status` / `exit`)。
|
|
16
|
+
* 2. **块要攒批发**。流式正文每秒几十段,一段一帧等于把「出字节奏」换成「网络往返次数」。
|
|
17
|
+
* 攒 60ms 一轮,同一块的多次改动只发最后那一份。
|
|
18
|
+
* 3. **会话托管在宿主**。手机锁屏、切后台、换 Wi-Fi 都不该把 CLI 掐掉;重连时 `hello` 帧
|
|
19
|
+
* 带全量块重放,页面回到原样。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { WebSocketServer } = require('ws');
|
|
23
|
+
|
|
24
|
+
const { readJsonBody, sendJson } = require('../../shared/http-util');
|
|
25
|
+
const { unwrap } = require('../envelope.js');
|
|
26
|
+
const { AGENTS, resolveAgent, childEnv, installedPackages, isInstalled } = require('./agents.js');
|
|
27
|
+
const { loadIcons } = require('./icons.js');
|
|
28
|
+
const { createUsage, applyUsageUpdate, applyPromptUsage } = require('./usage.js');
|
|
29
|
+
const { listRegistryAgents } = require('./registry.js');
|
|
30
|
+
const { preferredModel, rememberModel } = require('./model-prefs.js');
|
|
31
|
+
const { prefsOf, setValue, rememberOptions, describe } = require('./config-prefs.js');
|
|
32
|
+
const images = require('./images.js');
|
|
33
|
+
const { createBlockLog } = require('./blocks.js');
|
|
34
|
+
const { startAgent } = require('./acp.js');
|
|
35
|
+
const { createCliMobile } = require('./mobile.js');
|
|
36
|
+
|
|
37
|
+
/** 错误码 → HTTP 状态;与终端那一组同口径。 */
|
|
38
|
+
const STATUS_BY_CODE = {
|
|
39
|
+
BAD_REQUEST: 400,
|
|
40
|
+
FORBIDDEN: 403,
|
|
41
|
+
NOT_FOUND: 404,
|
|
42
|
+
UNSUPPORTED: 400,
|
|
43
|
+
AGENT_UNAVAILABLE: 404,
|
|
44
|
+
WORKSPACE_UNAVAILABLE: 502,
|
|
45
|
+
TOO_MANY_SESSIONS: 429,
|
|
46
|
+
SPAWN_FAILED: 500,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** 同时活着的 CLI 会话上限:每一条都挂着一个真进程(有的还带一个 app-server)。 */
|
|
50
|
+
const MAX_SESSIONS = 4;
|
|
51
|
+
|
|
52
|
+
/** 流式块的攒批窗口。 */
|
|
53
|
+
const FLUSH_MS = 60;
|
|
54
|
+
|
|
55
|
+
/** 标题兜底截断长度(宿主没给 `session_info_update.title` 时用首条提问)。 */
|
|
56
|
+
const TITLE_MAX = 40;
|
|
57
|
+
|
|
58
|
+
class CliRequestError extends Error {
|
|
59
|
+
constructor(code, message) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'CliRequestError';
|
|
62
|
+
this.code = code;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fail(code, message) {
|
|
67
|
+
return new CliRequestError(code, message);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function text(value) {
|
|
71
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function query(req) {
|
|
75
|
+
return new URL(req.url || '/', 'http://x').searchParams;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 「这条新会话还差哪几项没套上」—— 记着的偏好 ∩ 新会话真有的选项。
|
|
80
|
+
*
|
|
81
|
+
* 抽成纯函数是为了能脱离进程直接钉(`test/cli-config-prefs.test.cjs` 跑的就是这一份,不是
|
|
82
|
+
* 照抄一份等价实现):三条同时成立才写 —— ① 用户记过这一项 ② 新会话里有这一项 ③ 这个值还在
|
|
83
|
+
* 它的候选里。少了任何一条都跳过:CLI 升级换了档位名、或者这一版少了某个选项,硬塞过去只会
|
|
84
|
+
* 让整条会话卡在一个它不认识的值上。
|
|
85
|
+
*
|
|
86
|
+
* 模型那一项不在这里(它记在 `model-prefs` 那本账里,由 `applyPreferredModel` 负责),
|
|
87
|
+
* 免得两边各写一次、后写的那次盖掉前一次。
|
|
88
|
+
*/
|
|
89
|
+
function pendingPrefsOf(session) {
|
|
90
|
+
const recorded = prefsOf(session.agentId).values;
|
|
91
|
+
const options = Array.isArray(session.configOptions) ? session.configOptions : [];
|
|
92
|
+
const skip = new Set(['model']);
|
|
93
|
+
for (const option of options) {
|
|
94
|
+
if (option && option.category === 'model') skip.add(option.id);
|
|
95
|
+
}
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const [configId, value] of Object.entries(recorded)) {
|
|
98
|
+
if (skip.has(configId)) continue;
|
|
99
|
+
const option = options.find((item) => item && item.id === configId);
|
|
100
|
+
if (!option) continue;
|
|
101
|
+
if (String(option.currentValue) === value) continue;
|
|
102
|
+
const choices = Array.isArray(option.options) ? option.options : [];
|
|
103
|
+
if (!choices.some((item) => item && String(item.value) === value)) continue;
|
|
104
|
+
out.push([configId, value]);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function bodyJson(req) {
|
|
110
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
111
|
+
if (!body || typeof body !== 'object') throw fail('BAD_REQUEST', '需要 JSON 请求体');
|
|
112
|
+
return body;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 会话表与它的所有出口。一个宿主进程一份(装配层建、装配层关)。 */
|
|
116
|
+
function createCliService({ authorized, onLog } = {}) {
|
|
117
|
+
/** sessionId → 会话。 */
|
|
118
|
+
const sessions = new Map();
|
|
119
|
+
let seq = 0;
|
|
120
|
+
let permSeq = 0;
|
|
121
|
+
let closed = false;
|
|
122
|
+
|
|
123
|
+
const wss = new WebSocketServer({
|
|
124
|
+
noServer: true,
|
|
125
|
+
perMessageDeflate: false,
|
|
126
|
+
maxPayload: 256 * 1024,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const log = (message) => { try { onLog?.(message); } catch { /* 日志失败不该影响搬运 */ } };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 这一次的适配器清单:注册表(带缓存与兜底)那份,`agents.js` 内置四家垫底。
|
|
133
|
+
*
|
|
134
|
+
* 建会话与列清单都走它,所以「列出来的」和「点得下去的」永远是同一批 —— 两份各自算的话,
|
|
135
|
+
* 注册表刚更新那一下会出现「列表里有、点了说不认识」。
|
|
136
|
+
*/
|
|
137
|
+
let agentsPromise = null;
|
|
138
|
+
function listAgents() {
|
|
139
|
+
if (!agentsPromise) {
|
|
140
|
+
agentsPromise = listRegistryAgents(AGENTS).catch(() => AGENTS);
|
|
141
|
+
}
|
|
142
|
+
return agentsPromise;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function send(ws, frame) {
|
|
146
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
147
|
+
try { ws.send(JSON.stringify(frame)); } catch { /* 已经断了 */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function broadcast(session, frame) {
|
|
151
|
+
for (const ws of session.sockets) send(ws, frame);
|
|
152
|
+
// 普通会话那套移动端接口(`cli/mobile.js`)挂在同一条广播上:它同样需要「块变了 /
|
|
153
|
+
// 状态变了 / 卡来了」这几件事,但出口是 SSE 而不是 WebSocket。失败不能影响搬运。
|
|
154
|
+
for (const sink of session.sinks) { try { sink(frame); } catch { /* 单个观众写失败不影响别人 */ } }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 挂一个非 WebSocket 的观众(`cli/mobile.js` 的 SSE)。
|
|
159
|
+
*
|
|
160
|
+
* 返回解绑函数。会话被收掉时 sinks 跟着会话对象一起没,不需要额外清理。
|
|
161
|
+
*/
|
|
162
|
+
function watch(session, sink) {
|
|
163
|
+
session.sinks.add(sink);
|
|
164
|
+
return () => { session.sinks.delete(sink); };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 攒批:同一块的多次改动只发最后那一份。 */
|
|
168
|
+
function markBlock(session, block) {
|
|
169
|
+
session.dirty.set(block.id, block);
|
|
170
|
+
if (session.timer) return;
|
|
171
|
+
session.timer = setTimeout(() => {
|
|
172
|
+
session.timer = null;
|
|
173
|
+
const pending = [...session.dirty.values()];
|
|
174
|
+
session.dirty.clear();
|
|
175
|
+
for (const item of pending) broadcast(session, { t: 'block', block: item });
|
|
176
|
+
}, FLUSH_MS);
|
|
177
|
+
session.timer.unref?.();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function flushNow(session) {
|
|
181
|
+
if (session.timer) { clearTimeout(session.timer); session.timer = null; }
|
|
182
|
+
const pending = [...session.dirty.values()];
|
|
183
|
+
session.dirty.clear();
|
|
184
|
+
for (const item of pending) broadcast(session, { t: 'block', block: item });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function detailOf(session) {
|
|
188
|
+
return {
|
|
189
|
+
sessionId: session.id,
|
|
190
|
+
agent: session.agentId,
|
|
191
|
+
label: session.label,
|
|
192
|
+
workspaceId: session.workspaceId,
|
|
193
|
+
title: session.title,
|
|
194
|
+
cwd: session.cwd,
|
|
195
|
+
status: session.status,
|
|
196
|
+
updatedAt: session.updatedAt,
|
|
197
|
+
createdAt: session.createdAt,
|
|
198
|
+
// 会话的可配置项 / 模式 / 斜杠命令:手机照着这两张表画「选模型」与「模式」,
|
|
199
|
+
// 不必为每家 CLI 各写一套(见 acp.js 的 configOptions)。
|
|
200
|
+
configOptions: session.configOptions,
|
|
201
|
+
modes: session.modes,
|
|
202
|
+
commands: session.commands,
|
|
203
|
+
// 挂着的审批也要进这一份:审批原本只靠 WS 那一帧送达,帧进不来时卡片永远不出现,
|
|
204
|
+
// 而适配器那头正等着这个回答。放进详情里,轮询那条路也能把它画出来。
|
|
205
|
+
permissions: [...session.permissionFrames.values()],
|
|
206
|
+
blocks: session.blocks.list(),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 只发「配置 / 命令变了」,不重发整份块(那是 `hello` 的活)。 */
|
|
211
|
+
function pushConfig(session) {
|
|
212
|
+
broadcast(session, {
|
|
213
|
+
t: 'config',
|
|
214
|
+
configOptions: session.configOptions,
|
|
215
|
+
modes: session.modes,
|
|
216
|
+
commands: session.commands,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function setStatus(session, status) {
|
|
221
|
+
if (session.status === status) return;
|
|
222
|
+
session.status = status;
|
|
223
|
+
session.updatedAt = Date.now();
|
|
224
|
+
broadcast(session, { t: 'status', status });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function setTitle(session, title) {
|
|
228
|
+
const clean = text(title);
|
|
229
|
+
if (!clean || clean === session.title) return;
|
|
230
|
+
session.title = clean;
|
|
231
|
+
session.updatedAt = Date.now();
|
|
232
|
+
broadcast(session, { t: 'title', title: clean });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function say(session, level, message) {
|
|
236
|
+
const block = session.blocks.notice(level, message, Date.now());
|
|
237
|
+
flushNow(session);
|
|
238
|
+
markBlock(session, block);
|
|
239
|
+
flushNow(session);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 进程没了(正常退出 / 崩溃 / 握不上手):收口块、拒掉挂着的审批、告诉每一部手机。 */
|
|
243
|
+
function finish(session, reason) {
|
|
244
|
+
if (session.finished || session.closed) return;
|
|
245
|
+
session.finished = true;
|
|
246
|
+
session.handle = null;
|
|
247
|
+
for (const [, pending] of session.permissions) {
|
|
248
|
+
try { pending({ outcome: { outcome: 'cancelled' } }); } catch { /* 已经没了 */ }
|
|
249
|
+
}
|
|
250
|
+
session.permissions.clear();
|
|
251
|
+
session.permissionFrames.clear();
|
|
252
|
+
for (const block of session.blocks.settle(Date.now())) markBlock(session, block);
|
|
253
|
+
// 同上:块先出去,再播「退出了」,否则最后一段正文会跟在收口帧后面。
|
|
254
|
+
flushNow(session);
|
|
255
|
+
session.status = 'error';
|
|
256
|
+
session.updatedAt = Date.now();
|
|
257
|
+
broadcast(session, { t: 'status', status: 'error' });
|
|
258
|
+
broadcast(session, { t: 'exit', reason: text(reason) || 'CLI 退出了' });
|
|
259
|
+
log(`cli ${session.id}: 退出(${text(reason) || '未说明'})`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function onUpdate(session, update) {
|
|
263
|
+
const at = Date.now();
|
|
264
|
+
const kind = update && update.sessionUpdate;
|
|
265
|
+
// 配置类事件不进块表:它们是「这一页能做哪些设置」,画在输入条上。
|
|
266
|
+
if (kind === 'available_commands_update') {
|
|
267
|
+
const list = Array.isArray(update.availableCommands) ? update.availableCommands : [];
|
|
268
|
+
session.commands = list.map((entry) => ({
|
|
269
|
+
name: text(entry.name),
|
|
270
|
+
description: text(entry.description),
|
|
271
|
+
...(entry.input ? { hint: text(entry.input.hint) } : {}),
|
|
272
|
+
})).filter((entry) => entry.name);
|
|
273
|
+
pushConfig(session);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (kind === 'config_option_update') {
|
|
277
|
+
if (Array.isArray(update.configOptions)) session.configOptions = update.configOptions;
|
|
278
|
+
pushConfig(session);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (kind === 'current_mode_update') {
|
|
282
|
+
if (session.modes && text(update.currentModeId)) session.modes = { ...session.modes, currentModeId: text(update.currentModeId) };
|
|
283
|
+
pushConfig(session);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// 用量不进块表:它是一条指标,不是对话内容(见 `cli/usage.js`)。
|
|
287
|
+
if (kind === 'usage_update') {
|
|
288
|
+
if (applyUsageUpdate(session.usage, update)) session.updatedAt = at;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const result = session.blocks.apply(update, at);
|
|
292
|
+
if (!result) return;
|
|
293
|
+
session.updatedAt = at;
|
|
294
|
+
if (result.title) setTitle(session, result.title);
|
|
295
|
+
if (result.block) markBlock(session, result.block);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 适配器要放行:做成一条挂着的 promise,等手机上点。 */
|
|
299
|
+
function onPermission(session, params) {
|
|
300
|
+
permSeq += 1;
|
|
301
|
+
const id = `p${permSeq}`;
|
|
302
|
+
const toolCall = (params && params.toolCall) || {};
|
|
303
|
+
const options = Array.isArray(params && params.options) ? params.options : [];
|
|
304
|
+
const request = {
|
|
305
|
+
id,
|
|
306
|
+
title: text(toolCall.title) || '需要你的许可',
|
|
307
|
+
detail: toolCall.rawInput === undefined ? '' : JSON.stringify(toolCall.rawInput).slice(0, 400),
|
|
308
|
+
options: options.map((option) => ({
|
|
309
|
+
optionId: option.optionId,
|
|
310
|
+
name: text(option.name) || option.optionId,
|
|
311
|
+
kind: text(option.kind) || 'allow_once',
|
|
312
|
+
})),
|
|
313
|
+
};
|
|
314
|
+
session.permissionFrames.set(id, request);
|
|
315
|
+
broadcast(session, { t: 'permission', request });
|
|
316
|
+
return new Promise((resolve) => {
|
|
317
|
+
session.permissions.set(id, (outcome) => {
|
|
318
|
+
session.permissions.delete(id);
|
|
319
|
+
session.permissionFrames.delete(id);
|
|
320
|
+
broadcast(session, { t: 'permission-resolved', id });
|
|
321
|
+
resolve(outcome);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function boot(session, spec) {
|
|
327
|
+
try {
|
|
328
|
+
const handle = await startAgent({
|
|
329
|
+
spec,
|
|
330
|
+
cwd: session.cwd,
|
|
331
|
+
env: childEnv(spec.command),
|
|
332
|
+
onUpdate: (update) => onUpdate(session, update),
|
|
333
|
+
onPermission: (params) => onPermission(session, params),
|
|
334
|
+
onExit: (reason) => finish(session, reason),
|
|
335
|
+
});
|
|
336
|
+
if (session.closed) { handle.close(); return; }
|
|
337
|
+
session.handle = handle;
|
|
338
|
+
session.finished = false;
|
|
339
|
+
session.configOptions = handle.configOptions || [];
|
|
340
|
+
session.modes = handle.modes || null;
|
|
341
|
+
// 选项表先记一份:配置页在没开这条会话时要能画出「权限档有哪几个」。
|
|
342
|
+
rememberOptions(session.agentId, session.configOptions);
|
|
343
|
+
await applyRecordedPrefs(session, handle);
|
|
344
|
+
// 套完之后再记一次:CLI 升级换了档位名时,这一步会把过期的选项表刷成新的。
|
|
345
|
+
rememberOptions(session.agentId, session.configOptions);
|
|
346
|
+
pushConfig(session);
|
|
347
|
+
setStatus(session, 'idle');
|
|
348
|
+
log(`cli ${session.id}: ${session.label} 起来了`);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (session.closed) return;
|
|
351
|
+
// 适配器的报错是英文原文(`Invalid params` / `Authentication required` 之类),单看一眼
|
|
352
|
+
// 不知道是谁出了问题,所以前面补一句「哪个 CLI 起不来」。
|
|
353
|
+
const raw = error && error.message ? error.message : '不知道什么原因';
|
|
354
|
+
const message = `${session.label} 起不来:${raw}`;
|
|
355
|
+
say(session, 'error', message);
|
|
356
|
+
finish(session, message);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 发一轮。
|
|
362
|
+
*
|
|
363
|
+
* WS 与 HTTP 两个入口都走这一份:WS 是快的那条路,HTTP 是「隧道上帧进不来」时的兜底
|
|
364
|
+
* (手机上实测过这条,见 A1-259 的坑)。所以两边都不能各写一份,不然行为会分叉。
|
|
365
|
+
*
|
|
366
|
+
* `images` 是这一发带的图(已经存好的元数据,见 `cli/images.js`):块表记一份、交给适配器
|
|
367
|
+
* 一份。**只有正文没有图**也照常走这里,与之前完全一样。
|
|
368
|
+
*/
|
|
369
|
+
function runPrompt(session, message, images) {
|
|
370
|
+
const pictures = Array.isArray(images) ? images.filter(Boolean) : [];
|
|
371
|
+
if (!message && pictures.length === 0) return { ok: false, error: '空消息' };
|
|
372
|
+
if (session.finished || !session.handle) {
|
|
373
|
+
say(session, 'error', 'CLI 还没起来,先等一下');
|
|
374
|
+
return { ok: false, error: 'CLI 还没起来' };
|
|
375
|
+
}
|
|
376
|
+
if (session.status === 'running') {
|
|
377
|
+
say(session, 'info', '上一轮还没结束,先停一下再发');
|
|
378
|
+
return { ok: false, error: '上一轮还没结束' };
|
|
379
|
+
}
|
|
380
|
+
if (pictures.length > 0 && session.handle.supportsImages === false) {
|
|
381
|
+
say(session, 'error', `${session.label} 不接受图片输入,这一发没带图`);
|
|
382
|
+
return { ok: false, error: '这个 CLI 不接受图片' };
|
|
383
|
+
}
|
|
384
|
+
markBlock(session, session.blocks.pushUser(message, Date.now(), pictures));
|
|
385
|
+
flushNow(session);
|
|
386
|
+
// 宿主还没给过标题就用首条提问兜一个 —— 侧栏那一行不能一直写着 CLI 的名字。
|
|
387
|
+
// 只发了图没写字的:标题留给适配器自己(它多半会按上下文生成一个)。
|
|
388
|
+
if (message && session.title === session.label) {
|
|
389
|
+
setTitle(session, message.length > TITLE_MAX ? `${message.slice(0, TITLE_MAX)}…` : message);
|
|
390
|
+
}
|
|
391
|
+
setStatus(session, 'running');
|
|
392
|
+
const handle = session.handle;
|
|
393
|
+
const startedAt = Date.now();
|
|
394
|
+
handle.prompt(message, pictures).then(
|
|
395
|
+
(result) => {
|
|
396
|
+
// 用量先记:`/session/stats` 就在这一轮之后被 App 拉一次,记晚了那一发读到的是上一轮。
|
|
397
|
+
applyPromptUsage(session.usage, result, Date.now() - startedAt);
|
|
398
|
+
// 顺序:先让落定的块出去,再播 idle。反过来的话最后那一段正文会落在 `turn/end`
|
|
399
|
+
// 之后,App 收到它会把已经收尾的这一轮重新认成「在跑」(见 cli/mobile.js 的 onBlock)。
|
|
400
|
+
for (const block of session.blocks.settle(Date.now())) markBlock(session, block);
|
|
401
|
+
flushNow(session);
|
|
402
|
+
if (!session.finished) setStatus(session, 'idle');
|
|
403
|
+
},
|
|
404
|
+
(error) => {
|
|
405
|
+
const reason = error && error.message ? error.message : '这一轮失败了';
|
|
406
|
+
for (const block of session.blocks.settle(Date.now())) markBlock(session, block);
|
|
407
|
+
flushNow(session);
|
|
408
|
+
say(session, 'error', reason);
|
|
409
|
+
setStatus(session, handle.alive ? 'idle' : 'error');
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
return { ok: true };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 把「这个 CLI 上次选的模型」套到刚起来的这条会话上。
|
|
417
|
+
*
|
|
418
|
+
* ACP 的模型是会话级配置,不套的话每次新会话都回到 CLI 自己的默认,用户每一轮都要重选一次
|
|
419
|
+
* —— 普通会话里没有这种事(宿主那边的默认模型是设备级的)。值在新会话的目录里已经不存在
|
|
420
|
+
* (CLI 升级换了名字)时不设,留在默认上,别硬塞一个它不认识的。
|
|
421
|
+
*/
|
|
422
|
+
/**
|
|
423
|
+
* 把用户在配置页定下的那一套套到这条新会话上(见 `cli/config-prefs.js`)。
|
|
424
|
+
*
|
|
425
|
+
* 顺序:先模型(`model-prefs` 那本老账),再其余各项(档位 / 权限模式 / 快速档)。逐项都是
|
|
426
|
+
* 「新会话里有这一项、且这个值还在它的选项里」才写,写不动就跳过 —— 一条过期的偏好不该让
|
|
427
|
+
* 会话建不起来,也不该把用户静默留在 CLI 的默认上还以为是自己的设置。
|
|
428
|
+
*
|
|
429
|
+
* 每一项的回包都带完整的新 `configOptions`,直接用它:手机随后那次目录请求读到的就是最终值。
|
|
430
|
+
*/
|
|
431
|
+
async function applyRecordedPrefs(session, handle) {
|
|
432
|
+
await applyPreferredModel(session, handle);
|
|
433
|
+
for (const [configId, value] of pendingPrefsOf(session)) {
|
|
434
|
+
try {
|
|
435
|
+
const updated = await handle.setConfigOption(configId, value);
|
|
436
|
+
if (updated && Array.isArray(updated.configOptions)) session.configOptions = updated.configOptions;
|
|
437
|
+
} catch {
|
|
438
|
+
// 这一项写不进去:停在 CLI 自己的默认,别连累其余几项。
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function applyPreferredModel(session, handle) {
|
|
444
|
+
const preferred = preferredModel(session.agentId);
|
|
445
|
+
if (!preferred) return;
|
|
446
|
+
const options = Array.isArray(session.configOptions) ? session.configOptions : [];
|
|
447
|
+
const option = options.find((item) => item && item.id === preferred.configId);
|
|
448
|
+
const values = option && Array.isArray(option.options) ? option.options : [];
|
|
449
|
+
if (!option || !values.some((item) => item && String(item.value) === preferred.value)) return;
|
|
450
|
+
if (String(option.currentValue) === preferred.value) return;
|
|
451
|
+
try {
|
|
452
|
+
const updated = await handle.setConfigOption(preferred.configId, preferred.value);
|
|
453
|
+
// 回包带着新的一整份配置项:直接用它,手机上那次目录请求就能看见已选中。
|
|
454
|
+
if (updated && Array.isArray(updated.configOptions)) session.configOptions = updated.configOptions;
|
|
455
|
+
log(`cli ${session.id}: 套上了上次选的模型(${preferred.value})`);
|
|
456
|
+
} catch {
|
|
457
|
+
// 设不动就维持 CLI 的默认:这不是失败,界面上照旧能选。
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function answerPermission(session, id, optionId) {
|
|
462
|
+
const pending = session.permissions.get(id);
|
|
463
|
+
if (!pending || !optionId) return false;
|
|
464
|
+
pending({ outcome: { outcome: 'selected', optionId } });
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function cancelTurn(session) {
|
|
469
|
+
try { session.handle?.cancel(); } catch { /* 已经没了 */ }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function forget(sessionId) {
|
|
473
|
+
const session = sessions.get(sessionId);
|
|
474
|
+
if (!session) return false;
|
|
475
|
+
session.closed = true;
|
|
476
|
+
sessions.delete(sessionId);
|
|
477
|
+
if (session.timer) { clearTimeout(session.timer); session.timer = null; }
|
|
478
|
+
for (const [, pending] of session.permissions) {
|
|
479
|
+
try { pending({ outcome: { outcome: 'cancelled' } }); } catch { /* 已经没了 */ }
|
|
480
|
+
}
|
|
481
|
+
session.permissions.clear();
|
|
482
|
+
try { session.handle?.close(); } catch { /* 可能已经退出 */ }
|
|
483
|
+
for (const ws of session.sockets) {
|
|
484
|
+
try { ws.close(); } catch { /* 已经关了 */ }
|
|
485
|
+
}
|
|
486
|
+
session.sockets.clear();
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* GET cli/agents:这台电脑上**装了、现在能聊**的 CLI。
|
|
492
|
+
*
|
|
493
|
+
* 清单来自官方 ACP 注册表(见 `cli/registry.js`),拉不到就退回内置的四家。建会话时按同一
|
|
494
|
+
* 份清单回头查,所以「列出来的」与「点得下去的」永远是同一批。
|
|
495
|
+
*
|
|
496
|
+
* 两条口径:
|
|
497
|
+
*
|
|
498
|
+
* 1. **注册表补进来的那些要求本机真的装了**(`isInstalled`)。注册表是「npx 能拉哪些」,
|
|
499
|
+
* 不是「这台电脑上有哪些」;照单全列,手机上就是一屏从没装过、图标还认不出来的
|
|
500
|
+
* 条目 —— 用户会以为是我们编的(2026-11-04 用户报的就是这个)。内置那四条不受此限:
|
|
501
|
+
* 它们是本仓维护的那一档,桥包由 npx 按需拉。
|
|
502
|
+
* 2. **图标跟着走**:每家一枚官方 SVG(`cli/icons.js` 本地缓存),认不出的那几家退成
|
|
503
|
+
* 首字母方块 —— 由 App 兜,插件这边只给能给的。
|
|
504
|
+
*/
|
|
505
|
+
async function handleAgents(_ctx, _req, res) {
|
|
506
|
+
const listed = await listAgents();
|
|
507
|
+
const builtin = new Set(AGENTS.map((agent) => agent.id));
|
|
508
|
+
const installed = installedPackages();
|
|
509
|
+
const kept = listed.filter((agent) => builtin.has(agent.id) || isInstalled(agent, installed));
|
|
510
|
+
const icons = await loadIcons(listed, kept.map((agent) => agent.id)).catch(() => ({}));
|
|
511
|
+
const agents = kept.map((agent) => {
|
|
512
|
+
const spec = resolveAgent(agent);
|
|
513
|
+
return {
|
|
514
|
+
id: agent.id,
|
|
515
|
+
label: agent.label,
|
|
516
|
+
available: spec.ok,
|
|
517
|
+
...(spec.ok ? {} : { reason: spec.reason }),
|
|
518
|
+
...(agent.description ? { description: agent.description } : {}),
|
|
519
|
+
...(icons[agent.id] ? { icon: icons[agent.id] } : {}),
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
sendJson(res, 200, { ok: true, agents });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* POST cli/session { workspaceId, agent }
|
|
527
|
+
*
|
|
528
|
+
* 工作目录从工作区清单里取:手机只给 `workspaceId`,路径由宿主自己填 —— 与终端、Git
|
|
529
|
+
* 同一个口径,没有一条能让调用方指定任意路径的路。
|
|
530
|
+
*/
|
|
531
|
+
async function handleCreate(ctx, req, res) {
|
|
532
|
+
const body = await bodyJson(req);
|
|
533
|
+
if (closed) throw fail('UNSUPPORTED', '宿主正在关闭,稍后再试');
|
|
534
|
+
|
|
535
|
+
const workspaceId = text(body.workspaceId);
|
|
536
|
+
if (!workspaceId) throw fail('BAD_REQUEST', '需要 workspaceId');
|
|
537
|
+
|
|
538
|
+
const agent = (await listAgents()).find((item) => item && item.id === text(body.agent)) || null;
|
|
539
|
+
if (!agent) throw fail('BAD_REQUEST', '不认识这个 CLI');
|
|
540
|
+
const spec = resolveAgent(agent);
|
|
541
|
+
if (!spec.ok) throw fail('AGENT_UNAVAILABLE', spec.reason);
|
|
542
|
+
|
|
543
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
544
|
+
throw fail('TOO_MANY_SESSIONS', '同时开着的 CLI 太多了,先关掉几个');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const listed = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
|
|
548
|
+
const { value, error } = unwrap(listed);
|
|
549
|
+
if (error) throw fail('WORKSPACE_UNAVAILABLE', '取不到工作区清单,稍后再试');
|
|
550
|
+
const items = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : []);
|
|
551
|
+
const item = items.find((entry) => entry && entry.workspaceId === workspaceId);
|
|
552
|
+
if (!item) throw fail('FORBIDDEN', '这个工作区不在这台机器上');
|
|
553
|
+
const cwd = text(item.path);
|
|
554
|
+
if (!cwd) throw fail('FORBIDDEN', '这个工作区没有对应目录');
|
|
555
|
+
|
|
556
|
+
seq += 1;
|
|
557
|
+
const id = `cli-${Date.now().toString(36)}-${seq.toString(36)}`;
|
|
558
|
+
const session = {
|
|
559
|
+
id,
|
|
560
|
+
workspaceId,
|
|
561
|
+
agentId: agent.id,
|
|
562
|
+
label: agent.label,
|
|
563
|
+
cwd,
|
|
564
|
+
title: agent.label,
|
|
565
|
+
status: 'starting',
|
|
566
|
+
createdAt: Date.now(),
|
|
567
|
+
updatedAt: Date.now(),
|
|
568
|
+
blocks: createBlockLog(),
|
|
569
|
+
configOptions: [],
|
|
570
|
+
modes: null,
|
|
571
|
+
commands: [],
|
|
572
|
+
sockets: new Set(),
|
|
573
|
+
dirty: new Map(),
|
|
574
|
+
timer: null,
|
|
575
|
+
handle: null,
|
|
576
|
+
finished: false,
|
|
577
|
+
closed: false,
|
|
578
|
+
permissions: new Map(),
|
|
579
|
+
permissionFrames: new Map(),
|
|
580
|
+
sinks: new Set(),
|
|
581
|
+
/** 用量账(上下文 / token / 耗时),见 `cli/usage.js`。 */
|
|
582
|
+
usage: createUsage(),
|
|
583
|
+
};
|
|
584
|
+
sessions.set(id, session);
|
|
585
|
+
// 新行要立刻出现在侧栏上:不然手机得等下一拍兜底轮询(推送活着时是 3 分钟)。
|
|
586
|
+
mobile.bumpList();
|
|
587
|
+
|
|
588
|
+
// 不 await:见文件头第 1 点。
|
|
589
|
+
void boot(session, spec);
|
|
590
|
+
|
|
591
|
+
sendJson(res, 200, { ok: true, session: detailOf(session) });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** GET cli/sessions:宿主上还活着的会话(侧栏与页面重进都读它)。 */
|
|
595
|
+
function handleSessions(_ctx, _req, res) {
|
|
596
|
+
const list = [...sessions.values()].map((session) => {
|
|
597
|
+
const detail = detailOf(session);
|
|
598
|
+
delete detail.blocks;
|
|
599
|
+
return detail;
|
|
600
|
+
});
|
|
601
|
+
sendJson(res, 200, { ok: true, sessions: list });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** 按 sessionId 取一条会话,取不到就 404。 */
|
|
605
|
+
function mustSession(id) {
|
|
606
|
+
const session = sessions.get(text(id));
|
|
607
|
+
if (!session) throw fail('NOT_FOUND', '这条会话已经不在了');
|
|
608
|
+
return session;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* GET cli/session?sessionId=:一条会话的全貌(块、配置、命令、状态)。
|
|
613
|
+
*
|
|
614
|
+
* 页面靠它做兜底轮询:隧道上偶尔会出现「WS 握手成了、帧一个都没进来」的形状,只靠推送
|
|
615
|
+
* 的话界面就一直是空的、发出去的消息也看不到结果。
|
|
616
|
+
*/
|
|
617
|
+
function handleDetail(_ctx, req, res) {
|
|
618
|
+
const session = mustSession(query(req).get('sessionId'));
|
|
619
|
+
sendJson(res, 200, { ok: true, session: detailOf(session) });
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** POST cli/prompt { sessionId, text }:发一轮(WS 那条的 HTTP 版本)。 */
|
|
623
|
+
async function handlePrompt(_ctx, req, res) {
|
|
624
|
+
const body = await bodyJson(req);
|
|
625
|
+
const session = mustSession(body.sessionId);
|
|
626
|
+
const result = runPrompt(session, text(body.text));
|
|
627
|
+
if (!result.ok) throw fail('BAD_REQUEST', result.error);
|
|
628
|
+
sendJson(res, 200, { ok: true });
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** POST cli/permission { sessionId, id, optionId }:回答一条挂着的审批。 */
|
|
632
|
+
async function handlePermission(_ctx, req, res) {
|
|
633
|
+
const body = await bodyJson(req);
|
|
634
|
+
const session = mustSession(body.sessionId);
|
|
635
|
+
if (!answerPermission(session, text(body.id), text(body.optionId))) {
|
|
636
|
+
throw fail('NOT_FOUND', '这条审批已经不在了');
|
|
637
|
+
}
|
|
638
|
+
sendJson(res, 200, { ok: true });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** POST cli/cancel { sessionId }:停掉这一轮(会话不杀)。 */
|
|
642
|
+
async function handleCancel(_ctx, req, res) {
|
|
643
|
+
const body = await bodyJson(req);
|
|
644
|
+
const session = mustSession(body.sessionId);
|
|
645
|
+
cancelTurn(session);
|
|
646
|
+
sendJson(res, 200, { ok: true });
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* POST cli/config { sessionId, configId?, value?, modeId? }
|
|
651
|
+
*
|
|
652
|
+
* 选模型 / 换思考档走 `configId + value`,换模式走 `modeId` —— 两者在 ACP 里是两条方法
|
|
653
|
+
* (`session/set_config_option` 与 `session/set_mode`),这里按传了哪个分派。
|
|
654
|
+
*/
|
|
655
|
+
async function handleConfig(_ctx, req, res) {
|
|
656
|
+
const body = await bodyJson(req);
|
|
657
|
+
const session = mustSession(body.sessionId);
|
|
658
|
+
if (!session.handle) throw fail('BAD_REQUEST', 'CLI 还没起来');
|
|
659
|
+
const modeId = text(body.modeId);
|
|
660
|
+
const configId = text(body.configId);
|
|
661
|
+
try {
|
|
662
|
+
if (modeId) await session.handle.setMode(modeId);
|
|
663
|
+
else if (configId) {
|
|
664
|
+
const updated = await session.handle.setConfigOption(configId, body.value);
|
|
665
|
+
if (updated && Array.isArray(updated.configOptions)) session.configOptions = updated.configOptions;
|
|
666
|
+
// 选过的模型要记住:下一次在这个 CLI 上开新会话时自动套上(见 applyPreferredModel)。
|
|
667
|
+
// 只记模型那一项 —— 思考档 / 权限模式跟着模型走,单独记会把两边记岔。
|
|
668
|
+
const option = (session.configOptions || []).find((item) => item && item.id === configId);
|
|
669
|
+
if (option && option.category === 'model') rememberModel(session.agentId, configId, body.value);
|
|
670
|
+
// 每一项都记:这是「下次开局自动套上」的唯一来源(见 `cli/config-prefs.js`)。
|
|
671
|
+
if (option) setValue(session.agentId, configId, text(body.value));
|
|
672
|
+
} else throw fail('BAD_REQUEST', '需要 configId 或 modeId');
|
|
673
|
+
} catch (error) {
|
|
674
|
+
if (error && error.code && STATUS_BY_CODE[error.code]) throw error;
|
|
675
|
+
throw fail('SPAWN_FAILED', error && error.message ? error.message : '改不了这一项');
|
|
676
|
+
}
|
|
677
|
+
sendJson(res, 200, { ok: true, session: detailOf(session) });
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* GET /cli/prefs?agent=:一个 CLI 的配置全貌(配置页读它)。
|
|
682
|
+
*
|
|
683
|
+
* 配置页在**没有开会话**的时候就要能画:所以这里回的是「上次记下的选项表 + 记着的值」,
|
|
684
|
+
* 一个进程都不用起。`live` 说的是这份表是从正在跑的那条会话读的(更新),还是缓存(CLI 那之后
|
|
685
|
+
* 可能升级过)。
|
|
686
|
+
*/
|
|
687
|
+
function handlePrefs(_ctx, req, res) {
|
|
688
|
+
const agentId = text(query(req).get('agent'));
|
|
689
|
+
if (!agentId) throw fail('BAD_REQUEST', '需要 agent');
|
|
690
|
+
const recorded = prefsOf(agentId);
|
|
691
|
+
// 正开着这个 CLI 的会话:拿它手上的选项表,比缓存新。
|
|
692
|
+
let options = recorded.options;
|
|
693
|
+
let live = false;
|
|
694
|
+
for (const session of sessions.values()) {
|
|
695
|
+
if (session.agentId !== agentId) continue;
|
|
696
|
+
if (session.status === 'starting') continue;
|
|
697
|
+
const described = describe(session.configOptions);
|
|
698
|
+
if (described.length === 0) continue;
|
|
699
|
+
options = described;
|
|
700
|
+
live = true;
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
sendJson(res, 200, {
|
|
704
|
+
ok: true,
|
|
705
|
+
agent: agentId,
|
|
706
|
+
options,
|
|
707
|
+
values: recorded.values,
|
|
708
|
+
live,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 读写同一条 `/cli/prefs`(与过程播报那条同一个做法):GET 读,POST 写。
|
|
714
|
+
*/
|
|
715
|
+
async function handlePrefsRoute(ctx, req, res) {
|
|
716
|
+
if (req && String(req.method || 'GET').toUpperCase() === 'POST') return handleSetPref(ctx, req, res);
|
|
717
|
+
return handlePrefs(ctx, req, res);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* POST /cli/prefs { agent, configId, value }:单独改一项默认配置。
|
|
722
|
+
*
|
|
723
|
+
* 与 `cli/config` 的区别:那条**改的是一条正在跑的会话**(顺带记下来),这条只改「以后新开的
|
|
724
|
+
* 那几条」。配置页在没有会话时用它,所以它也不启动任何进程。
|
|
725
|
+
*
|
|
726
|
+
* `value` 空 = 撤掉这一项,回到 CLI 自己的默认。
|
|
727
|
+
*/
|
|
728
|
+
async function handleSetPref(_ctx, req, res) {
|
|
729
|
+
const body = await bodyJson(req);
|
|
730
|
+
const agentId = text(body.agent);
|
|
731
|
+
const configId = text(body.configId);
|
|
732
|
+
if (!agentId || !configId) throw fail('BAD_REQUEST', '需要 agent 与 configId');
|
|
733
|
+
setValue(agentId, configId, text(body.value));
|
|
734
|
+
sendJson(res, 200, { ok: true, agent: agentId, values: prefsOf(agentId).values });
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** POST cli/close { sessionId }:主动收掉一个会话(用户点「结束」)。 */
|
|
738
|
+
async function handleClose(_ctx, req, res) {
|
|
739
|
+
const body = await bodyJson(req);
|
|
740
|
+
const id = text(body.sessionId);
|
|
741
|
+
if (!id) throw fail('BAD_REQUEST', '需要 sessionId');
|
|
742
|
+
if (forget(id)) mobile.bumpList();
|
|
743
|
+
sendJson(res, 200, { ok: true });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* WebSocket 升级:`?session=<id>`。
|
|
748
|
+
*
|
|
749
|
+
* 与终端那条同一套:凭据优先看**请求头**(连接建在 RN 侧,中转靠这些头决定转给哪台电脑),
|
|
750
|
+
* 头没有再把查询串 `cred`/`token` 折成一次普通鉴权判定。拒绝要发生在 `handleUpgrade`
|
|
751
|
+
* 之前 —— 那时 socket 还是普通 HTTP 连接。
|
|
752
|
+
*/
|
|
753
|
+
function handleUpgrade(req, socket, head) {
|
|
754
|
+
if (closed) return refuseUpgrade(socket, 503, 'Service Unavailable', 'CLI 服务已关闭');
|
|
755
|
+
|
|
756
|
+
const params = query(req);
|
|
757
|
+
const id = text(params.get('session'));
|
|
758
|
+
if (!id) return refuseUpgrade(socket, 400, 'Bad Request', '缺少 session 参数');
|
|
759
|
+
const token = text(params.get('cred')) || text(params.get('token'));
|
|
760
|
+
|
|
761
|
+
let allowed = false;
|
|
762
|
+
try {
|
|
763
|
+
allowed = Boolean(authorized && (authorized(req) || (token && authorized({
|
|
764
|
+
headers: { 'x-dsh-device-credential': token, authorization: `Bearer ${token}` },
|
|
765
|
+
}))));
|
|
766
|
+
} catch {
|
|
767
|
+
allowed = false;
|
|
768
|
+
}
|
|
769
|
+
if (!allowed) {
|
|
770
|
+
log(`cli ${id}: 凭据无效,拒绝升级`);
|
|
771
|
+
return refuseUpgrade(socket, 401, 'Unauthorized', '移动端凭据无效');
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const session = sessions.get(id);
|
|
775
|
+
if (!session) {
|
|
776
|
+
log(`cli ${id}: 会话不在表里,拒绝升级`);
|
|
777
|
+
return refuseUpgrade(socket, 404, 'Not Found', '这条会话已经不在了');
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
wss.handleUpgrade(req, socket, head, (ws) => attach(ws, session));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function attach(ws, session) {
|
|
784
|
+
session.sockets.add(ws);
|
|
785
|
+
log(`cli ${session.id}: 手机接上了(第 ${session.sockets.size} 条连接)`);
|
|
786
|
+
|
|
787
|
+
// 先补全貌:页面重进 / 断线重连回来看到的仍是原来那一屏。
|
|
788
|
+
send(ws, { t: 'hello', session: detailOf(session) });
|
|
789
|
+
// 挂着的审批错过一次就再也点不到了,接上时重发一遍。
|
|
790
|
+
for (const request of session.permissionFrames.values()) send(ws, { t: 'permission', request });
|
|
791
|
+
if (session.finished) send(ws, { t: 'exit', reason: 'CLI 已经退出' });
|
|
792
|
+
|
|
793
|
+
ws.on('message', (raw) => {
|
|
794
|
+
let frame = null;
|
|
795
|
+
try { frame = JSON.parse(String(raw)); } catch { return; }
|
|
796
|
+
if (!frame || typeof frame !== 'object') return;
|
|
797
|
+
if (frame.t === 'prompt') { runPrompt(session, text(frame.text)); return; }
|
|
798
|
+
if (frame.t === 'permission') { answerPermission(session, text(frame.id), text(frame.optionId)); return; }
|
|
799
|
+
if (frame.t === 'cancel') { cancelTurn(session); }
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
ws.on('close', () => {
|
|
803
|
+
session.sockets.delete(ws);
|
|
804
|
+
log(`cli ${session.id}: 手机断开了(剩 ${session.sockets.size} 条连接)`);
|
|
805
|
+
});
|
|
806
|
+
ws.on('error', () => { session.sockets.delete(ws); });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function close() {
|
|
810
|
+
closed = true;
|
|
811
|
+
for (const id of [...sessions.keys()]) forget(id);
|
|
812
|
+
try { wss.close(); } catch { /* 已经关了 */ }
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** 路由包装:把领域错误翻成 HTTP 状态,与终端那一份同口径。 */
|
|
816
|
+
function wrap(handler) {
|
|
817
|
+
return async (ctx, req, res) => {
|
|
818
|
+
try {
|
|
819
|
+
await handler(ctx, req, res);
|
|
820
|
+
} catch (error) {
|
|
821
|
+
const code = error && typeof error.code === 'string' && STATUS_BY_CODE[error.code]
|
|
822
|
+
? error.code
|
|
823
|
+
: 'SPAWN_FAILED';
|
|
824
|
+
const status = STATUS_BY_CODE[code] || 500;
|
|
825
|
+
const message = error && typeof error.message === 'string' && error.message
|
|
826
|
+
? error.message
|
|
827
|
+
: 'CLI 操作失败';
|
|
828
|
+
log(`cli: ${code} ${message}`);
|
|
829
|
+
sendJson(res, status, { ok: false, error: { code, message } });
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* 让 `cli-*` 会话走宿主普通会话那一整套移动端接口的适配层(见 `cli/mobile.js`)。
|
|
836
|
+
*
|
|
837
|
+
* 构造在这里而不是装配层:它要读的会话表、发一轮、答审批、改标题全在这个闭包里。装配层
|
|
838
|
+
* 只负责把端点接上(`mobile-api.js` 按 sessionId 前缀分派)。
|
|
839
|
+
*/
|
|
840
|
+
const mobile = createCliMobile({
|
|
841
|
+
sessions,
|
|
842
|
+
runPrompt,
|
|
843
|
+
answerPermission,
|
|
844
|
+
cancelTurn,
|
|
845
|
+
setTitle,
|
|
846
|
+
watch,
|
|
847
|
+
// 「清空已归档」要把 CLI 这边的归档会话当场收掉(没有磁盘日志可清)。
|
|
848
|
+
forget,
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
return {
|
|
852
|
+
mobile,
|
|
853
|
+
handleAgents: wrap(handleAgents),
|
|
854
|
+
handleCreate: wrap(handleCreate),
|
|
855
|
+
handleSessions: wrap(handleSessions),
|
|
856
|
+
handleDetail: wrap(handleDetail),
|
|
857
|
+
handlePrompt: wrap(handlePrompt),
|
|
858
|
+
handlePermission: wrap(handlePermission),
|
|
859
|
+
handleCancel: wrap(handleCancel),
|
|
860
|
+
handleConfig: wrap(handleConfig),
|
|
861
|
+
handlePrefs: wrap(handlePrefsRoute),
|
|
862
|
+
handleClose: wrap(handleClose),
|
|
863
|
+
handleUpgrade,
|
|
864
|
+
close,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** 拒绝升级:这时 socket 还是一条普通 HTTP 连接,老实回一个状态码就行。 */
|
|
869
|
+
function refuseUpgrade(socket, status, reason, text_) {
|
|
870
|
+
if (!socket || socket.destroyed) return;
|
|
871
|
+
const body = Buffer.from(text_, 'utf8');
|
|
872
|
+
socket.write(
|
|
873
|
+
`HTTP/1.1 ${status} ${reason}\r\n`
|
|
874
|
+
+ 'Connection: close\r\n'
|
|
875
|
+
+ 'Content-Type: text/plain; charset=utf-8\r\n'
|
|
876
|
+
+ `Content-Length: ${body.length}\r\n\r\n`,
|
|
877
|
+
);
|
|
878
|
+
socket.write(body);
|
|
879
|
+
socket.end();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* 装配层只调这一个(`impl.js` 有行数上限,装配代码不许长回来):建服务 + 挂清理。
|
|
884
|
+
*
|
|
885
|
+
* 清理必须挂 `ctx.effect`:插件热替换之后旧实例没人能关,适配器子进程会一直挂到宿主退出。
|
|
886
|
+
*/
|
|
887
|
+
function mountCli(ctx, { authorized, name } = {}) {
|
|
888
|
+
const service = createCliService({
|
|
889
|
+
authorized,
|
|
890
|
+
onLog: (message) => ctx.logger?.info?.(`${name}: %s`, message),
|
|
891
|
+
});
|
|
892
|
+
ctx.effect(() => () => service.close(), `${name}: cli sessions`);
|
|
893
|
+
return service;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
module.exports = {
|
|
897
|
+
mountCli,
|
|
898
|
+
createCliService,
|
|
899
|
+
pendingPrefsOf,
|
|
900
|
+
MAX_SESSIONS,
|
|
901
|
+
};
|