dsh-mobile-plugin 0.1.78 → 0.1.84
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/host-bridge/transport.js +33 -5
- package/lib/mobile/session-cache.js +31 -24
- package/lib/mobile/session-send.js +3 -2
- package/lib/relay/tunnel.js +30 -9
- package/lib/session-cleanup.cjs +96 -17
- package/lib/shell/device-ota.js +96 -52
- package/lib/shell/manager.js +99 -1
- 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 };
|
|
@@ -128,11 +128,41 @@ function dropUnexpectedArgs(args, message) {
|
|
|
128
128
|
return removed ? next : null;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* 调用宿主时一并交出「本机操作者」身份。
|
|
133
|
+
*
|
|
134
|
+
* 0.2.1 起网关在派发前先 `assertPeerLive(request.peer)`:Peer 缺了就对 undefined 读
|
|
135
|
+
* `.signal`,抛 `Cannot read properties of undefined (reading 'signal')`,这里把它收敛成
|
|
136
|
+
* `host-call-failed` —— 手机上表现为 /sessions、/workspaces 一律 400,会话镜像永远
|
|
137
|
+
* 「未推送」(只有本机自算的 /status 还活着)。0.2.0 及更早没有这道检查(网关自己回落
|
|
138
|
+
* operatorPeer),也没有 `browserPeer` 这个成员,所以取不到就**不带**,行为与从前一致。
|
|
139
|
+
*
|
|
140
|
+
* 用 `ctx.get` 而不是 inject:旧宿主没这个成员、极简 profile 也可能没有这个服务,
|
|
141
|
+
* 注入一个不存在的服务会让整行永远 pending(见 impl.js 里 apiProxy 那段教训)。
|
|
142
|
+
*/
|
|
143
|
+
function peerOf(ctx) {
|
|
144
|
+
try {
|
|
145
|
+
const connection = ctx.get && ctx.get('connection');
|
|
146
|
+
const peer = connection && connection.browserPeer;
|
|
147
|
+
return peer && typeof peer === 'object' ? peer : null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 补齐到「当前宿主认得」的请求形状:新旧宿主的差别只在这一个函数里判定。 */
|
|
154
|
+
function hostRequest(ctx, namespace, method, args, signal) {
|
|
155
|
+
const request = { namespace, method, args };
|
|
156
|
+
if (signal) request.signal = signal;
|
|
157
|
+
const peer = peerOf(ctx);
|
|
158
|
+
if (peer) request.peer = peer;
|
|
159
|
+
return request;
|
|
160
|
+
}
|
|
161
|
+
|
|
131
162
|
async function invoke(ctx, namespace, method, args, signal) {
|
|
132
163
|
const gateway = gatewayOf(ctx);
|
|
133
164
|
if (!gateway) return { error: { ...UNAVAILABLE } };
|
|
134
|
-
const request =
|
|
135
|
-
if (signal) request.signal = signal;
|
|
165
|
+
const request = hostRequest(ctx, namespace, method, args, signal);
|
|
136
166
|
try {
|
|
137
167
|
return { value: await gateway.invoke(request) };
|
|
138
168
|
} catch (error) {
|
|
@@ -154,9 +184,7 @@ async function invoke(ctx, namespace, method, args, signal) {
|
|
|
154
184
|
async function openStream(ctx, namespace, method, args, signal) {
|
|
155
185
|
const gateway = gatewayOf(ctx);
|
|
156
186
|
if (!gateway) throw Object.assign(new Error(UNAVAILABLE.message), { code: UNAVAILABLE.code });
|
|
157
|
-
|
|
158
|
-
if (signal) request.signal = signal;
|
|
159
|
-
return gateway.stream(request);
|
|
187
|
+
return gateway.stream(hostRequest(ctx, namespace, method, args, signal));
|
|
160
188
|
}
|
|
161
189
|
|
|
162
190
|
async function invokeEnvelope(ctx, namespace, method, args, signal) {
|
|
@@ -85,7 +85,7 @@ function sessionListResponseState(ctx, deviceKey = 'legacy') {
|
|
|
85
85
|
function sessionCleanupStateOf(ctx) {
|
|
86
86
|
let state = deletedSessionState.get(ctx);
|
|
87
87
|
if (!state) {
|
|
88
|
-
state = { deleted: new Set()
|
|
88
|
+
state = { deleted: new Set() };
|
|
89
89
|
deletedSessionState.set(ctx, state);
|
|
90
90
|
}
|
|
91
91
|
return state;
|
|
@@ -96,7 +96,6 @@ function rememberDeletedSessionIds(ctx, sessionIds) {
|
|
|
96
96
|
for (const sessionId of sessionIds) {
|
|
97
97
|
if (typeof sessionId !== 'string' || !sessionId) continue;
|
|
98
98
|
state.deleted.add(sessionId);
|
|
99
|
-
state.pendingUnarchive.add(sessionId);
|
|
100
99
|
}
|
|
101
100
|
}
|
|
102
101
|
|
|
@@ -104,15 +103,6 @@ function deletedSessionIdsOf(ctx) {
|
|
|
104
103
|
return sessionCleanupStateOf(ctx).deleted;
|
|
105
104
|
}
|
|
106
105
|
|
|
107
|
-
function pendingUnarchiveSessionIdsOf(ctx) {
|
|
108
|
-
return sessionCleanupStateOf(ctx).pendingUnarchive;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function clearPendingUnarchiveSessionIds(ctx, sessionIds) {
|
|
112
|
-
const pending = sessionCleanupStateOf(ctx).pendingUnarchive;
|
|
113
|
-
for (const sessionId of sessionIds) pending.delete(sessionId);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
106
|
function sessionPreviewStateOf(ctx) {
|
|
117
107
|
let state = sessionPreviewCache.get(ctx);
|
|
118
108
|
if (!state) {
|
|
@@ -162,24 +152,41 @@ function completionStateOf(ctx, deviceKey = 'legacy') {
|
|
|
162
152
|
return states;
|
|
163
153
|
}
|
|
164
154
|
|
|
155
|
+
function completionStateMapsOf(ctx) {
|
|
156
|
+
return [...(completionStates.get(ctx)?.values() || [])];
|
|
157
|
+
}
|
|
158
|
+
|
|
165
159
|
/**
|
|
166
|
-
*
|
|
160
|
+
* 标记「这条会话读过了」:记一个水位,把当前的完成 / 失败提醒清掉,返回清掉的条数。
|
|
167
161
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
162
|
+
* **不删条目**(旧实现是 delete)。删掉之后重建的条目会丢掉水位,于是「读过的轮次」
|
|
163
|
+
* 会在下一次 history 折叠里重新点亮 —— 2026-10-17 真机:桌面读过之后手机上红点 / 绿点
|
|
164
|
+
* 还在或者反过来。水位才是「这个时刻之前的轮次不算数」的唯一凭据。
|
|
165
|
+
*/
|
|
166
|
+
function markCompletionRead(ctx, deviceKey, sessionId, at = Date.now()) {
|
|
167
|
+
const state = completionStates.get(ctx)?.get(deviceKey)?.get(sessionId);
|
|
168
|
+
if (!state) return 0;
|
|
169
|
+
const had = state.completed === true || !!state.failure;
|
|
170
|
+
state.clearedAt = at;
|
|
171
|
+
state.completed = false;
|
|
172
|
+
state.failure = undefined;
|
|
173
|
+
// 已读要压过「本进程看见过它跑」这一条(见 completion.js 的 baselineOf):
|
|
174
|
+
// 否则同一轮会在下一次列表折叠里又亮回来。
|
|
175
|
+
state.wasRunning = false;
|
|
176
|
+
return had ? 1 : 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 清掉某个会话在**所有**设备维度上的完成提醒,返回真正清掉过的设备数。
|
|
171
181
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* 轮次照样能重新亮起来 —— 「新一轮完成压过更早的已读」这条语义就落在这里。
|
|
182
|
+
* 用于**桌面端说「我读过了」**——读一次就是读过了,不该只清某台设备。手机上点开
|
|
183
|
+
* 走 markCompletionRead 只清自己那台(每台手机各有一份,互不干扰)。
|
|
175
184
|
*/
|
|
176
|
-
function clearCompletionForAllDevices(ctx, sessionId) {
|
|
185
|
+
function clearCompletionForAllDevices(ctx, sessionId, at = Date.now()) {
|
|
177
186
|
const byDevice = completionStates.get(ctx);
|
|
178
187
|
if (!byDevice) return 0;
|
|
179
188
|
let cleared = 0;
|
|
180
|
-
for (const
|
|
181
|
-
if (states.delete(sessionId)) cleared += 1;
|
|
182
|
-
}
|
|
189
|
+
for (const deviceKey of byDevice.keys()) cleared += markCompletionRead(ctx, deviceKey, sessionId, at);
|
|
183
190
|
return cleared;
|
|
184
191
|
}
|
|
185
192
|
|
|
@@ -283,14 +290,14 @@ module.exports = {
|
|
|
283
290
|
invalidateSessionList,
|
|
284
291
|
rememberDeletedSessionIds,
|
|
285
292
|
deletedSessionIdsOf,
|
|
286
|
-
pendingUnarchiveSessionIdsOf,
|
|
287
|
-
clearPendingUnarchiveSessionIds,
|
|
288
293
|
dropSessionListCache,
|
|
289
294
|
sessionPreviewStateOf,
|
|
290
295
|
sessionPreviewInFlightStateOf,
|
|
291
296
|
sessionPreviewClockOf,
|
|
292
297
|
mirrorHistoryStateOf,
|
|
293
298
|
completionStateOf,
|
|
299
|
+
completionStateMapsOf,
|
|
300
|
+
markCompletionRead,
|
|
294
301
|
clearCompletionForAllDevices,
|
|
295
302
|
completionGenerationOf,
|
|
296
303
|
bumpCompletionGeneration,
|
|
@@ -17,7 +17,7 @@ const { unwrap } = require('./envelope.js');
|
|
|
17
17
|
const { callSessions, mobilePromptRpcId } = require('./rpc.js');
|
|
18
18
|
const {
|
|
19
19
|
canonicalDeviceKey, invalidateSessionList, invalidateSessionModels,
|
|
20
|
-
|
|
20
|
+
markCompletionRead, sendIdempotencyStateOf,
|
|
21
21
|
} = require('./session-cache.js');
|
|
22
22
|
const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
|
|
23
23
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
@@ -211,7 +211,8 @@ async function handleSessionRead(ctx, req, res, fallbackDeviceKey = 'legacy') {
|
|
|
211
211
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId }' } });
|
|
212
212
|
}
|
|
213
213
|
const deviceKey = canonicalDeviceKey(req, fallbackDeviceKey);
|
|
214
|
-
|
|
214
|
+
// 只清这台设备自己的提醒;水位留在状态里,读过的轮次就不会再亮回来。
|
|
215
|
+
markCompletionRead(ctx, deviceKey, body.sessionId);
|
|
215
216
|
// Invalidate the cache and any build already reading history. Its generation
|
|
216
217
|
// check prevents a stale build from restoring `completed` after this ack.
|
|
217
218
|
invalidateSessionList(ctx, deviceKey);
|