dsh-mobile-plugin 0.1.93 → 0.1.95
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 +1 -1
- package/lib/desktop-view.cjs +20 -0
- package/lib/impl.js +1 -1
- package/lib/mirror/pusher.cjs +40 -0
- package/lib/mobile/archive-clean.js +17 -18
- package/lib/mobile/completion.js +33 -25
- package/lib/mobile/events.js +28 -3
- package/lib/mobile/host-bridge/endpoints.js +0 -1
- package/lib/mobile/host-bridge/event-synthesis.js +33 -3
- package/lib/mobile/host-bridge/shims.js +25 -2
- package/lib/mobile/monitor.js +135 -0
- package/lib/mobile/plugin-update.js +199 -0
- package/lib/mobile/session-cache.js +18 -0
- package/lib/mobile/session-flags.js +63 -0
- package/lib/mobile/session-last-turn.js +106 -0
- package/lib/mobile/session-send.js +4 -0
- package/lib/mobile/session-snapshot.js +9 -17
- package/lib/mobile/sessions.js +16 -39
- package/lib/mobile/stream.js +69 -11
- package/lib/panel/index.html +1 -1
- package/lib/reconnect-scheduler.cjs +31 -4
- package/lib/relay/lan-proxy.js +10 -2
- package/lib/relay/protocol.js +3 -3
- package/lib/relay/tunnel.js +17 -4
- package/lib/routes/control-plane.js +15 -4
- package/lib/routes/mobile-api.js +27 -1
- package/lib/session-cleanup.cjs +160 -27
- package/lib/shared/desktop-view-signal.js +66 -0
- package/lib/shared/http-util.js +50 -4
- package/lib/shared/sse-write.js +52 -2
- package/lib/sidecar-facade-worker.cjs +16 -2
- package/package.json +1 -1
|
@@ -9,20 +9,47 @@
|
|
|
9
9
|
*
|
|
10
10
|
* 这里把「同一个待触发窗口内只保留一个定时器」这一不变量收敛成一个模块,
|
|
11
11
|
* 以便用毫秒级延迟做真实的行为测试。端到端数连接次数是分不出来的:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* startTunnel 开头的 stopTunnel 会吸收重复定时器造成的大部分可见差异。
|
|
13
|
+
*
|
|
14
|
+
* 延迟本身带**抖动**与**退避**:
|
|
15
|
+
* - 抖动:中转重启(或宿主机休眠醒来)之后,所有宿主都会在同一刻掉线,固定 5s 会让
|
|
16
|
+
* 它们踩着同一个点回来;上下抖两千毫秒就把这一波摊开。
|
|
17
|
+
* - 退避:连续失败(中转没起来、注册密钥不对)时不该每 5 秒敲一次,逐次翻倍到上限;
|
|
18
|
+
* 一次成功的注册由调用方 `reset()` 清账。
|
|
14
19
|
*/
|
|
15
|
-
|
|
20
|
+
|
|
21
|
+
/** 连续失败最多翻到 delayMs 的 2^BACKOFF_EXP_CAP 倍(再被 maxDelayMs 压住)。 */
|
|
22
|
+
const BACKOFF_EXP_CAP = 5
|
|
23
|
+
|
|
24
|
+
function createReconnectScheduler({
|
|
25
|
+
delayMs,
|
|
26
|
+
jitterMs = 0,
|
|
27
|
+
maxDelayMs = Number.POSITIVE_INFINITY,
|
|
28
|
+
random = Math.random,
|
|
29
|
+
onFire,
|
|
30
|
+
setTimer = setTimeout,
|
|
31
|
+
clearTimer = clearTimeout,
|
|
32
|
+
} = {}) {
|
|
16
33
|
if (typeof delayMs !== 'number' || !(delayMs >= 0)) throw new Error('invalid delayMs')
|
|
34
|
+
if (typeof jitterMs !== 'number' || !(jitterMs >= 0)) throw new Error('invalid jitterMs')
|
|
35
|
+
if (typeof maxDelayMs !== 'number' || !(maxDelayMs > 0)) throw new Error('invalid maxDelayMs')
|
|
36
|
+
if (typeof random !== 'function') throw new Error('invalid random')
|
|
17
37
|
if (typeof onFire !== 'function') throw new Error('invalid onFire')
|
|
18
38
|
let timer = null
|
|
39
|
+
let misses = 0
|
|
19
40
|
return {
|
|
20
41
|
/** 排程一次重连;已有待触发的定时器时返回 false,表示被幂等吸收。 */
|
|
21
42
|
schedule() {
|
|
22
43
|
if (timer !== null) return false
|
|
23
|
-
|
|
44
|
+
const backoff = misses === 0 ? 1 : 2 ** Math.min(misses, BACKOFF_EXP_CAP)
|
|
45
|
+
const base = Math.min(delayMs * backoff, maxDelayMs)
|
|
46
|
+
const wait = base + (jitterMs > 0 ? Math.floor(random() * jitterMs) : 0)
|
|
47
|
+
misses += 1
|
|
48
|
+
timer = setTimer(() => { timer = null; onFire() }, wait)
|
|
24
49
|
return true
|
|
25
50
|
},
|
|
51
|
+
/** 连上之后清掉连续失败计数(下一次掉线重新从 delayMs 起算)。 */
|
|
52
|
+
reset() { misses = 0 },
|
|
26
53
|
/** 取消待触发的重连(显式停止隧道时调用)。 */
|
|
27
54
|
cancel() {
|
|
28
55
|
if (timer === null) return
|
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -17,6 +17,7 @@ const core = require('./core.js');
|
|
|
17
17
|
const { isGlobalUnicastV6 } = require('./lan-network.cjs');
|
|
18
18
|
const { safeEqual } = require('../shared/dshtoken');
|
|
19
19
|
const { isFreshRequest } = require('../shared/fresh-request');
|
|
20
|
+
const { encodeJsonBody } = require('../shared/http-util');
|
|
20
21
|
const credentials = require('../shared/credentials-entry.js').credentialsEntry();
|
|
21
22
|
|
|
22
23
|
const HOP_BY_HOP = new Set([
|
|
@@ -200,8 +201,15 @@ function createLanProxy(opts) {
|
|
|
200
201
|
const body = isFreshRequest(req.url) ? null : readSnapshot({ method: req.method, path: req.url });
|
|
201
202
|
if (body && body.ok) {
|
|
202
203
|
try {
|
|
203
|
-
|
|
204
|
-
|
|
204
|
+
// 与中转那条同一条捷径:列表 / 工作区 / 消息 / 统计都在这里回,压缩才省得下来。
|
|
205
|
+
const encoded = encodeJsonBody(JSON.stringify(body), req.headers['accept-encoding']);
|
|
206
|
+
res.writeHead(200, {
|
|
207
|
+
'content-type': 'application/json; charset=utf-8',
|
|
208
|
+
'cache-control': 'no-store',
|
|
209
|
+
vary: 'accept-encoding',
|
|
210
|
+
...(encoded.gzip ? { 'content-encoding': 'gzip' } : {}),
|
|
211
|
+
});
|
|
212
|
+
res.end(encoded.body);
|
|
205
213
|
} catch { /* 已关闭 */ }
|
|
206
214
|
return;
|
|
207
215
|
}
|
package/lib/relay/protocol.js
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
* - 控制消息(register / httpHead 等)payload 为 UTF-8 JSON;
|
|
12
12
|
* - ping/pong 为应用层心跳(经代理/负载均衡时比 WS 原生 ping 更可靠)。
|
|
13
13
|
*
|
|
14
|
-
* 协议语义见 ../../../server/dsh-mobile-server/PROTOCOL.md
|
|
14
|
+
* 协议语义见 ../../../server/dsh-mobile-server/server/relay-core/PROTOCOL.md。
|
|
15
15
|
*
|
|
16
16
|
* 本文件是自包含副本(插件包不能跨目录引用中转仓库的文件),完整内联了共用类型表
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* 与编解码实现。类型表的事实源在独立中转仓库的 server/relay-core/protocol-types.js;
|
|
18
|
+
* 本仓的 protocol-parity 测试钉住线格式,中转仓库另做 Rust 与 JS 字节对撞;
|
|
19
19
|
* 改动帧编号后必须同步修改本文件。
|
|
20
20
|
*/
|
|
21
21
|
|
package/lib/relay/tunnel.js
CHANGED
|
@@ -14,6 +14,7 @@ const http = require('node:http');
|
|
|
14
14
|
const { WebSocket } = require('ws');
|
|
15
15
|
const { T, jsonFrame, encodeFrame, createFrameDecoder, parseJson, encodeClosePayload, decodeClosePayload } = require('./protocol.js');
|
|
16
16
|
const { isFreshRequest } = require('../shared/fresh-request');
|
|
17
|
+
const { encodeJsonBody } = require('../shared/http-util');
|
|
17
18
|
|
|
18
19
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
19
20
|
// Relay currently expires an idle tunnel after 60s. Keep a wider client-side
|
|
@@ -464,7 +465,10 @@ class RelayTunnel {
|
|
|
464
465
|
fn(arg);
|
|
465
466
|
}
|
|
466
467
|
};
|
|
467
|
-
|
|
468
|
+
// 中转侧是 tokio-tungstenite 手写的 101,从不回 `sec-websocket-extensions`,
|
|
469
|
+
// 所以 permessage-deflate 一次都没协商成功过;开着只会让人以为隧道压过。
|
|
470
|
+
// 真省字节靠的是响应体的 gzip(见 lib/shared/http-util.js)。
|
|
471
|
+
const ws = new WebSocket(this.relayUrl);
|
|
468
472
|
this.ws = ws;
|
|
469
473
|
const timer = setTimeout(() => {
|
|
470
474
|
done(reject, new Error('注册超时'));
|
|
@@ -572,9 +576,18 @@ class RelayTunnel {
|
|
|
572
576
|
if (this.readSnapshot && !isFreshRequest(msg.path) && (msg.method === 'GET' || msg.method === undefined)) {
|
|
573
577
|
const body = this.readSnapshot({ method: msg.method, path: msg.path });
|
|
574
578
|
if (body && body.ok) {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
this.queueHttpResponseFrame(id, entry, T.
|
|
579
|
+
// 这条捷径回的是列表 / 工作区 / 消息 / 统计这几条最重的读取,压缩的收益全在这儿。
|
|
580
|
+
const encoded = encodeJsonBody(JSON.stringify(body), msg.headers?.['accept-encoding']);
|
|
581
|
+
this.queueHttpResponseFrame(id, entry, T.httpHead, {
|
|
582
|
+
status: 200,
|
|
583
|
+
headers: {
|
|
584
|
+
'content-type': 'application/json; charset=utf-8',
|
|
585
|
+
'cache-control': 'no-store',
|
|
586
|
+
vary: 'accept-encoding',
|
|
587
|
+
...(encoded.gzip ? { 'content-encoding': 'gzip' } : {}),
|
|
588
|
+
},
|
|
589
|
+
}, true);
|
|
590
|
+
this.queueHttpResponseFrame(id, entry, T.httpChunk, encoded.body);
|
|
578
591
|
this.queueHttpResponseFrame(id, entry, T.httpEnd, Buffer.alloc(0));
|
|
579
592
|
return;
|
|
580
593
|
}
|
|
@@ -48,6 +48,7 @@ const { createSelfCheck } = require('../mobile/self-check.cjs');
|
|
|
48
48
|
const { checkForUpdate } = require('../update-registry.cjs');
|
|
49
49
|
const { declaredSource } = require('../update-install.cjs');
|
|
50
50
|
const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobile/session-cache.js');
|
|
51
|
+
const { recordSessionRead } = require('../mobile/session-last-turn.js');
|
|
51
52
|
|
|
52
53
|
/** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
|
|
53
54
|
const MAX_DESKTOP_READ_IDS = 200;
|
|
@@ -423,11 +424,21 @@ function registerControlPlaneRoutes(deps) {
|
|
|
423
424
|
if (!ids.length) return sendJson(res, 200, { ok: true, cleared: 0 })
|
|
424
425
|
if (!ctx) return sendJson(res, 200, { ok: true, cleared: 0 })
|
|
425
426
|
let cleared = 0
|
|
426
|
-
|
|
427
|
+
let advanced = 0
|
|
428
|
+
const at = Date.now()
|
|
429
|
+
for (const id of ids) {
|
|
430
|
+
cleared += clearCompletionForAllDevices(ctx, id, at)
|
|
431
|
+
// 「这一轮被读过了」还要记一笔**设备无关**的水位:手机侧的点现在由它自己按这份
|
|
432
|
+
// 水位算(见 mobile/session-last-turn.js)。只清按设备的提醒的话,桌面点开之后
|
|
433
|
+
// 手机上那个点永远不会灭 —— 那条提醒本来就不在手机读取的路径上。
|
|
434
|
+
if (recordSessionRead(ctx, id, at)) advanced += 1
|
|
435
|
+
}
|
|
427
436
|
desktopHalf.noteDesktopRead({ ids: ids.length, cleared })
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
437
|
+
// 作废列表缓存要抬 generation,让镜像推送器重建清单并广播 device/list-changed
|
|
438
|
+
// (见 A3-14)—— 只在**真有东西变了**时才调:已读水位进了下行清单,水位前进同样算
|
|
439
|
+
// 内容变了;但重复上报(客户端半边每 2s 报一批,多数都是旧的)绝不能每次都抬,
|
|
440
|
+
// 否则 App 的会话列表缓存会被永久压住。
|
|
441
|
+
if (cleared > 0 || advanced > 0) invalidateSessionList(ctx)
|
|
431
442
|
return sendJson(res, 200, { ok: true, cleared })
|
|
432
443
|
}) })
|
|
433
444
|
// The process-level error ledger (see lib/host-guard.cjs). "Stop an unknown
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -36,8 +36,10 @@ const deepSeekAccount = require('../mobile/deepseek-account.js');
|
|
|
36
36
|
const skills = require('../mobile/skills.js');
|
|
37
37
|
const mobileCredentials = require('../mobile/credentials.js');
|
|
38
38
|
const subagents = require('../mobile/subagents.js');
|
|
39
|
+
const monitor = require('../mobile/monitor.js');
|
|
39
40
|
const references = require('../mobile/references.js');
|
|
40
41
|
const plugins = require('../mobile/plugins.js');
|
|
42
|
+
const pluginUpdate = require('../mobile/plugin-update.js');
|
|
41
43
|
const uploads = require('../mobile/uploads.js');
|
|
42
44
|
const pairing = require('../mobile/pairing.js');
|
|
43
45
|
const { credentialsEntry } = require('../shared/credentials-entry.js');
|
|
@@ -58,6 +60,9 @@ const API_VERSION = '1';
|
|
|
58
60
|
* let 变量,闭包捕获对象会让 handler 永远读到启动那一刻的旧快照(lan.updatedAt 停在
|
|
59
61
|
* 进程启动时间,手机端看连接信息像失联了一样)。
|
|
60
62
|
* @param {(port: number) => Array<object>} deps.safeLanCandidates 局域网候选地址
|
|
63
|
+
* @param {(port: number) => Array<object>} deps.safeIpv6Candidates 公网 IPv6 直连候选。
|
|
64
|
+
* 与上面那份并列、不并进它:手机拿这份和中转实测比快慢,掺进局域网那份就等于让它
|
|
65
|
+
* 跟「同一个 Wi-Fi」共用一套失败退避(手机刚离开家里 Wi-Fi 时正是最该试它的时候)。
|
|
61
66
|
* @param {() => number} deps.lanPortInUse 手机该拨的局域网端口。**必须用取值器**:
|
|
62
67
|
* sidecar 自己 bind 到的端口可能与本进程解析出的不同(两条端口自适应),只认
|
|
63
68
|
* `state.lanPort` 会把这个接口变成「报一个没人监听的端口」——真机实测过 50933
|
|
@@ -71,7 +76,7 @@ const API_VERSION = '1';
|
|
|
71
76
|
* 一次拒绝或一次握手。
|
|
72
77
|
*/
|
|
73
78
|
function registerMobileRoutes(deps) {
|
|
74
|
-
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, lanPortInUse, preview, core, pairingApproval } = deps;
|
|
79
|
+
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval } = deps;
|
|
75
80
|
const route = (path, handler) => registerRoute({ kind: 'exact', path, handler: guarded(handler) })
|
|
76
81
|
|
|
77
82
|
registerRoute({ kind: 'exact', path: '/api/mobile/v1/status', handler: guarded(async (_ctx, _req, res) => {
|
|
@@ -135,6 +140,14 @@ function registerMobileRoutes(deps) {
|
|
|
135
140
|
const candidates = state.lanEnabled
|
|
136
141
|
? orderCandidates(safeLanCandidates(port), state.preferredLanAddress)
|
|
137
142
|
: []
|
|
143
|
+
// IPv6 直连候选:单独一份、**不参与**上面的排序 —— 它和中转谁更快只有手机实测得出,
|
|
144
|
+
// 宿主无从判断对端走哪条路由。与局域网那份同一个开关管(那条监听挂在同一个代理上),
|
|
145
|
+
// 关掉局域网直连就没有这条通道。
|
|
146
|
+
//
|
|
147
|
+
// 这份是手机配对之后**唯一**能拿到新 IPv6 地址的地方:二维码里带的候选只在配对那一刻
|
|
148
|
+
// 是真值,而隐私地址按小时轮换、运营商换前缀之后旧的那条就拨不通了。少回这一格,
|
|
149
|
+
// 手机上那条通道就只能靠配对当时的旧地址活着。
|
|
150
|
+
const ipv6 = state.lanEnabled ? safeIpv6Candidates(port) : []
|
|
138
151
|
const lanUpdatedAt = getSidecarSnapshot()?.lan?.updatedAt || Date.now()
|
|
139
152
|
sendJson(res, 200, {
|
|
140
153
|
ok: true,
|
|
@@ -143,12 +156,17 @@ function registerMobileRoutes(deps) {
|
|
|
143
156
|
relayUrl: state.enabled ? (state.relayUrl || null) : null,
|
|
144
157
|
lanUrls: candidates,
|
|
145
158
|
lanCandidates: candidates,
|
|
159
|
+
ipv6Candidates: ipv6,
|
|
146
160
|
updatedAt: lanUpdatedAt,
|
|
147
161
|
lastSeen: lanUpdatedAt,
|
|
148
162
|
})
|
|
149
163
|
})
|
|
150
164
|
|
|
151
165
|
route('/api/mobile/v1/sessions', sessions.handleSessions)
|
|
166
|
+
// 后台提醒服务专用:只回「在跑 / 刚完成 / 待回话 / 上一轮失败」这几格,
|
|
167
|
+
// 带 `?rev=` 条件刷新。它要替掉原生那边每 8 秒一发的完整清单(那份还得为 20 个
|
|
168
|
+
// 会话各读一次 history 补预览),见 mobile/monitor.js。
|
|
169
|
+
route('/api/mobile/v1/monitor', monitor.handleMonitor)
|
|
152
170
|
route('/api/mobile/v1/session', sessions.handleSession)
|
|
153
171
|
route('/api/mobile/v1/session/messages', sessions.handleMessages)
|
|
154
172
|
route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
|
|
@@ -254,6 +272,14 @@ function registerMobileRoutes(deps) {
|
|
|
254
272
|
// 经 typertGateway 命名空间 pluginInventory 取,见 mobile/plugins.js。
|
|
255
273
|
route('/api/mobile/v1/plugins', plugins.handlePlugins)
|
|
256
274
|
|
|
275
|
+
// 插件自更新:APP 的「应用更新」页给这台宿主升级插件版本(见 mobile/plugin-update.js)。
|
|
276
|
+
// 与桌面控制面 `/api/relay/update/*` 同一套实现,差别只在入口:那三条 localOnly,手机
|
|
277
|
+
// 永远打不到,而手机上聊天时「有对话在跑」那道闸几乎总关着 —— 自动更新等于不跑。
|
|
278
|
+
// 能力面有意放开给已配对设备,来源判据(只放行 npm 装的)不放开,理由写在那个文件头。
|
|
279
|
+
route('/api/mobile/v1/plugin/update/status', pluginUpdate.handlePluginUpdateStatus)
|
|
280
|
+
route('/api/mobile/v1/plugin/update/check', pluginUpdate.handlePluginUpdateCheck)
|
|
281
|
+
route('/api/mobile/v1/plugin/update/apply', pluginUpdate.handlePluginUpdateApply)
|
|
282
|
+
|
|
257
283
|
// 分享文件的落地通道(App 把手机分享进来的文件分片传上来,见 mobile/uploads.js)。
|
|
258
284
|
// 手机端没有「把文件交给宿主」的路:图片能进 session.send 的 base64 内容块,其它格式
|
|
259
285
|
// 一律进不去。四条端点只做「收字节 → 落盘到 ~/.dsh/mobile-inbox/<日期>/<文件名>」,
|
package/lib/session-cleanup.cjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 会话数据清理:按 sessionId 删除 dshHome 下的 sessions/** 目录与 project cache。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 两个入口共用同一套落地代码:DSH agent 工具 registerSessionCleanupTool(action=delete
|
|
5
5
|
* 必须 confirm:true 且只针对已归档)与移动 API /workspace/clear-archived(archive-clean.js)。
|
|
6
6
|
*
|
|
7
|
+
* **两个入口都不在进程里删磁盘**:只把 id 排进待删队列(dshHome 下的
|
|
8
|
+
* mobile-archived-clear.json),真正的删除放在宿主下次启动时做,见
|
|
9
|
+
* deleteQueuedArchivedSessions。宿主没有「删除会话」接口,进程活着的时候它手上那些会话
|
|
10
|
+
* 随时可能被读或写,文件不在就是一次未捕获的 ENOENT —— 宿主对未捕获异常是零容忍的,
|
|
11
|
+
* 整个进程退出,桌面端跟着消失(2026-10-20 就是这么闪退的)。
|
|
12
|
+
*
|
|
7
13
|
* 认哪份文件、怎么读 id 都以宿主自己的规则为准,不按目录名猜:
|
|
8
14
|
* - 文件名 = `session[.vN].jsonl[.zstd]`(宿主 `dsh-session-format` 的
|
|
9
15
|
* CANONICAL_LOG_FILENAME + compressionSuffix:0 代不带 vN,压缩时才带 .zstd)。
|
|
@@ -12,10 +18,10 @@
|
|
|
12
18
|
* - 解压用 Node 自带的 zstd(DSH 自己就是这么读写日志的,宿主并不需要系统 zstd
|
|
13
19
|
* 命令),不再 shell 出去:没装 zstd 命令的机器上它一缺就是 readHeader 静默返回
|
|
14
20
|
* undefined,整条删除路径空转。id 仍然取自日志第一行那条 header。
|
|
15
|
-
* -
|
|
16
|
-
* (pruneOrphanedArchivedSessionsOnce
|
|
17
|
-
*
|
|
18
|
-
*
|
|
21
|
+
* - **进程活着的时候不删日志、也不擦宿主的归档集合**;两件事都放到宿主启动时
|
|
22
|
+
* (pruneOrphanedArchivedSessionsOnce)。擦集合的理由写在那个函数上:桌面端的会话
|
|
23
|
+
* 列表是连上宿主时拉的一份快照 + 之后的事件增量,进程里擦集合等于把已经删掉、却还
|
|
24
|
+
* 留在快照里的行放回正常列表。删日志的理由见上面那段:进程里删 = 宿主随时可能踩空。
|
|
19
25
|
* - **Windows 上目录删不掉 ≠ 会话没删掉**:宿主对它打开过的会话一直握着 `session.lock`
|
|
20
26
|
* 的写句柄,日志 unlink 成功后那个名字要等句柄关闭才消失(delete-pending),rmdir
|
|
21
27
|
* 于是报 ENOTEMPTY。判据改成「目录里还有没有规范名日志」:没有就是删成了。
|
|
@@ -39,6 +45,7 @@ const CANONICAL_LOG_FILENAME = /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/
|
|
|
39
45
|
const HEADER_MAX_BYTES = 64 * 1024
|
|
40
46
|
|
|
41
47
|
const { envelopeValue, unwrap } = require('./mobile/envelope.js')
|
|
48
|
+
const { rememberDeletedSessionIds } = require('./mobile/session-cache.js')
|
|
42
49
|
|
|
43
50
|
function asItems(response) {
|
|
44
51
|
const value = envelopeValue(response)
|
|
@@ -218,6 +225,104 @@ function sessionIdsWithoutAnyData(sessionIds) {
|
|
|
218
225
|
return [...candidates, ...withoutData]
|
|
219
226
|
}
|
|
220
227
|
|
|
228
|
+
/**
|
|
229
|
+
* 「清理所有已归档」的待删队列:dshHome 下的一份小 JSON(`mobile-archived-clear.json`)。
|
|
230
|
+
*
|
|
231
|
+
* 用户点清理的那一刻**不动磁盘**,只把 id 记进这里;真正的删除在宿主下次启动时做,见
|
|
232
|
+
* deleteQueuedArchivedSessions。原因是宿主没有「删除会话」这个接口:进程活着的时候,
|
|
233
|
+
* 它内存里那些会话随时可能被读或写(列一遍会话、续写一条事件、开一条还没落盘的空白会话),
|
|
234
|
+
* 文件不在就是一次没人接的 ENOENT —— 宿主对未捕获异常零容忍,进程直接退出,桌面端窗口
|
|
235
|
+
* 跟着消失(2026-10-20 真机就是这么闪退的:清理删到一条 0 轮次的空白归档会话)。
|
|
236
|
+
*
|
|
237
|
+
* 队列是**并集**语义:同一批 id 点两次、两次清理之间换了批次,都只是往同一张表里加。
|
|
238
|
+
*/
|
|
239
|
+
const PENDING_ARCHIVED_CLEAR_FILE = 'mobile-archived-clear.json'
|
|
240
|
+
|
|
241
|
+
function pendingArchivedClearFile() {
|
|
242
|
+
return path.join(dshHome(), PENDING_ARCHIVED_CLEAR_FILE)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 队列里的 id。文件不在 / 读坏了都当空:下一次点清理会把该排的重新排一遍。 */
|
|
246
|
+
function readPendingArchivedClearIds() {
|
|
247
|
+
let text
|
|
248
|
+
try { text = fs.readFileSync(pendingArchivedClearFile(), 'utf8') } catch { return [] }
|
|
249
|
+
try {
|
|
250
|
+
const parsed = JSON.parse(text)
|
|
251
|
+
const list = parsed && Array.isArray(parsed.sessionIds) ? parsed.sessionIds : []
|
|
252
|
+
return [...new Set(list.filter((id) => typeof id === 'string' && id))]
|
|
253
|
+
} catch {
|
|
254
|
+
return []
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 整份覆写队列;空队列就删文件。
|
|
260
|
+
*
|
|
261
|
+
* 写失败**向上抛**:排不上队就不能回「已清理」——那等于告诉用户删掉了,重启之后它们
|
|
262
|
+
* 原样回来。调用方(清理路由 / agent 工具)自带失败通道,不需要在这里兜。
|
|
263
|
+
*/
|
|
264
|
+
function writePendingArchivedClearIds(sessionIds) {
|
|
265
|
+
const file = pendingArchivedClearFile()
|
|
266
|
+
const unique = [...new Set((Array.isArray(sessionIds) ? sessionIds : []).filter((id) => typeof id === 'string' && id))]
|
|
267
|
+
if (unique.length === 0) {
|
|
268
|
+
fs.rmSync(file, { force: true })
|
|
269
|
+
return []
|
|
270
|
+
}
|
|
271
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
272
|
+
// 先写临时文件再改名:宿主随时可能被杀,半截 JSON 会让下一次启动整队读不出来。
|
|
273
|
+
const temp = `${file}.${process.pid}.tmp`
|
|
274
|
+
fs.writeFileSync(temp, `${JSON.stringify({ sessionIds: unique }, null, 2)}\n`)
|
|
275
|
+
fs.renameSync(temp, file)
|
|
276
|
+
return unique
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 把一批 id 排进队列(并集、幂等),返回排完之后的整队。 */
|
|
280
|
+
function queueArchivedClearIds(sessionIds) {
|
|
281
|
+
const incoming = Array.isArray(sessionIds) ? sessionIds : []
|
|
282
|
+
return writePendingArchivedClearIds([...readPendingArchivedClearIds(), ...incoming])
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 宿主刚启动时把队里排着的日志真正删掉,删成的从队列里摘掉。
|
|
287
|
+
*
|
|
288
|
+
* 只在这个时刻删:桌面端还没连上(或正要重连,快照本来就从磁盘重新拉),宿主手上也没有
|
|
289
|
+
* 任何热会话,不存在「正读着 / 正写着被删掉的那条」。某一条删不掉(日志还在盘上)不算整轮
|
|
290
|
+
* 失败 —— 它的 id 留在队里等下次启动,其余照删。deleteSessionData 中途抛错时已经删掉的
|
|
291
|
+
* 那批靠 sessionIdsWithoutAnyData 补账(返回值随异常丢了)。
|
|
292
|
+
*
|
|
293
|
+
* @returns {Promise<{deleted: string[], kept: string[]}>}
|
|
294
|
+
*/
|
|
295
|
+
async function deleteQueuedArchivedSessions(log, ctx) {
|
|
296
|
+
const queued = readPendingArchivedClearIds()
|
|
297
|
+
if (queued.length === 0) return { deleted: [], kept: [] }
|
|
298
|
+
let removed = []
|
|
299
|
+
try {
|
|
300
|
+
removed = await deleteSessionData(queued)
|
|
301
|
+
} catch (error) {
|
|
302
|
+
log?.(`archived clear queue: ${error && error.message ? error.message : String(error)}`)
|
|
303
|
+
}
|
|
304
|
+
const done = new Set([...removed, ...sessionIdsWithoutAnyData(queued)])
|
|
305
|
+
const deleted = queued.filter((id) => done.has(id))
|
|
306
|
+
const kept = queued.filter((id) => !done.has(id))
|
|
307
|
+
writePendingArchivedClearIds(kept)
|
|
308
|
+
// 出队之后还得在本进程继续压住:宿主那份会话清单可能是清理之前建好的,行还在里面。
|
|
309
|
+
// 没有这一步,手机上会冒出一条点进去什么都没有的归档行(修剪成功时它甚至会被当成
|
|
310
|
+
// 未归档的普通行露出来)。
|
|
311
|
+
if (ctx && deleted.length > 0) rememberDeletedSessionIds(ctx, deleted)
|
|
312
|
+
return { deleted, kept }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 队列里的 id 在手机上必须继续看不到:插件的删除集是**进程内**的,宿主重启、代际热替换
|
|
317
|
+
* 都会丢,而队列里的会话在宿主那边还活着(归档列表照样列得出它们)。每次装载都从队列
|
|
318
|
+
* 里补一遍,两次启动之间也不会复活。
|
|
319
|
+
*/
|
|
320
|
+
function restoreQueuedArchivedClearIds(ctx) {
|
|
321
|
+
const queued = readPendingArchivedClearIds()
|
|
322
|
+
if (queued.length > 0) rememberDeletedSessionIds(ctx, queued)
|
|
323
|
+
return queued
|
|
324
|
+
}
|
|
325
|
+
|
|
221
326
|
async function pruneOrphanedArchivedSessions(ctx) {
|
|
222
327
|
const workspaceList = ctx && ctx.apiProxy && ctx.apiProxy.workspace ? ctx.apiProxy.workspace.list : undefined
|
|
223
328
|
if (typeof workspaceList !== 'function') return 0
|
|
@@ -234,27 +339,47 @@ async function pruneOrphanedArchivedSessions(ctx) {
|
|
|
234
339
|
}
|
|
235
340
|
|
|
236
341
|
/**
|
|
237
|
-
*
|
|
342
|
+
* 宿主进程启动时做一次收尾,然后整个进程不再碰:先删队里排着的日志,再修剪归档集合。
|
|
238
343
|
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
344
|
+
* 这两件事都只能在启动这一刻做:
|
|
345
|
+
* - **删日志**:宿主没有「删除会话」接口,进程活着时它随时可能去开那些文件;文件不在
|
|
346
|
+
* 就是一次未捕获的 ENOENT,宿主直接退出(2026-10-20 桌面端闪退就是这条)。
|
|
347
|
+
* - **擦归档集合**:桌面端的会话列表是「连上宿主时拉的一份快照 + 之后的事件增量」,
|
|
348
|
+
* 进程活着的时候擦集合,宿主会把「归档集合变了」广播给桌面端,桌面端就把它快照里
|
|
349
|
+
* 那些其实已经没有日志、本来被归档藏着的行放回正常列表 —— 用户报的正是「清理后
|
|
350
|
+
* 它们又回来了,重启 DSH 才真的消失」。启动时快照本来就从磁盘重新拉,擦掉不会放出
|
|
351
|
+
* 任何行。留在集合里的死 id 不显示在任何地方:「已归档」取的是会话列表与归档集合
|
|
352
|
+
* 的交集,没有日志就没有那一行。
|
|
246
353
|
*
|
|
247
354
|
* 用进程级标记而不是模块变量:代际热替换会重跑 apply,而桌面端那条快照挂在同一个进程上。
|
|
248
355
|
*/
|
|
249
356
|
const PRUNE_ONCE = Symbol.for('dsh-mobile-plugin.archived-prune')
|
|
357
|
+
const PRUNE_ATTEMPTS = 3
|
|
358
|
+
const PRUNE_RETRY_MS = 500
|
|
250
359
|
async function pruneOrphanedArchivedSessionsOnce(ctx, log) {
|
|
251
360
|
if (globalThis[PRUNE_ONCE]) return
|
|
252
361
|
globalThis[PRUNE_ONCE] = true
|
|
362
|
+
// 删日志不碰宿主,先做:越早越好,别等宿主把 workspace 服务挂齐(那时桌面端可能已经连上)。
|
|
253
363
|
try {
|
|
254
|
-
const
|
|
255
|
-
if (
|
|
364
|
+
const { deleted, kept } = await deleteQueuedArchivedSessions(log, ctx)
|
|
365
|
+
if (deleted.length > 0) log?.(`archived clear queue: deleted ${deleted.length} session logs`)
|
|
366
|
+
if (kept.length > 0) log?.(`archived clear queue: ${kept.length} session logs kept for the next start`)
|
|
256
367
|
} catch (error) {
|
|
257
|
-
log?.(`archived
|
|
368
|
+
log?.(`archived clear queue failed: ${error && error.message ? error.message : String(error)}`)
|
|
369
|
+
}
|
|
370
|
+
// 修剪要等宿主的 workspace 服务就绪:0.2.x 的 workspace.list 就是 workspace.follow 的
|
|
371
|
+
// 首帧,刚启动那一下常常还没开出来。以前一次失败就把整轮收尾丢掉,结果是归档集合里
|
|
372
|
+
// 的死 id 一直没人清(实测宿主重启过一次,315 个没有日志的 id 原样挂着)。
|
|
373
|
+
for (let attempt = 1; attempt <= PRUNE_ATTEMPTS; attempt += 1) {
|
|
374
|
+
try {
|
|
375
|
+
const pruned = await pruneOrphanedArchivedSessions(ctx)
|
|
376
|
+
if (pruned > 0) log?.(`archived session prune: dropped ${pruned} deleted session ids`)
|
|
377
|
+
return
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const message = error && error.message ? error.message : String(error)
|
|
380
|
+
if (attempt === PRUNE_ATTEMPTS) log?.(`archived session prune failed: ${message}`)
|
|
381
|
+
else await new Promise((resolve) => setTimeout(resolve, PRUNE_RETRY_MS * attempt))
|
|
382
|
+
}
|
|
258
383
|
}
|
|
259
384
|
}
|
|
260
385
|
|
|
@@ -292,19 +417,20 @@ async function cleanupSessions(ctx, args) {
|
|
|
292
417
|
}
|
|
293
418
|
if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
|
|
294
419
|
const ids = selected.map(item => item.sessionId)
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
420
|
+
// 与移动端那条路由同一口径:进程里只排队,**日志留到宿主下次启动时删**。进程活着时
|
|
421
|
+
// 删日志,宿主随时可能去开一条已经不存在的文件,那是未捕获异常(会带走整个宿主)。
|
|
422
|
+
queueArchivedClearIds(ids)
|
|
423
|
+
rememberDeletedSessionIds(ctx, ids)
|
|
424
|
+
return { action: args.action, cwd, selected: ids.length, archived: 0, deleted: ids.length, failed: 0, sessionIds: ids }
|
|
299
425
|
}
|
|
300
426
|
|
|
301
427
|
function registerSessionCleanupTool(ctx) {
|
|
302
428
|
const { defineTool } = require('@deepseek-ai/dsh-tools')
|
|
303
429
|
ctx.tools.register(defineTool({
|
|
304
430
|
name: 'session_cleanup',
|
|
305
|
-
description: 'Archive or permanently delete old DSH sessions. Delete only targets already archived sessions
|
|
431
|
+
description: 'Archive or permanently delete old DSH sessions. Delete only targets already archived sessions, requires confirm=true, and drops their logs when the host next starts.',
|
|
306
432
|
parameters: {
|
|
307
|
-
action: { type: 'string', required: true, enum: ['archive', 'delete'], description: 'archive hides sessions; delete
|
|
433
|
+
action: { type: 'string', required: true, enum: ['archive', 'delete'], description: 'archive hides sessions; delete schedules permanent removal of their logs and local projection cache at the next host start.' },
|
|
308
434
|
cwd: { type: 'string', description: 'Project directory to target. Required when selecting by age across multiple projects.' },
|
|
309
435
|
olderThanMinutes: { type: 'number', description: 'Only target sessions not updated within this many minutes. Defaults to 30.' },
|
|
310
436
|
minProjectSessions: { type: 'integer', description: 'Skip the operation when the selected project has fewer sessions than this number.' },
|
|
@@ -315,24 +441,31 @@ function registerSessionCleanupTool(ctx) {
|
|
|
315
441
|
schema: { type: 'object', additionalProperties: false, properties: {
|
|
316
442
|
action: { type: 'string' }, cwd: { type: 'string' }, selected: { type: 'integer' }, archived: { type: 'integer' }, deleted: { type: 'integer' }, failed: { type: 'integer' }, sessionIds: { type: 'array', items: { type: 'string' } }, failures: { type: 'array', items: { type: 'object' } },
|
|
317
443
|
} },
|
|
318
|
-
render: (_args, value) => [{ type: 'text', text:
|
|
444
|
+
render: (_args, value) => [{ type: 'text', text: value.action === 'archive' ? `Archived ${value.archived} sessions.` : `Scheduled ${value.deleted} sessions for removal; their logs are deleted the next time the host starts.` }],
|
|
319
445
|
},
|
|
320
446
|
execute: (args) => cleanupSessions(ctx, args),
|
|
321
447
|
}))
|
|
322
448
|
}
|
|
323
449
|
|
|
324
450
|
/**
|
|
325
|
-
* 装配层的入口:注册 agent
|
|
451
|
+
* 装配层的入口:注册 agent 的清理工具,同时在「宿主刚起来」时删掉待删队列里的日志、
|
|
452
|
+
* 修剪一遍归档集合。
|
|
326
453
|
*
|
|
327
|
-
*
|
|
454
|
+
* 三件事都是会话清理的一部分,所以共用装配层那一处 effect;impl.js 有行数兜底守卫,
|
|
328
455
|
* 别把实现搬回去。
|
|
329
456
|
*/
|
|
330
457
|
function installSessionCleanup(ctx) {
|
|
458
|
+
// 队列里的 id 每次装载都要补回删除集:宿主重启、代际热替换都会丢掉进程内那份,
|
|
459
|
+
// 而它们在宿主那边还活着(归档列表照样列得出),漏一次手机上就「又回来了」。
|
|
460
|
+
try {
|
|
461
|
+
restoreQueuedArchivedClearIds(ctx)
|
|
462
|
+
} catch { /* 读不出来就当没有:用户再点一次清理会重新排队 */ }
|
|
331
463
|
// 「宿主启动时」不能只看进程级标记:升级到这份实现那次是**热替换**进来的(进程早就在跑),
|
|
332
464
|
// 标记还没被谁设过。那一刻桌面端的会话列表快照里可能正挂着之前删掉的行,擦集合就会把它们
|
|
333
465
|
// 放回列表。所以只在进程刚起的这点窗口里修剪;晚于它就留给下次启动。
|
|
334
466
|
if (process.uptime() <= BOOT_PRUNE_WINDOW_SECONDS) {
|
|
335
|
-
//
|
|
467
|
+
// 收尾先挂:没有 tools 注入的 profile 注册不了工具(外层会吞掉这条错),但队列里的日志
|
|
468
|
+
// 与归档集合照样要收尾。
|
|
336
469
|
void pruneOrphanedArchivedSessionsOnce(ctx, (message) => {
|
|
337
470
|
try { ctx.logger?.info?.(message) } catch { /* 日志失败不影响修剪 */ }
|
|
338
471
|
})
|
|
@@ -340,4 +473,4 @@ function installSessionCleanup(ctx) {
|
|
|
340
473
|
registerSessionCleanupTool(ctx)
|
|
341
474
|
}
|
|
342
475
|
|
|
343
|
-
module.exports = { cleanupSessions, registerSessionCleanupTool, installSessionCleanup, deleteSessionData, sessionIdsWithoutAnyData, forgetArchivedSessions, pruneOrphanedArchivedSessions, pruneOrphanedArchivedSessionsOnce }
|
|
476
|
+
module.exports = { cleanupSessions, registerSessionCleanupTool, installSessionCleanup, deleteSessionData, sessionIdsWithoutAnyData, forgetArchivedSessions, pruneOrphanedArchivedSessions, pruneOrphanedArchivedSessionsOnce, deleteQueuedArchivedSessions, queueArchivedClearIds, readPendingArchivedClearIds, restoreQueuedArchivedClearIds }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「桌面侧栏折叠态变了」的信号总线。
|
|
5
|
+
*
|
|
6
|
+
* 桌面端那份 groupExpansion 只活在浏览器 localStorage 里,插件靠客户端半边轮询上报
|
|
7
|
+
* (lib/client.js 每 2 秒读一次,变了才 POST /api/relay/desktop-view),落到
|
|
8
|
+
* lib/desktop-view.cjs。手机要看这份镜像只有一条路:GET /api/mobile/v1/desktop-view。
|
|
9
|
+
*
|
|
10
|
+
* 过去 App 按一个 5 分钟最短间隔去问它 —— 因为没有任何「它变了」的信号可用:列表
|
|
11
|
+
* revision 只哈希会话与工作区,桌面改折叠不动它。那套是「猜一个足够长的周期」,
|
|
12
|
+
* 稳态下一天白问几百次。本模块给出真正的出口:上报落盘且**内容真变了**时广播一次,
|
|
13
|
+
* 设备级流立刻推给手机,App 收到才拉。
|
|
14
|
+
*
|
|
15
|
+
* 与 list-signal 同款模块级单例:生产端(desktop-view.cjs)与消费端
|
|
16
|
+
* (mobile/events.js)不需要互相认识;测试用 createDesktopViewSignal() 造独立实例。
|
|
17
|
+
*
|
|
18
|
+
* 内容没变不广播:客户端半边会重复 POST 同一份(页面重载、切窗口都会重来一次),
|
|
19
|
+
* 每次都给每台手机推一帧,等于把「5 分钟一问」换成「一上报一帧」。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const NOOP_RELEASE = () => {};
|
|
23
|
+
|
|
24
|
+
function createDesktopViewSignal() {
|
|
25
|
+
const listeners = new Set();
|
|
26
|
+
let updatedAt = 0;
|
|
27
|
+
let at = 0;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
/**
|
|
31
|
+
* 广播一次折叠态变更。同一个 updatedAt 连着广播两次只算一次(幂等)。
|
|
32
|
+
*
|
|
33
|
+
* @param {number} nextUpdatedAt 快照的 receivedAt(desktop-view.cjs 落盘时的时间)
|
|
34
|
+
* @returns {boolean} 是否真的广播了
|
|
35
|
+
*/
|
|
36
|
+
bump(nextUpdatedAt) {
|
|
37
|
+
const value = Number(nextUpdatedAt) || 0;
|
|
38
|
+
if (value && value === updatedAt) return false;
|
|
39
|
+
updatedAt = value;
|
|
40
|
+
at = Date.now();
|
|
41
|
+
for (const listener of [...listeners]) {
|
|
42
|
+
try { listener({ updatedAt, at }); } catch { /* 订阅者自己的异常不该影响上报链路 */ }
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/** 订阅变更;返回退订函数(可重复调用)。 */
|
|
48
|
+
onBump(listener) {
|
|
49
|
+
if (typeof listener !== 'function') return NOOP_RELEASE;
|
|
50
|
+
listeners.add(listener);
|
|
51
|
+
let released = false;
|
|
52
|
+
return () => {
|
|
53
|
+
if (released) return;
|
|
54
|
+
released = true;
|
|
55
|
+
listeners.delete(listener);
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/** 最近一次变更(供断流重连后的补发与诊断)。 */
|
|
60
|
+
snapshot() { return { updatedAt, at }; },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const desktopViewSignal = createDesktopViewSignal();
|
|
65
|
+
|
|
66
|
+
module.exports = { createDesktopViewSignal, desktopViewSignal };
|