dsh-mobile-plugin 0.1.93 → 0.1.94
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/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/session-flags.js +63 -0
- package/lib/mobile/session-snapshot.js +9 -17
- package/lib/mobile/sessions.js +3 -38
- 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/mobile-api.js +18 -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
package/lib/mobile/sessions.js
CHANGED
|
@@ -52,44 +52,13 @@ const { promptOnce, handleSend, handleCommands, handleSessionRead, handleSession
|
|
|
52
52
|
const { handleSessionSearch } = require('./session-search.js');
|
|
53
53
|
const { handleAgentPresets } = require('./session-presets.js');
|
|
54
54
|
const { handleGoalAction } = require('./session-goal.js');
|
|
55
|
+
const { workspaceSessionFlags, pendingInteractionOf } = require('./session-flags.js');
|
|
55
56
|
// 预览(lastMessage 补全)与它自己的重读节流:只读日志、不发写,单列一个子模块。
|
|
56
57
|
const { HISTORY_FOR_ENRICH, sessionPreviewFor } = require('./session-preview.js');
|
|
57
|
-
const approvals = require('./approvals.js');
|
|
58
|
-
const questions = require('./questions.js');
|
|
59
58
|
|
|
60
59
|
const SESSIONS_PREVIEW_LIMIT = 20; // 仅为最近/运行中的会话补最后消息预览
|
|
61
60
|
const SESSION_LIST_CACHE_MS = 10000; // 会话列表增料缓存(降低手机轮询对桌面的冲击)
|
|
62
61
|
|
|
63
|
-
/**
|
|
64
|
-
* 宿主的两个**会话集合**(同一次 `workspace.list` 快照里拿到):
|
|
65
|
-
*
|
|
66
|
-
* - `archivedSessionIds`:已归档的会话。桌面端侧边栏据此把它们藏起来,而
|
|
67
|
-
* `sessions.list` 自身**不过滤**归档,所以移动端必须自己读这个集合:此前完全没读,
|
|
68
|
-
* App 的「归档会话」写是写成功了(宿主确实把 id 记进了 archivedSessionIds),列表却
|
|
69
|
-
* 照旧把那一行发回手机,用户看到的就是一次毫无反馈的空操作。
|
|
70
|
-
* - `pinnedSessionIds`:已置顶的会话(宿主侧「最近置顶的在前」)。桌面端也只从这份
|
|
71
|
-
* 快照读置顶,`sessions.list` 的摘要里没有置顶字段,所以移动端同样要自己读,
|
|
72
|
-
* 否则 App 既画不出置顶标记、也排不到工作区最前。
|
|
73
|
-
*
|
|
74
|
-
* 两个集合合并成一次 RPC:它们同源同一次快照,而会话列表每 10 秒就会重建一次,
|
|
75
|
-
* 分两次 `workspace.list` 等于平白给手机多塞一轮往返。
|
|
76
|
-
*
|
|
77
|
-
* 取不到时两个集合都回空(失败放开):宁可多显示几行、少几个置顶,也不能因为一次
|
|
78
|
-
* RPC 抖动让整个会话列表凭空变空。
|
|
79
|
-
*/
|
|
80
|
-
async function workspaceSessionFlags(ctx) {
|
|
81
|
-
try {
|
|
82
|
-
const resp = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
|
|
83
|
-
const value = unwrap(resp).value;
|
|
84
|
-
const idSetOf = (field) => new Set(
|
|
85
|
-
value && Array.isArray(value[field]) ? value[field].filter(id => typeof id === 'string') : [],
|
|
86
|
-
);
|
|
87
|
-
return { archived: idSetOf('archivedSessionIds'), pinned: idSetOf('pinnedSessionIds') };
|
|
88
|
-
} catch {
|
|
89
|
-
return { archived: new Set(), pinned: new Set() };
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
62
|
/**
|
|
94
63
|
* GET /api/mobile/v1/session/head?sessionId=…&parentSessionId=…&mode=…(token)
|
|
95
64
|
*
|
|
@@ -229,12 +198,8 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
|
|
|
229
198
|
// 该项是廉价字段;history 仅用于补「最后消息预览」,属于重活,见下方预览子集。
|
|
230
199
|
const rows = allSummaries.map((s) => {
|
|
231
200
|
const completionState = completionStatesForContext.get(s.sessionId);
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
// 列表页连黄点都不会亮。审批优先(一次 turn 里审批总是挡在提问前面)。
|
|
235
|
-
const pendingInteraction = (s.pendingInteraction === 'approval' || s.pendingInteraction === 'plan-review' || s.pendingInteraction === 'question')
|
|
236
|
-
? s.pendingInteraction
|
|
237
|
-
: (approvals.hasPending(s.sessionId) ? 'approval' : questions.pendingKind(s.sessionId));
|
|
201
|
+
// 待处理标记:判据与后台监控共用一份(见 session-flags.js),别再在这里写第二份。
|
|
202
|
+
const pendingInteraction = pendingInteractionOf(s.sessionId, s);
|
|
238
203
|
const base = {
|
|
239
204
|
sessionId: s.sessionId,
|
|
240
205
|
title: listTitle(s),
|
package/lib/mobile/stream.js
CHANGED
|
@@ -38,9 +38,20 @@ const approvals = require('./approvals.js');
|
|
|
38
38
|
const questions = require('./questions.js');
|
|
39
39
|
|
|
40
40
|
// Android React Native's XHR may buffer tiny chunked responses until its
|
|
41
|
-
// internal progress threshold.
|
|
42
|
-
// promptly; the padding is an SSE comment
|
|
41
|
+
// internal progress threshold. Keep each window of records padded past that
|
|
42
|
+
// threshold so onprogress exposes them promptly; the padding is an SSE comment
|
|
43
|
+
// and is ignored by clients.
|
|
43
44
|
const SSE_FLUSH_PADDING_BYTES = 2048;
|
|
45
|
+
/**
|
|
46
|
+
* 草稿增量的补齐窗口。
|
|
47
|
+
*
|
|
48
|
+
* **只对 `assistant/chunk` 生效**:一次流式回复每秒有几十条增量,每条都补 2048 字节
|
|
49
|
+
* 等于把正文放大十几倍,而这些字节两条腿(宿主→中转、中转→手机)都是实打实要走的。
|
|
50
|
+
* 按 120ms 一个窗口补齐,界面出字节奏不变(客户端本来就攒够一个阈值才渲染一次)。
|
|
51
|
+
* 带 seq 的事件、审批卡、排队/任务帧、心跳照旧补满整份 —— 它们慢一拍就是「消息不落地」
|
|
52
|
+
* 「卡不出现」。实现见 lib/shared/sse-write.js 的 takePadding。
|
|
53
|
+
*/
|
|
54
|
+
const SSE_PAD_MIN_INTERVAL_MS = 120;
|
|
44
55
|
const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
|
|
45
56
|
|
|
46
57
|
/**
|
|
@@ -75,22 +86,51 @@ function logStream(sessionId, message, error) {
|
|
|
75
86
|
} catch { /* ignore */ }
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
/**
|
|
90
|
+
* 这一帧是不是「流式回复里的草稿增量」——唯一可以按窗口补齐的那一类。
|
|
91
|
+
*
|
|
92
|
+
* 判据与丢帧口径同源:没有 seq 的 `assistant/chunk` 是草稿,同一轮收尾的
|
|
93
|
+
* `assistant/message` 带完整正文、重连回放也能补齐;所以把它按窗口补齐最坏只是慢一拍,
|
|
94
|
+
* 而其它帧慢一拍就可能表现为「消息不落地」「审批卡不出现」。
|
|
95
|
+
*/
|
|
96
|
+
function isStreamDraft(payload) {
|
|
97
|
+
if (!payload) return false;
|
|
98
|
+
// 实时帧写的是内层事件(`{type:'assistant/chunk', data}`),回放路径偶尔仍带一层
|
|
99
|
+
// `session/event` 外壳,两种形状都认。
|
|
100
|
+
const type = payload.type === 'session/event' && payload.event ? payload.event.type : payload.type;
|
|
101
|
+
return type === 'assistant/chunk';
|
|
102
|
+
}
|
|
103
|
+
|
|
78
104
|
/**
|
|
79
105
|
* 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
|
|
80
106
|
*
|
|
81
107
|
* 这条流的策略:软水位只丢**没有 seq** 的增量(`assistant/chunk` 是可丢弃草稿,同一轮
|
|
82
|
-
* 收尾的 `assistant/message` 带完整正文且有 seq
|
|
83
|
-
*
|
|
108
|
+
* 收尾的 `assistant/message` 带完整正文且有 seq,重连回放能补齐);草稿增量按 120ms
|
|
109
|
+
* 一个窗口补到 2048 字节以上,**成批灌入的回放**同样按窗口补齐,其余帧补满整份,
|
|
110
|
+
* 让 RN 的 XHR 尽快吐出来(见 `SSE_PAD_MIN_INTERVAL_MS`)。
|
|
111
|
+
*
|
|
112
|
+
* `options.burst` = 成批灌入(断线回放)。回放是几百到几千条连着写出去的:客户端本来
|
|
113
|
+
* 就在持续收字节、进度事件照常触发,而逐条补满分摊到一次重连就是几 MB 白字节(实测一次
|
|
114
|
+
* 903 帧的回放共 2.06MB,其中约 1.85MB 是注释)。窗口机制照样保证「每 120ms 至少一窗
|
|
115
|
+
* 2048 字节」,而且这条流的**第一条**记录总是落在空窗口上、拿到整份填充,所以「重连后
|
|
116
|
+
* 界面立刻出内容」不受影响。实时流里那条「带 seq 的帧补满整份」的理由(它可能夹在一串
|
|
117
|
+
* 慢草稿后面单独到达,慢一拍就是「消息不落地」)在成批灌入里不成立,别把它抄到回放上。
|
|
84
118
|
*
|
|
119
|
+
* @param {{burst?: boolean}} [options] 成批灌入(回放)标记,见上。
|
|
85
120
|
* @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
|
|
86
121
|
* `ok:false` → 这条流该收尾(客户端走了,或积压超过硬水位);
|
|
87
122
|
* `shed:true` → 这条**增量**帧被丢掉、连接还活着;
|
|
88
123
|
* `needDrain:true` → 写成功了,但底下已经写不动,调用方要先 await 一次 drain 再继续。
|
|
89
124
|
*/
|
|
90
|
-
function writeSse(res, payload, frame) {
|
|
125
|
+
function writeSse(res, payload, frame, options = {}) {
|
|
126
|
+
// 两条路走窗口补齐:可丢弃的草稿增量,与成批灌入的回放(见上面 options.burst)。
|
|
127
|
+
// 其余帧一律补满整份 —— 带 seq 的事件、审批卡、排队/任务帧、心跳是一次回复里真正
|
|
128
|
+
// 要落地的东西,宁可多花 2KB 也不让它们卡在客户端的缓冲里等下一次写入。
|
|
91
129
|
return sharedWriteSse(res, payload, {
|
|
92
130
|
frame,
|
|
93
|
-
|
|
131
|
+
...(options.burst === true || isStreamDraft(payload)
|
|
132
|
+
? { paddingBytes: SSE_FLUSH_PADDING_BYTES, padEveryMs: SSE_PAD_MIN_INTERVAL_MS }
|
|
133
|
+
: { padding: ssePadding(SSE_FLUSH_PADDING_BYTES) }),
|
|
94
134
|
// 审批帧没有 seq,按默认口径会在软水位上被丢掉(见上面那条策略)。但它是「人工决策
|
|
95
135
|
// 的入口」不是草稿:丢一张,手机就再也看不到那张卡(软水位只在客户端跟不上时到,
|
|
96
136
|
// 回放也不覆盖这一段)。它一次审批也就一两帧,不是撑爆写队列的那类帧。
|
|
@@ -155,6 +195,13 @@ async function handleStream(ctx, req, res) {
|
|
|
155
195
|
const url = new URL(req.url, 'http://x');
|
|
156
196
|
const sessionId = url.searchParams.get('sessionId');
|
|
157
197
|
if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
|
|
198
|
+
// 子会话地址:App 打开子会话的流时带上父会话与模式(见 app/src/sse.ts 的
|
|
199
|
+
// openSessionStream 传参)。缺这两个字段就是普通会话,与历史那条同一套判定。
|
|
200
|
+
const parentSessionId = url.searchParams.get('parentSessionId');
|
|
201
|
+
const mode = url.searchParams.get('mode');
|
|
202
|
+
const streamAddress = typeof parentSessionId === 'string' && parentSessionId
|
|
203
|
+
? { sessionId, parentSessionId, ...(mode === 'one-shot' || mode === 'continuable' ? { mode } : {}) }
|
|
204
|
+
: { sessionId };
|
|
158
205
|
try {
|
|
159
206
|
res.writeHead(200, {
|
|
160
207
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
@@ -218,7 +265,7 @@ async function handleStream(ctx, req, res) {
|
|
|
218
265
|
try {
|
|
219
266
|
// 断线续传:先回放 history 中 seq > resumeSeq 的事件(收敛状态)。
|
|
220
267
|
if (resumeSeq != null && resumeSeq > 0) {
|
|
221
|
-
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); });
|
|
268
|
+
const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); }, streamAddress);
|
|
222
269
|
if (replay.gap && !ac.signal.aborted) {
|
|
223
270
|
const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
|
|
224
271
|
// 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
|
|
@@ -230,7 +277,10 @@ async function handleStream(ctx, req, res) {
|
|
|
230
277
|
// payload 里带 sessionId:0.2.0 的宿主没有设备级事件流了,兼容桥据此收窄成
|
|
231
278
|
// 这一条会话的 follow + 投影 + 任务三条流(见 mobile/host-bridge.js 的 muxFrames)。
|
|
232
279
|
// 不带就是设备级视角,只会给工作区基线与全局投影 —— 这里反正只要单会话。
|
|
233
|
-
|
|
280
|
+
// 子会话必须带上父会话(`parentSessionId` + `mode`):宿主对 origin==='subagent'
|
|
281
|
+
// 的会话拿普通地址订阅一律回 agent-busy,表现是「页面打得开、内容不更新」。
|
|
282
|
+
// 地址的拼装与历史那条同源,见 host-bridge/event-synthesis.js 的 sessionAddress。
|
|
283
|
+
const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: streamAddress }, ac.signal);
|
|
234
284
|
// 写一帧并把三种结果统一收口:丢增量、需要等 drain、该收尾。
|
|
235
285
|
// 「需要等 drain」这一步是这次 OOM 的正解 —— 写不动就停手,等底层消化完再继续,
|
|
236
286
|
// 而不是把帧继续往内存里塞。
|
|
@@ -312,9 +362,15 @@ async function handleStream(ctx, req, res) {
|
|
|
312
362
|
* 从会话 history 回放 seq > sinceSeq 的事件(升序),逐个经附件解析后写 SSE。
|
|
313
363
|
* 通过 onDelivered(seq) 回报已交付的最大 seq,供上层去重。
|
|
314
364
|
*/
|
|
315
|
-
async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
|
|
365
|
+
async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered, address) {
|
|
316
366
|
try {
|
|
317
|
-
|
|
367
|
+
// 子会话的重放也要带父会话(同 historyAddress 的口径),否则宿主回 agent-busy,
|
|
368
|
+
// 这条断线续传静默退化成「后面缺一段」。
|
|
369
|
+
const { value } = unwrap(await callSessions(ctx, 'history', {
|
|
370
|
+
sessionId,
|
|
371
|
+
maxMessages: REPLAY_WINDOW,
|
|
372
|
+
...(address && address.parentSessionId ? { parentSessionId: address.parentSessionId, mode: address.mode || 'continuable' } : {}),
|
|
373
|
+
}));
|
|
318
374
|
const events = (value && Array.isArray(value.events)) ? value.events : [];
|
|
319
375
|
const seqs = events
|
|
320
376
|
.map((entry) => (entry?.event || entry)?.seq)
|
|
@@ -336,7 +392,9 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
|
|
|
336
392
|
// Keep replay data identical to live SSE data: the client reducer consumes
|
|
337
393
|
// the event's own type (turn/start, assistant/message, etc.).
|
|
338
394
|
const payload = { type: 'session/event', sessionId, event };
|
|
339
|
-
|
|
395
|
+
// 成批灌入:这一节几百条连着写,逐条补满 2048 字节注释等于给一次重连凭空加几 MB
|
|
396
|
+
// (见 writeSse 的 options.burst)。
|
|
397
|
+
const sent = writeSse(res, event, { payload }, { burst: true });
|
|
340
398
|
if (!sent.ok) break;
|
|
341
399
|
// 回放是「一次性灌几千条」,最容易把写队列撑爆:写不动就在这里等,别一口气塞完。
|
|
342
400
|
if (sent.needDrain && !(await waitForDrain(res, signal))) break;
|