dsh-mobile-plugin 0.1.82 → 0.1.85
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 +8 -5
- package/lib/client.js +43 -50
- package/lib/impl.js +2 -2
- package/lib/mirror/pusher.cjs +2 -0
- package/lib/mobile/archive-clean.js +15 -38
- package/lib/mobile/completion.js +58 -36
- package/lib/mobile/events.js +28 -5
- package/lib/mobile/plugins.js +22 -28
- package/lib/mobile/references.js +17 -30
- package/lib/mobile/session-cache.js +31 -24
- package/lib/mobile/session-send.js +31 -40
- package/lib/mobile/stream.js +42 -8
- package/lib/relay/tunnel.js +30 -9
- package/lib/session-cleanup.cjs +96 -17
- package/lib/shell/device-ota.js +81 -51
- package/lib/shell/manager.js +22 -3
- package/lib/shell/ota-package.js +237 -0
- package/lib/sidecar-facade-worker.cjs +65 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,16 +74,19 @@ pnpm add dsh-mobile-plugin@<版本>
|
|
|
74
74
|
|
|
75
75
|
新版插件的管理面板提供「本机调试 OTA」卡片,可手动检查并安装中转发布的紧急完整包。入口仅限宿主本机访问,不会由手机或公网触发;安装后通过实现代切换生效,切换失败会恢复上一代。npm 仍是正式发布源;后续 npm 正式版本达到 OTA 版本时会接管该版本。首次发布带此功能的 npm 包需要重启一次 DSH,让稳定外壳加载 OTA 能力。
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
发布一个紧急完整包:用仓库里的打包脚本,不要手工 `tar`。
|
|
78
78
|
|
|
79
79
|
```sh
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
# 只打包(产物在当前目录)
|
|
81
|
+
node scripts/pack-ota.mjs
|
|
82
|
+
|
|
83
|
+
# 打包并发布,发布后会下载回来核对哈希
|
|
84
|
+
node scripts/pack-ota.mjs --publish https://你的中转 --token <中转的 OTA 发布 token>
|
|
82
85
|
```
|
|
83
86
|
|
|
84
|
-
`
|
|
87
|
+
脚本做四件事:文件清单问 `npm pack --dry-run --json` 要(**不碰系统 tar**,三个平台同一份逻辑)、往包里写一份 `lib/ota-manifest.json`(版本 + 每个文件的路径与字节数)、用客户端的真解包器自检、发布后再下载回来比 sha256。中转那边也记哈希,客户端装之前会核对——半个包、被中间层改过的包都装不上去。
|
|
85
88
|
|
|
86
|
-
|
|
89
|
+
`--platform` 默认 `process.platform`(`darwin` / `linux` / `win32`);这个插件是纯 JS,也可以 `--platform any` 发一份三个系统通用,客户端在自己平台没有包时会回落去问 `any`。`channel` 一般 `stable`。发布接口没配 token 时是关闭的,下载不受影响;版本按号比,最后发的那个才是「最新」。
|
|
87
90
|
|
|
88
91
|
## 开发
|
|
89
92
|
|
package/lib/client.js
CHANGED
|
@@ -271,27 +271,39 @@ window.__ModuleLoader__.load({
|
|
|
271
271
|
/**
|
|
272
272
|
* 方向一:桌面端读过了 → 告诉宿主,手机端侧栏灭灯。
|
|
273
273
|
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
274
|
+
* 三种情形算「桌面读过了」,每种只报一次(变回未读或重新跑起来时摘掉标记、可以再报):
|
|
275
|
+
* - 本次打开页面之后**看见它从别的状态变成未读、又掉出去**(普通的点开);
|
|
276
|
+
* - 此刻**正开在主视图里**(`retainedBy.mainView > 0`:用户盯着它读完,它压根不进未读集合);
|
|
277
|
+
* - 本次打开页面之后**它变过、而且现在不是未读**(补掉漏采的那一瞬间)。
|
|
278
|
+
*
|
|
279
|
+
* **首帧一条都不报**:桌面页面刚加载时 `sessionStatus` 里全是 `completionUnread: false`
|
|
280
|
+
* (`completionUnread` 只在浏览器内存里,重开页面就没了),照单全收会把手机上真的
|
|
281
|
+
* 未读全灭掉。所以「页面打开之后变过」这条要求 `updatedAt > 页面打开时刻`,
|
|
282
|
+
* 没拿到会话清单时宁可不报。
|
|
283
283
|
*/
|
|
284
284
|
function startDesktopReadSync(ctx) {
|
|
285
285
|
withClientServices(ctx, ['uiSession'], 'uiSession', 'mobile-plugin: 桌面端已读上报', (scope, isDisposed) => {
|
|
286
|
-
|
|
286
|
+
/** 页面打开的时刻:只有这之后动过的会话才敢当成「看见过」。 */
|
|
287
|
+
const openedAt = Date.now();
|
|
287
288
|
let sending = false;
|
|
288
|
-
/**
|
|
289
|
-
const
|
|
290
|
-
/**
|
|
291
|
-
const
|
|
289
|
+
/** 已经替它报过「读过」的会话;变回未读 / 运行中就摘掉。 */
|
|
290
|
+
const reported = new Set();
|
|
291
|
+
/** 本次页面里见过它处于未读的(普通的「点开」边沿就靠这个)。 */
|
|
292
|
+
const everUnread = new Set();
|
|
292
293
|
/** 待上报的会话 id;POST 失败就留着下一轮再发。 */
|
|
293
294
|
const pending = new Set();
|
|
294
295
|
|
|
296
|
+
/** 会话清单里每行的 `updatedAt` / `retainedBy`(拿不到就是空表,方向一退化成边沿判定)。 */
|
|
297
|
+
const retainedRows = () => {
|
|
298
|
+
try {
|
|
299
|
+
const list = currentSessions && currentSessions.list;
|
|
300
|
+
const snapshot = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : null;
|
|
301
|
+
return (snapshot && snapshot.byId) || {};
|
|
302
|
+
} catch {
|
|
303
|
+
return {};
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
295
307
|
const flush = async () => {
|
|
296
308
|
if (sending || !pending.size) return;
|
|
297
309
|
const ids = [];
|
|
@@ -310,22 +322,6 @@ window.__ModuleLoader__.load({
|
|
|
310
322
|
}
|
|
311
323
|
};
|
|
312
324
|
|
|
313
|
-
/**
|
|
314
|
-
* 清单里每行的 `retainedBy`:「谁正开着它」只在这里。
|
|
315
|
-
*
|
|
316
|
-
* 读的是上面 `captureSessions` 抓回来的那份服务(可选依赖):拿不到就只少
|
|
317
|
-
* 「正开着」这一项增强,第 1 类边沿上报照常工作 —— 所以它不该是硬门禁。
|
|
318
|
-
*/
|
|
319
|
-
const retainedRows = () => {
|
|
320
|
-
try {
|
|
321
|
-
const list = currentSessions && currentSessions.list;
|
|
322
|
-
const snapshot = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : null;
|
|
323
|
-
return (snapshot && snapshot.byId) || {};
|
|
324
|
-
} catch {
|
|
325
|
-
return {};
|
|
326
|
-
}
|
|
327
|
-
};
|
|
328
|
-
|
|
329
325
|
const scan = () => {
|
|
330
326
|
if (isDisposed()) return;
|
|
331
327
|
const uiSession = scope.uiSession;
|
|
@@ -338,28 +334,25 @@ window.__ModuleLoader__.load({
|
|
|
338
334
|
return;
|
|
339
335
|
}
|
|
340
336
|
const rows = retainedRows();
|
|
341
|
-
const next = new Map();
|
|
342
337
|
for (const [id, status] of statusEntries(snapshot)) {
|
|
343
338
|
const unread = !!(status && status.completionUnread === true);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (!open || (status && status.running === true)) {
|
|
350
|
-
reportedOpen.delete(id);
|
|
339
|
+
const running = !!(status && status.running === true);
|
|
340
|
+
// 未读 = 还没读;运行中 = 这一轮的结局还没出来。两者都不是「读过了」。
|
|
341
|
+
if (unread || running) {
|
|
342
|
+
if (unread) everUnread.add(id);
|
|
343
|
+
reported.delete(id);
|
|
351
344
|
continue;
|
|
352
345
|
}
|
|
353
|
-
if (
|
|
354
|
-
|
|
346
|
+
if (reported.has(id)) continue;
|
|
347
|
+
const row = rows[id];
|
|
348
|
+
const open = !!(row && Number(row.retainedBy && row.retainedBy.mainView) > 0);
|
|
349
|
+
const changedWhileOpen = !!(row && typeof row.updatedAt === 'number' && row.updatedAt > openedAt);
|
|
350
|
+
// 既没看见它掉出未读、也没见它这次变过、更没开着:多半只是页面刚加载,
|
|
351
|
+
// 全是不带未读标记的空快照。一个都不许报。
|
|
352
|
+
if (!everUnread.has(id) && !open && !changedWhileOpen) continue;
|
|
353
|
+
reported.add(id);
|
|
355
354
|
pending.add(id);
|
|
356
355
|
}
|
|
357
|
-
seen.clear();
|
|
358
|
-
for (const [id, unread] of next) seen.set(id, unread);
|
|
359
|
-
if (!primed) {
|
|
360
|
-
primed = true;
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
356
|
if (pending.size) void flush();
|
|
364
357
|
};
|
|
365
358
|
|
|
@@ -413,9 +406,9 @@ window.__ModuleLoader__.load({
|
|
|
413
406
|
}
|
|
414
407
|
|
|
415
408
|
/**
|
|
416
|
-
* 把 sessions
|
|
417
|
-
*
|
|
418
|
-
*
|
|
409
|
+
* 把 sessions 服务抓进一个可变引用,供方向一判断「正开着」与「这次变过」。
|
|
410
|
+
* 它缺席时少这一路增强(方向一仍有边沿与主视图两条判据),宿主侧自检里
|
|
411
|
+
* `sessions` 那一格正是 false,排查时能一眼看出是服务没拿到。
|
|
419
412
|
*/
|
|
420
413
|
let currentSessions = null;
|
|
421
414
|
function captureSessions(ctx) {
|
|
@@ -726,8 +719,8 @@ window.__ModuleLoader__.load({
|
|
|
726
719
|
ctx.effect(() => ctx.locale.register(NS, DICT), 'mobile-plugin: settings dictionaries');
|
|
727
720
|
const t = ctx.locale.bind(NS);
|
|
728
721
|
startDesktopViewSync(ctx);
|
|
729
|
-
startDesktopReadSync(ctx);
|
|
730
722
|
captureSessions(ctx);
|
|
723
|
+
startDesktopReadSync(ctx);
|
|
731
724
|
startReadReceiptSync(ctx);
|
|
732
725
|
startClientHalfReport(ctx);
|
|
733
726
|
// 插槽是「先声明后注册」:宿主可能比我们晚一步声明 settings.section,
|
package/lib/impl.js
CHANGED
|
@@ -35,7 +35,7 @@ const { createApiProxy } = require('./mobile/host-bridge.js')
|
|
|
35
35
|
// 「手机上最近来过没有」:守卫记、控制面状态读。单独一个模块,装配层只转交。
|
|
36
36
|
const activity = require('./mobile/activity.cjs')
|
|
37
37
|
const { installAutoArchive } = require('./auto-archive.cjs')
|
|
38
|
-
const {
|
|
38
|
+
const { installSessionCleanup } = require('./session-cleanup.cjs')
|
|
39
39
|
const { activeGuard } = require('./host-guard.cjs')
|
|
40
40
|
// 桌面端侧栏展开态的快照存储。桌面把这份状态只记在浏览器 localStorage 里,宿主必须
|
|
41
41
|
// 有一个落盘处:客户端半边(lib/client.js)上报 → 这里 → 移动端读取。
|
|
@@ -299,7 +299,7 @@ function apply(ctx) {
|
|
|
299
299
|
ctx.effect(() => installAutoArchive(ctx), `${name}: automatic session archive`)
|
|
300
300
|
ctx.effect(() => {
|
|
301
301
|
// Profiles without the optional tools injection still support core remote control.
|
|
302
|
-
try {
|
|
302
|
+
try { installSessionCleanup(ctx) } catch (error) {
|
|
303
303
|
if (!String(error).includes('without inject')) throw error
|
|
304
304
|
}
|
|
305
305
|
return undefined
|
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -34,6 +34,7 @@ const { dshHome } = require('../shared/paths.js');
|
|
|
34
34
|
const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
35
35
|
const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
|
|
36
36
|
const { dropSessionListCache } = require('../mobile/session-cache.js');
|
|
37
|
+
const { observeCompletionEvent } = require('../mobile/completion.js');
|
|
37
38
|
|
|
38
39
|
/** 后台循环每个 tick 补推一个冷会话,保证没人动的会话镜像也不长期不更新。 */
|
|
39
40
|
const HIGH_PRIORITY_WINDOW_MS = 5 * 60 * 1000;
|
|
@@ -683,6 +684,7 @@ function createMirrorPusher(deps) {
|
|
|
683
684
|
// 同一轮收尾时的 assistant/message 会把完整消息带进来,手机侧的实时文本由
|
|
684
685
|
// session/stream 的 SSE 负责。不跳的话每条增量都要重建五份全量快照并推一次
|
|
685
686
|
// sidecar,形成推送风暴(配合 pushSessionImmediately 的单飞才有稳定上界)。
|
|
687
|
+
observeCompletionEvent(ctx, sessionId, p?.event);
|
|
686
688
|
if (p?.event?.type === 'assistant/chunk') continue;
|
|
687
689
|
void pushSessionImmediately(sessionId);
|
|
688
690
|
}
|
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 移动端「清理所有已归档的对话」:按宿主归档集合确定清理范围,并删掉它们的日志。
|
|
5
5
|
*
|
|
6
6
|
* 以 workspace.list 的 archivedSessionIds 为清理范围,不用可能过时的列表状态缩小范围;
|
|
7
7
|
* 请求必须显式带 confirm: true —— 磁盘删除不可恢复,手机端「少点一次」不值得把它变成滑一下就没了的动作。
|
|
8
8
|
*
|
|
9
|
-
* 删除动作不重写:读日志 header.id → 删会话目录 +
|
|
10
|
-
*
|
|
9
|
+
* 删除动作不重写:读日志 header.id → 删会话目录 + 投影缓存只在 session-cleanup.cjs 里存在
|
|
10
|
+
* (见地图 A3-17),这里只是把它的触发面从 agent 工具扩到移动端路由。
|
|
11
|
+
*
|
|
12
|
+
* **这里不擦宿主的归档集合**(只把删掉的 id 记进本进程的删除集,挡住手机端列表里可能
|
|
13
|
+
* 还没失效的旧快照)。进程里擦集合会把桌面端那份会话列表快照里、其实已经删掉的行放回
|
|
14
|
+
* 正常列表——桌面端用户看到的就是「清理完它们又回来了,重启 DSH 才真的消失」。归档集合
|
|
15
|
+
* 的收尾放在宿主启动时做,见 session-cleanup.cjs 的 pruneOrphanedArchivedSessionsOnce。
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
14
19
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
15
|
-
const {
|
|
16
|
-
canonicalDeviceKey,
|
|
17
|
-
rememberDeletedSessionIds,
|
|
18
|
-
pendingUnarchiveSessionIdsOf,
|
|
19
|
-
clearPendingUnarchiveSessionIds,
|
|
20
|
-
} = require('./session-cache.js');
|
|
20
|
+
const { canonicalDeviceKey, rememberDeletedSessionIds } = require('./session-cache.js');
|
|
21
21
|
const { invalidateSessionList } = require('./sessions.js');
|
|
22
22
|
const { unwrap } = require('./envelope.js');
|
|
23
|
-
const { deleteSessionData
|
|
23
|
+
const { deleteSessionData } = require('../session-cleanup.cjs');
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* POST /api/mobile/v1/workspace/clear-archived(token)
|
|
27
|
-
* body: { confirm: true } → { ok: true, deleted: N
|
|
27
|
+
* body: { confirm: true } → { ok: true, deleted: N }。confirm 不为 true 一律 400。
|
|
28
28
|
*/
|
|
29
29
|
async function handleClearArchived(ctx, req, res) {
|
|
30
30
|
const body = await readJsonBody(req, 64 * 1024);
|
|
@@ -40,36 +40,13 @@ async function handleClearArchived(ctx, req, res) {
|
|
|
40
40
|
}
|
|
41
41
|
const archivedIds = Array.isArray(workspaceResponse.value?.archivedSessionIds) ? workspaceResponse.value.archivedSessionIds : [];
|
|
42
42
|
const selected = archivedIds.filter((id) => typeof id === 'string' && id);
|
|
43
|
-
if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0
|
|
44
|
-
// 顺序是**先删日志、再擦归档集合**。删日志才是用户要的那件事,擦集合只是收尾。
|
|
45
|
-
//
|
|
46
|
-
// 反过来(先擦后删)曾导致用户报的现象:擦归档是逐条 RPC,中途任何一条失败或链路超时,
|
|
47
|
-
// 日志一份没删、名字却已经从归档集合里消失——会话退回普通列表,看着像「清理只是把它
|
|
48
|
-
// 移出了归档」,必须重启桌面端才恢复。先删就没这个窗口:日志没了就是**真的清理掉了**,
|
|
49
|
-
// 立刻生效、不必等桌面端重启;擦集合失败最多在「已归档」里留一行点不开的死行,
|
|
50
|
-
// 重试一次即可,不会让操作白做。
|
|
51
|
-
//
|
|
52
|
-
// 擦集合是 best-effort:它失败**不**让整条清理报错(日志已经删成功,报错等于骗用户)。
|
|
43
|
+
if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0 } };
|
|
53
44
|
const removed = await deleteSessionData(selected);
|
|
45
|
+
// 删的是列表里可见的行:手机端正开着列表,宿主那份快照可能还带着它们,先在本进程压住。
|
|
54
46
|
rememberDeletedSessionIds(ctx, removed);
|
|
55
|
-
|
|
56
|
-
const retryable = [...new Set(selected)].filter((id) => pendingUnarchive.has(id));
|
|
57
|
-
const failedToUnarchive = await forgetArchivedSessions(ctx, retryable, 'mobile');
|
|
58
|
-
const failed = new Set(failedToUnarchive);
|
|
59
|
-
clearPendingUnarchiveSessionIds(ctx, retryable.filter((id) => !failed.has(id)));
|
|
60
|
-
// 删的是列表里可见的行:手机端正开着列表,不立刻失效最长 10 秒还能点到死行。
|
|
47
|
+
// 不立刻失效的话,最长 10 秒还能点到已删的行。
|
|
61
48
|
invalidateSessionList(ctx);
|
|
62
|
-
|
|
63
|
-
const cleared = new Set(retryable.filter((id) => !failed.has(id)));
|
|
64
|
-
return {
|
|
65
|
-
status: 200,
|
|
66
|
-
body: {
|
|
67
|
-
ok: true,
|
|
68
|
-
deleted: removed.length,
|
|
69
|
-
archivedSessionIds: archivedIds.filter((id) => !cleared.has(id)),
|
|
70
|
-
unarchiveFailedSessionIds: failedToUnarchive,
|
|
71
|
-
},
|
|
72
|
-
};
|
|
49
|
+
return { status: 200, body: { ok: true, deleted: removed.length } };
|
|
73
50
|
});
|
|
74
51
|
sendJson(res, outcome.status, outcome.body);
|
|
75
52
|
}
|
package/lib/mobile/completion.js
CHANGED
|
@@ -1,22 +1,47 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 完成提醒状态机:一轮怎么结束以 history 为准(turn/end 的 reason.kind),实时 turn
|
|
5
|
+
* 事件只是让结论早一步到达。提醒算不算数看两个水位:读过的时刻之前不算,本进程启动
|
|
6
|
+
* 之前结束的轮次也不重播 —— 不再要求「先看见 running」。
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* assistant/message 再 append turn/end,所以必须由 history 折出同一轮的这两个事件
|
|
9
|
-
* 才算完成;
|
|
10
|
-
* 2. `wasRunning` 是进程内边沿标记,只有本次 Host 存活期间观察到的轮次才有资格报完成,
|
|
11
|
-
* 否则冷启动的一份旧列表会把上次开机前的轮次当成新完成弹一遍通知;
|
|
12
|
-
* 3. 一轮的结局由 `turn/end` 的 `reason.kind` 说了算:`error` 记失败(宿主会话列表里
|
|
13
|
-
* 没有任何失败字段,App 侧栏的红点全靠这里折出来),`completed` 才算完成,其余
|
|
14
|
-
* kind(max-tokens / aborted)既不算完成也不算失败。
|
|
8
|
+
* 旧要求的后果(2026-10-17 真机):宿主没采到那一瞬间的轮次永远不亮,桌面亮着绿点 /
|
|
9
|
+
* 红点,手机上什么都没有。
|
|
15
10
|
*/
|
|
16
11
|
|
|
17
|
-
const { completionStateOf } = require('./session-cache.js');
|
|
12
|
+
const { completionStateOf, completionStateMapsOf } = require('./session-cache.js');
|
|
13
|
+
|
|
14
|
+
/** 本进程起点:比它更早结束的轮次不重播。 */
|
|
15
|
+
const PROCESS_STARTED_AT = Date.now();
|
|
16
|
+
|
|
17
|
+
/** 「不算数」水位:读过按读过时刻;没读过时,本进程见过它跑就放开(0)。 */
|
|
18
|
+
function baselineOf(state) {
|
|
19
|
+
if (!state) return PROCESS_STARTED_AT;
|
|
20
|
+
if (typeof state.clearedAt === 'number') return state.clearedAt;
|
|
21
|
+
return state.wasRunning === true ? 0 : PROCESS_STARTED_AT;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Consume one live Host turn event for every device with an initialized completion state. */
|
|
25
|
+
function observeCompletionEvent(ctx, sessionId, event) {
|
|
26
|
+
if (!sessionId || !event || !event.data || typeof event.data.turn !== 'number') return;
|
|
27
|
+
const turn = event.data.turn;
|
|
28
|
+
for (const states of completionStateMapsOf(ctx)) {
|
|
29
|
+
const previous = states.get(sessionId);
|
|
30
|
+
if (!previous) continue;
|
|
31
|
+
if (event.type === 'turn/start') {
|
|
32
|
+
states.set(sessionId, { ...previous, running: true, completed: false, wasRunning: true, turn, completionEvents: [event] });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if ((event.type !== 'assistant/message' && event.type !== 'turn/end')
|
|
36
|
+
|| previous.turn !== turn || previous.wasRunning !== true) continue;
|
|
37
|
+
const priorEvents = Array.isArray(previous.completionEvents) ? previous.completionEvents : [];
|
|
38
|
+
const events = [...priorEvents.filter((item) => item.type !== event.type), event];
|
|
39
|
+
const next = { ...previous, running: event.type === 'turn/end' ? false : previous.running, completionEvents: events };
|
|
40
|
+
if (event.type === 'turn/end') confirmCompletionFromHistory(next, events, false);
|
|
41
|
+
states.set(sessionId, next);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
18
44
|
|
|
19
|
-
/** Update shared completion reminders from one Host session-list snapshot. */
|
|
20
45
|
function updateCompletionStates(ctx, items, deviceKey = 'legacy') {
|
|
21
46
|
const states = completionStateOf(ctx, deviceKey);
|
|
22
47
|
const seen = new Set();
|
|
@@ -25,37 +50,30 @@ function updateCompletionStates(ctx, items, deviceKey = 'legacy') {
|
|
|
25
50
|
seen.add(summary.sessionId);
|
|
26
51
|
const running = !!summary.running;
|
|
27
52
|
const previous = states.get(summary.sessionId);
|
|
28
|
-
//
|
|
29
|
-
// must prove the matching turn/end plus its final assistant message.
|
|
53
|
+
// 不靠状态边沿猜完成:下面的 history 折叠必须自己找到同轮的 turn/end。
|
|
30
54
|
const completed = previous?.completed === true;
|
|
31
|
-
// `wasRunning` is an internal edge marker. A completed notification is
|
|
32
|
-
// only eligible for a turn observed while this Host was alive; a cold
|
|
33
|
-
// list snapshot must not replay an old turn as a new completion.
|
|
34
55
|
states.set(summary.sessionId, {
|
|
35
56
|
running,
|
|
36
57
|
completed: running ? false : completed,
|
|
37
58
|
turn: previous?.turn,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
59
|
+
// 只决定「允许追到多久以前」(见 baselineOf),不再是确认完成的前提。
|
|
60
|
+
wasRunning: previous?.running === true || previous?.wasRunning === true,
|
|
61
|
+
completionEvents: previous?.completionEvents,
|
|
62
|
+
clearedAt: previous?.clearedAt, // 已读水位跨快照延续,读过的轮次不许再亮回来
|
|
63
|
+
// 「上一轮失败」只能由 history 折出,必须跨快照、跨下一轮延续:否则用户失败后
|
|
64
|
+
// 又发一句话,state.turn 前进,红点还没被看见就没了。
|
|
42
65
|
failure: previous?.failure,
|
|
43
66
|
});
|
|
44
67
|
}
|
|
45
|
-
//
|
|
46
|
-
//
|
|
68
|
+
// 列表是本设备作用的权威:不在列表里的条目要丢(防止复用 id 串状态)。带已读水位的
|
|
69
|
+
// 留着 —— 丢了水位,会话离开列表再回来时旧轮次会重新点亮。
|
|
47
70
|
for (const sessionId of states.keys()) {
|
|
48
|
-
if (!seen.has(sessionId)) states.delete(sessionId);
|
|
71
|
+
if (!seen.has(sessionId) && typeof states.get(sessionId)?.clearedAt !== 'number') states.delete(sessionId);
|
|
49
72
|
}
|
|
50
73
|
return states;
|
|
51
74
|
}
|
|
52
75
|
|
|
53
|
-
/**
|
|
54
|
-
* Confirm the running -> idle edge against the event log. Agent status is a
|
|
55
|
-
* coarse activity signal: the agent-loop appends the final assistant/message
|
|
56
|
-
* and only then appends turn/end. Keep the edge tied to that exact turn so a
|
|
57
|
-
* late/stale list response cannot complete a newer (or an older) turn.
|
|
58
|
-
*/
|
|
76
|
+
/** 把 history 最后一轮的结局折到 state 上:结束时刻越过 baselineOf 才算提醒。 */
|
|
59
77
|
function confirmCompletionFromHistory(state, events, running) {
|
|
60
78
|
if (!state || !Array.isArray(events)) return;
|
|
61
79
|
let latestTurn;
|
|
@@ -76,15 +94,19 @@ function confirmCompletionFromHistory(state, events, running) {
|
|
|
76
94
|
}
|
|
77
95
|
}
|
|
78
96
|
if (latestTurn !== undefined) state.turn = latestTurn;
|
|
79
|
-
if (running ||
|
|
97
|
+
if (running || state.turn === undefined) return;
|
|
80
98
|
const end = ends.get(state.turn);
|
|
99
|
+
if (!end) return;
|
|
100
|
+
const baseline = baselineOf(state);
|
|
101
|
+
const endedAt = typeof end.time === 'number' ? end.time : baseline + 1;
|
|
102
|
+
if (endedAt <= baseline) return; // 读过的 / 冷启动之前结束的那一轮
|
|
81
103
|
const assistant = assistants.get(state.turn);
|
|
82
|
-
const reason = end
|
|
104
|
+
const reason = end.data ? end.data.reason : undefined;
|
|
83
105
|
const kind = reason && typeof reason.kind === 'string' ? reason.kind : undefined;
|
|
84
106
|
// 本轮以错误收尾:宿主不会再补一条最终消息,但它刚刚追加过 turn/end —— 下面那条
|
|
85
107
|
// 「assistant 早于 turn/end 就算完成」的规则会把这种轮次判成「已完成待读」,侧栏于是
|
|
86
108
|
// 把一次失败显示成绿点。失败是宿主列表摘要里没有的信息,只能在这里折出来交给 App。
|
|
87
|
-
if (
|
|
109
|
+
if (kind === 'error') {
|
|
88
110
|
const error = reason && reason.error && typeof reason.error === 'object' ? reason.error : {};
|
|
89
111
|
state.completed = false;
|
|
90
112
|
state.failure = {
|
|
@@ -97,18 +119,18 @@ function confirmCompletionFromHistory(state, events, running) {
|
|
|
97
119
|
}
|
|
98
120
|
// max-tokens / aborted 等:这一轮没跑完,同样不能报完成;但它没有错误码可显示,
|
|
99
121
|
// 所以只清完成标记,不产生红点。
|
|
100
|
-
if (
|
|
122
|
+
if (kind !== undefined && kind !== 'completed') {
|
|
101
123
|
state.completed = false;
|
|
102
124
|
state.failure = undefined;
|
|
103
125
|
return;
|
|
104
126
|
}
|
|
105
127
|
// The assistant message must be the settled message for this turn, not an
|
|
106
128
|
// interrupted partial and not merely a preceding turn's message.
|
|
107
|
-
if (
|
|
129
|
+
if (assistant && assistant.seq <= end.seq && assistant.data.interrupted !== true) {
|
|
108
130
|
state.completed = true;
|
|
109
131
|
// 成功收尾的轮次覆盖旧失败:标记必须跟着最新一次运行走。
|
|
110
132
|
state.failure = undefined;
|
|
111
133
|
}
|
|
112
134
|
}
|
|
113
135
|
|
|
114
|
-
module.exports = { updateCompletionStates, confirmCompletionFromHistory };
|
|
136
|
+
module.exports = { observeCompletionEvent, updateCompletionStates, confirmCompletionFromHistory };
|
package/lib/mobile/events.js
CHANGED
|
@@ -24,6 +24,16 @@ const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal
|
|
|
24
24
|
|
|
25
25
|
const SSE_RETRY_MS = 3000;
|
|
26
26
|
const DEDUP_WINDOW = 2048;
|
|
27
|
+
/**
|
|
28
|
+
* 写缓冲的两道水位(字节),与 `mobile/stream.js` 同一口径。
|
|
29
|
+
*
|
|
30
|
+
* `res.write()` 在客户端读得慢时返回 false,字节留在 Node 的写队列里。设备级流以前
|
|
31
|
+
* 完全不看这个信号,于是「手机挂着一条流、网又慢」时帧无限堆积 —— 2026-10-17 真机
|
|
32
|
+
* 现场:堆快照里 17.6 万条 SSE 帧(466MB)挂在 `WriteWrap._chunks` 上,宿主 70 分钟
|
|
33
|
+
* 涨到 3.9G 被 OOM 杀掉。软水位只丢信号帧(幂等、下一条变更会再补),硬水位才收尾。
|
|
34
|
+
*/
|
|
35
|
+
const SSE_SHED_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
36
|
+
const SSE_CLOSE_BUFFERED_BYTES = 16 * 1024 * 1024;
|
|
27
37
|
/** 心跳帧间隔。App 用它判定「这条流真的还活着」,必须与 app 侧的 SIGNAL_HEARTBEAT_MS 一致。 */
|
|
28
38
|
const HEARTBEAT_MS = 15_000;
|
|
29
39
|
|
|
@@ -56,12 +66,23 @@ function stableEventId(payload, frame) {
|
|
|
56
66
|
return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
/**
|
|
70
|
+
* 写一条 SSE 记录。返回值与 `mobile/stream.js` 的同名函数一致:`ok:false` 表示该收尾,
|
|
71
|
+
* `shed:true` 表示这条帧被主动丢掉(写缓冲超过软水位)。
|
|
72
|
+
*
|
|
73
|
+
* 设备级流的帧全是「有东西变了」的信号,本来就幂等:丢掉一帧的代价是那一次刷新要等
|
|
74
|
+
* 下一次变更或 60s 兜底轮询;而堆在写缓冲里的代价是宿主 OOM(2026-10-17 现场:466MB
|
|
75
|
+
* 的 SSE 帧挂在 socket 写队列上,宿主撞 4GB 上限被杀)。所以软水位一律 shed。
|
|
76
|
+
*/
|
|
59
77
|
function writeSse(res, payload, frame) {
|
|
78
|
+
const buffered = Number(res.writableLength) || 0;
|
|
79
|
+
if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
|
|
80
|
+
if (buffered > SSE_SHED_BUFFERED_BYTES) return { ok: true, shed: true };
|
|
60
81
|
const id = stableEventId(payload, frame);
|
|
61
82
|
const fields = [`retry: ${SSE_RETRY_MS}`];
|
|
62
83
|
if (id) fields.push(`id: ${id}`);
|
|
63
84
|
fields.push(`data: ${JSON.stringify(payload)}`, '', '');
|
|
64
|
-
try { res.write(`${fields.join('\n')}\n`); return true; } catch { return false; }
|
|
85
|
+
try { res.write(`${fields.join('\n')}\n`); return { ok: true }; } catch { return { ok: false }; }
|
|
65
86
|
}
|
|
66
87
|
|
|
67
88
|
async function* mergeHostAndMux(ctx, signal) {
|
|
@@ -153,13 +174,13 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
153
174
|
/** 这条连接上真正写出去的帧数,只用于收尾日志(诊断「连着却一帧不发」)。 */
|
|
154
175
|
let framesSent = 0;
|
|
155
176
|
const writeFrame = (devicePayload) => {
|
|
156
|
-
const
|
|
177
|
+
const result = writeSse(res, {
|
|
157
178
|
type: 'device/event',
|
|
158
179
|
version: 1,
|
|
159
180
|
payload: devicePayload,
|
|
160
181
|
}, null);
|
|
161
|
-
if (
|
|
162
|
-
return
|
|
182
|
+
if (result.ok && !result.shed) framesSent += 1;
|
|
183
|
+
return result.ok;
|
|
163
184
|
};
|
|
164
185
|
/**
|
|
165
186
|
* 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就在这里推给手机。
|
|
@@ -267,7 +288,9 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
267
288
|
payload,
|
|
268
289
|
};
|
|
269
290
|
// Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
|
|
270
|
-
|
|
291
|
+
const sent = writeSse(res, envelope, frame);
|
|
292
|
+
if (sent.shed) continue;
|
|
293
|
+
if (!sent.ok) { clientGone = true; break; }
|
|
271
294
|
framesSent += 1;
|
|
272
295
|
}
|
|
273
296
|
if (!clientGone) loopEnded = true;
|
package/lib/mobile/plugins.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 关键:这个宿主服务 **不在 apiProxy 上** —— 它是 Typert Remote 服务
|
|
12
12
|
* (dsh-host-plugin-inventory 的 pluginInventory)。手机侧本来就能到它:
|
|
13
|
-
* lib/mobile/sessions.js
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* lib/mobile/sessions.js 早已派发 commands.execute,lib/mobile/references.js 通了
|
|
14
|
+
* fileReferences / sessionReferenceResolver,这里照搬同一条路 —— 都经
|
|
15
|
+
* host-bridge/transport.js 的 `invoke`,由它统一补 Peer 身份与网关错误收敛。
|
|
16
16
|
*
|
|
17
17
|
* 接口(只转发宿主的部署快照,不做任何加工):
|
|
18
18
|
* - GET /api/mobile/v1/plugins → pluginInventory.list
|
|
@@ -32,17 +32,13 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
const { sendJson } = require('../shared/http-util');
|
|
35
|
+
const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
|
|
35
36
|
|
|
36
37
|
const PLUGIN_ID_MAX_CHARS = 200; // entryId 长度上限
|
|
37
38
|
const PLUGIN_NAME_MAX_CHARS = 300; // moduleName 长度上限
|
|
38
39
|
const PLUGIN_MAX_ITEMS = 500; // 手机侧兜底上限(真实宿主是几十条)
|
|
39
40
|
const PLUGIN_PHASES = new Set(['failed', 'pending', 'active', 'loading', 'unloading']);
|
|
40
41
|
|
|
41
|
-
/** 宿主网关;宿主没组合 dsh-api-gateway 时返回 null,调用方回 503。 */
|
|
42
|
-
function gatewayOf(ctx) {
|
|
43
|
-
return (ctx.get && ctx.get('typertGateway')) || null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
42
|
/** 网关缺失/命名空间缺失对客户端是同一件事:宿主这个能力没装。 */
|
|
47
43
|
const UNAVAILABLE = Object.freeze({
|
|
48
44
|
code: 'plugin-inventory-unavailable',
|
|
@@ -57,35 +53,33 @@ function errorText(error) {
|
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
/**
|
|
60
|
-
*
|
|
56
|
+
* 经网关取一次插件清单快照。
|
|
57
|
+
*
|
|
58
|
+
* 走 host-bridge/transport.js 的 `invoke`:Peer 身份、`gateway/` 前缀的缺失类错误
|
|
59
|
+
* 都在那里统一处理,这里只做本域的错误码翻译。**不能直接 `gateway.invoke`** ——
|
|
60
|
+
* 0.2.1 起网关要求每个请求自带 Peer,绕过去就是对 undefined 读 `.signal`,
|
|
61
|
+
* 手机端看到的是「插件清单加载失败」。
|
|
61
62
|
*
|
|
62
63
|
* @returns {Promise<{value?: unknown, error?: {code: string, message: string}}>}
|
|
63
64
|
*/
|
|
64
65
|
async function callInventory(ctx) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
} catch (error) {
|
|
72
|
-
// 0.2.0 的网关错误码带 `gateway/` 前缀(dsh-api-gateway/src/types.ts:117-136);
|
|
73
|
-
// 0.1.x 时期是不带前缀的。两种都认,否则「宿主没装这个服务」的降级分支形同虚设。
|
|
74
|
-
const raw = error && typeof error.code === 'string' ? error.code.replace(/^gateway\//, '') : ''
|
|
75
|
-
const code = raw === 'invocation-unavailable' || raw === 'service-unavailable' || raw === 'method-unavailable'
|
|
76
|
-
? UNAVAILABLE.code
|
|
77
|
-
: 'plugin-inventory-error';
|
|
78
|
-
return { error: { code, message: errorText(error) } };
|
|
66
|
+
// 没有声明参数 → args 必须是空对象;这个方法也没声明 cancellation,不能多塞 signal。
|
|
67
|
+
const result = await invoke(ctx, 'pluginInventory', 'list', {});
|
|
68
|
+
if (result.error) {
|
|
69
|
+
// 宿主的服务/方法缺失由传输层收敛成同一个码:对客户端就是「这个能力没装」。
|
|
70
|
+
if (result.error.code === GATEWAY_UNAVAILABLE.code) return { error: { ...UNAVAILABLE } };
|
|
71
|
+
return { error: { code: 'plugin-inventory-error', message: errorText(result.error) } };
|
|
79
72
|
}
|
|
73
|
+
const value = result.value;
|
|
80
74
|
// decode 本体就是 { entries: […] };另外两种形状只是容错,防宿主以后改成联合。
|
|
81
|
-
if (
|
|
82
|
-
if (
|
|
83
|
-
if (
|
|
84
|
-
const raw =
|
|
75
|
+
if (value && typeof value === 'object') {
|
|
76
|
+
if (value.ok === true) return { value: value.value };
|
|
77
|
+
if (value.ok === false) {
|
|
78
|
+
const raw = value.error && typeof value.error === 'object' ? value.error : {};
|
|
85
79
|
const code = typeof raw.code === 'string' && raw.code ? raw.code : 'plugin-inventory-error';
|
|
86
80
|
return { error: { code, message: errorText(raw) } };
|
|
87
81
|
}
|
|
88
|
-
return { value
|
|
82
|
+
return { value };
|
|
89
83
|
}
|
|
90
84
|
return { error: { code: 'plugin-inventory-error', message: '宿主插件清单返回了意外的结果' } };
|
|
91
85
|
}
|