dsh-mobile-plugin 0.1.85 → 0.1.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/mobile/events.js +28 -7
- package/lib/mobile/history-derive.js +21 -3
- package/lib/mobile/local-images.js +87 -4
- package/lib/mobile/stream.js +83 -17
- package/lib/session-cleanup.cjs +13 -3
- package/package.json +1 -1
package/lib/mobile/events.js
CHANGED
|
@@ -67,22 +67,39 @@ function stableEventId(payload, frame) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
70
|
+
* 这条连接上真正「还没发出去」的字节数。
|
|
71
|
+
*
|
|
72
|
+
* 只看 `res.writableLength` 是错的:它只算响应对象自己那一层,真正的积压在底下的套接字
|
|
73
|
+
* 写队列里(堆快照里那些 `{chunk, encoding, callback}`)。2026-10-18 复测证实:只看
|
|
74
|
+
* 前者时闸门从不触发,内存照涨。所以顺着 socket 一起看,取较大值。
|
|
75
|
+
*/
|
|
76
|
+
function bufferedBytesOf(res) {
|
|
77
|
+
const own = Number(res.writableLength) || 0;
|
|
78
|
+
const socket = res.socket || res.connection;
|
|
79
|
+
const queued = socket ? Number(socket.writableLength) || 0 : 0;
|
|
80
|
+
return Math.max(own, queued);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 写一条 SSE 记录。返回值与 `mobile/stream.js` 的同名函数一致:`ok:false` 该收尾,
|
|
85
|
+
* `shed:true` 这条被丢掉,`needDrain:true` 写进去了但底下已经写不动。
|
|
72
86
|
*
|
|
73
87
|
* 设备级流的帧全是「有东西变了」的信号,本来就幂等:丢掉一帧的代价是那一次刷新要等
|
|
74
88
|
* 下一次变更或 60s 兜底轮询;而堆在写缓冲里的代价是宿主 OOM(2026-10-17 现场:466MB
|
|
75
89
|
* 的 SSE 帧挂在 socket 写队列上,宿主撞 4GB 上限被杀)。所以软水位一律 shed。
|
|
76
90
|
*/
|
|
77
91
|
function writeSse(res, payload, frame) {
|
|
78
|
-
const buffered =
|
|
92
|
+
const buffered = bufferedBytesOf(res);
|
|
79
93
|
if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
|
|
80
94
|
if (buffered > SSE_SHED_BUFFERED_BYTES) return { ok: true, shed: true };
|
|
81
95
|
const id = stableEventId(payload, frame);
|
|
82
96
|
const fields = [`retry: ${SSE_RETRY_MS}`];
|
|
83
97
|
if (id) fields.push(`id: ${id}`);
|
|
84
98
|
fields.push(`data: ${JSON.stringify(payload)}`, '', '');
|
|
85
|
-
try {
|
|
99
|
+
try {
|
|
100
|
+
const accepted = res.write(`${fields.join('\n')}\n`);
|
|
101
|
+
return { ok: true, needDrain: accepted === false };
|
|
102
|
+
} catch { return { ok: false }; }
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
async function* mergeHostAndMux(ctx, signal) {
|
|
@@ -179,8 +196,12 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
179
196
|
version: 1,
|
|
180
197
|
payload: devicePayload,
|
|
181
198
|
}, null);
|
|
182
|
-
if (result.ok
|
|
183
|
-
|
|
199
|
+
if (!result.ok) return false;
|
|
200
|
+
if (result.shed) return true;
|
|
201
|
+
framesSent += 1;
|
|
202
|
+
// 所有调用点都是同步回调(信号总线、心跳),没法在这里 await;写不动就如实回报,
|
|
203
|
+
// 由调用方 abort —— 这条流本来就是「断了重连、靠补发收敛」的语义。
|
|
204
|
+
return !result.needDrain;
|
|
184
205
|
};
|
|
185
206
|
/**
|
|
186
207
|
* 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就在这里推给手机。
|
|
@@ -290,7 +311,7 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
290
311
|
// Keep the existing device/event JSON envelope unchanged; SSE metadata is additive.
|
|
291
312
|
const sent = writeSse(res, envelope, frame);
|
|
292
313
|
if (sent.shed) continue;
|
|
293
|
-
if (!sent.ok) { clientGone = true; break; }
|
|
314
|
+
if (!sent.ok || sent.needDrain) { clientGone = true; break; }
|
|
294
315
|
framesSent += 1;
|
|
295
316
|
}
|
|
296
317
|
if (!clientGone) loopEnded = true;
|
|
@@ -79,6 +79,26 @@ function normalizeResultDiffs(meta) {
|
|
|
79
79
|
return out.length > 0 ? out : undefined;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* tool/result 的可见内容:两种形状都认。
|
|
84
|
+
*
|
|
85
|
+
* - 当前形状:`message.content` 直接就是内容块数组,成败在 `message.isError`;
|
|
86
|
+
* - 旧形状:`message.content[0]` 是 `{ isError, content: [内容块] }` 信封。
|
|
87
|
+
*
|
|
88
|
+
* 曾经只认旧形状,于是真实结果里的图片(`{type:'image', attachment}`)被当成信封剥掉、
|
|
89
|
+
* 整张图丢失,结果级 isError 也读不到(失败的工具显示成成功)。
|
|
90
|
+
*/
|
|
91
|
+
function toolResultParts(msg) {
|
|
92
|
+
const direct = Array.isArray(msg.content) ? msg.content : [];
|
|
93
|
+
const envelope = direct.length === 1 && direct[0] && typeof direct[0] === 'object'
|
|
94
|
+
&& typeof direct[0].type !== 'string' ? direct[0] : null;
|
|
95
|
+
if (!envelope) return { parts: direct, isError: msg.isError === true };
|
|
96
|
+
return {
|
|
97
|
+
parts: Array.isArray(envelope.content) ? envelope.content : [],
|
|
98
|
+
isError: envelope.isError === true || msg.isError === true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
/** 预扫描 tool/result 事件,构建 callId → {isError, code} 映射(对接桌面 toolRowModel 状态派生)。 */
|
|
83
103
|
function buildToolResultMap(events) {
|
|
84
104
|
const map = new Map();
|
|
@@ -88,10 +108,8 @@ function buildToolResultMap(events) {
|
|
|
88
108
|
const msg = e.data.message;
|
|
89
109
|
const src = msg.source;
|
|
90
110
|
if (!src || typeof src.callId !== 'string') continue;
|
|
91
|
-
const
|
|
92
|
-
const isError = content && content.isError === true;
|
|
111
|
+
const { parts, isError } = toolResultParts(msg);
|
|
93
112
|
const code = e.data.error && typeof e.data.error.code === 'string' ? e.data.error.code : undefined;
|
|
94
|
-
const parts = content && Array.isArray(content.content) ? content.content : content ? [content] : [];
|
|
95
113
|
const output = parts.map((part) => {
|
|
96
114
|
if (part && typeof part.text === 'string') return part.text;
|
|
97
115
|
if (part && typeof part === 'object' && part.type === 'image') return '';
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* - 三级体积预算(单图 / 单段文本 / 单次响应,见下方常量):超预算的图保持字面 markdown,
|
|
19
19
|
* 不截断、不缩水成半张图。
|
|
20
20
|
* - 读不到、不是普通文件、读取失败:原样保留 —— 一条图片取不到不能让整段历史或整帧 SSE 挂掉。
|
|
21
|
+
* - 围栏代码块与行内代码里的图片语法一律不碰:那是**写法示例**,不是要画的图;换掉会把
|
|
22
|
+
* 三行代码变成几十万字符的 base64,App 的代码块会铺成很长很长的一块。
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
const fs = require('node:fs/promises');
|
|
@@ -126,17 +128,97 @@ async function dataUriFor(filePath) {
|
|
|
126
128
|
return uri;
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Markdown 里「必须保持字面」的区间:围栏代码块与行内代码。
|
|
133
|
+
*
|
|
134
|
+
* 代码块里的 `` 是**写法示例**而不是要画的图。旧实现整段正则替换,
|
|
135
|
+
* 会把三行代码换成几十万字符的 base64——App 把这段按代码文本铺开,就成了很长很长
|
|
136
|
+
* 的一块(真机现象:图片语法那块刷出几十屏,要划很久)。围栏判定与 App 的
|
|
137
|
+
* `utils/markdown/parser.ts` 同口径(两边都 trim 后看 3 个以上 ` / ~)。
|
|
138
|
+
*/
|
|
139
|
+
function literalRanges(text) {
|
|
140
|
+
const ranges = [];
|
|
141
|
+
const lines = text.split('\n');
|
|
142
|
+
const lineStarts = [];
|
|
143
|
+
let offset = 0;
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
lineStarts.push(offset);
|
|
146
|
+
offset += line.length + 1;
|
|
147
|
+
}
|
|
148
|
+
let fence = null;
|
|
149
|
+
let fenceStart = -1;
|
|
150
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
151
|
+
const line = lines[index];
|
|
152
|
+
if (fence) {
|
|
153
|
+
const close = /^(`{3,}|~{3,})[ \t]*$/.exec(line.trim());
|
|
154
|
+
if (close && close[1][0] === fence.char && close[1].length >= fence.length) {
|
|
155
|
+
ranges.push([fenceStart, lineStarts[index] + line.length]);
|
|
156
|
+
fence = null;
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const open = /^(`{3,}|~{3,})/.exec(line.trim());
|
|
161
|
+
if (!open) continue;
|
|
162
|
+
fence = { char: open[1][0], length: open[1].length };
|
|
163
|
+
fenceStart = lineStarts[index];
|
|
164
|
+
}
|
|
165
|
+
// 没闭合的围栏按 App 的解析口径一直吃到文末。
|
|
166
|
+
if (fence) ranges.push([fenceStart, text.length]);
|
|
167
|
+
|
|
168
|
+
// 行内代码:同长度的反引号成对(`code`)。围栏内部的反引号已在上面标过。
|
|
169
|
+
let cursor = 0;
|
|
170
|
+
while (cursor < text.length) {
|
|
171
|
+
if (text[cursor] !== '`' || inRanges(cursor, ranges)) {
|
|
172
|
+
cursor += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
let ticks = 1;
|
|
176
|
+
while (text[cursor + ticks] === '`') ticks += 1;
|
|
177
|
+
let scan = cursor + ticks;
|
|
178
|
+
let closed = -1;
|
|
179
|
+
while (scan < text.length) {
|
|
180
|
+
if (text[scan] !== '`' || inRanges(scan, ranges)) {
|
|
181
|
+
scan += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
let run = 1;
|
|
185
|
+
while (text[scan + run] === '`') run += 1;
|
|
186
|
+
if (run === ticks) {
|
|
187
|
+
closed = scan + run;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
scan += run;
|
|
191
|
+
}
|
|
192
|
+
if (closed < 0) {
|
|
193
|
+
cursor += ticks;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
ranges.push([cursor, closed]);
|
|
197
|
+
cursor = closed;
|
|
198
|
+
}
|
|
199
|
+
return ranges;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function inRanges(index, ranges) {
|
|
203
|
+
for (const [start, end] of ranges) {
|
|
204
|
+
if (index >= start && index < end) return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
129
209
|
/**
|
|
130
210
|
* 把一段文本里的本机图片语法换成 data URI。返回原字符串本身(`===`)表示一个字没动,
|
|
131
211
|
* 调用方据此免掉不必要的对象拷贝。
|
|
132
212
|
*/
|
|
133
213
|
async function inlineTextImages(text, budget) {
|
|
134
214
|
if (typeof text !== 'string' || text.indexOf('![') < 0) return text;
|
|
135
|
-
const
|
|
215
|
+
const ranges = literalRanges(text);
|
|
216
|
+
const matches = [...text.matchAll(IMAGE_RE)].filter((m) => !inRanges(m.index, ranges));
|
|
136
217
|
if (!matches.length) return text;
|
|
137
218
|
const pool = budget || createTextImageBudget();
|
|
138
219
|
let textBudget = MAX_TEXT_CHARS;
|
|
139
|
-
let out =
|
|
220
|
+
let out = '';
|
|
221
|
+
let cursor = 0;
|
|
140
222
|
for (const m of matches) {
|
|
141
223
|
if (textBudget <= 0 || pool.remaining <= 0) break;
|
|
142
224
|
const filePath = localImagePathOf(m[2]);
|
|
@@ -145,9 +227,10 @@ async function inlineTextImages(text, budget) {
|
|
|
145
227
|
if (!uri || uri.length > textBudget || uri.length > pool.remaining) continue;
|
|
146
228
|
textBudget -= uri.length;
|
|
147
229
|
pool.remaining -= uri.length;
|
|
148
|
-
out
|
|
230
|
+
out += text.slice(cursor, m.index) + `![${m[1]}](${uri})`;
|
|
231
|
+
cursor = m.index + m[0].length;
|
|
149
232
|
}
|
|
150
|
-
return
|
|
233
|
+
return cursor === 0 ? text : out + text.slice(cursor);
|
|
151
234
|
}
|
|
152
235
|
|
|
153
236
|
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf };
|
package/lib/mobile/stream.js
CHANGED
|
@@ -44,6 +44,11 @@ const SSE_FLUSH_PADDING_BYTES = 2048;
|
|
|
44
44
|
*/
|
|
45
45
|
const SSE_SHED_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
46
46
|
const SSE_CLOSE_BUFFERED_BYTES = 16 * 1024 * 1024;
|
|
47
|
+
/**
|
|
48
|
+
* 等一次 `drain` 的上限。超过这么久还没排空,说明对面不是慢、是真的不读了(手机切后台、
|
|
49
|
+
* 中转卡住),继续等只是把这条连接挂在那里,不如收尾让客户端重连。
|
|
50
|
+
*/
|
|
51
|
+
const SSE_DRAIN_TIMEOUT_MS = 20_000;
|
|
47
52
|
const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -88,18 +93,58 @@ function stableEventId(payload, frame) {
|
|
|
88
93
|
return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
/**
|
|
97
|
+
* 这条连接上真正「还没发出去」的字节数。
|
|
98
|
+
*
|
|
99
|
+
* `res.writableLength` 只算响应对象自己那一层缓冲,真正堆积的地方在它底下的套接字
|
|
100
|
+
* 写队列(堆快照里那些 `{chunk, encoding, callback}` 就是它)。第一版只看
|
|
101
|
+
* `res.writableLength`,它恒为 0,两道闸一次都没触发 —— 2026-10-18 复测:修完内存
|
|
102
|
+
* 仍从 410MB 涨到 1.4GB。所以要顺着 socket 一起看,取两者较大值。
|
|
103
|
+
*/
|
|
104
|
+
function bufferedBytesOf(res) {
|
|
105
|
+
const own = Number(res.writableLength) || 0;
|
|
106
|
+
const socket = res.socket || res.connection;
|
|
107
|
+
const queued = socket ? Number(socket.writableLength) || 0 : 0;
|
|
108
|
+
return Math.max(own, queued);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 等底层把缓冲吐出去(`drain`)。背压的正解:写不动就停手,而不是继续塞。
|
|
113
|
+
*
|
|
114
|
+
* @returns {Promise<boolean>} 真的 drain 了 → true;超时或流被中断 → false(该收尾)。
|
|
115
|
+
*/
|
|
116
|
+
function waitForDrain(res, signal, timeoutMs = SSE_DRAIN_TIMEOUT_MS) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
if (signal && signal.aborted) return resolve(false);
|
|
119
|
+
let done = false;
|
|
120
|
+
const cleanup = () => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
if (typeof res.off === 'function') res.off('drain', onDrain);
|
|
123
|
+
if (signal && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort);
|
|
124
|
+
};
|
|
125
|
+
const finish = (value) => { if (done) return; done = true; cleanup(); resolve(value); };
|
|
126
|
+
const onDrain = () => finish(true);
|
|
127
|
+
const onAbort = () => finish(false);
|
|
128
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
129
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
130
|
+
if (typeof res.once === 'function') res.once('drain', onDrain);
|
|
131
|
+
if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort, { once: true });
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
91
135
|
/**
|
|
92
136
|
* 写一条 SSE 记录。
|
|
93
137
|
*
|
|
94
|
-
* @returns {{ok: boolean, shed?: boolean}}
|
|
95
|
-
*
|
|
96
|
-
*
|
|
138
|
+
* @returns {{ok: boolean, shed?: boolean, needDrain?: boolean}}
|
|
139
|
+
* `ok:false` → 这条流该收尾(客户端走了,或积压超过硬水位);
|
|
140
|
+
* `shed:true` → 这条**增量**帧被丢掉、连接还活着(超过软水位且没有 seq);
|
|
141
|
+
* `needDrain:true` → 写成功了,但底下已经写不动,调用方要先 await 一次 drain 再继续。
|
|
97
142
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
143
|
+
* 丢增量只丢没有 seq 的帧:有 seq 的帧是客户端水位的一部分,丢了不回放就在手机上
|
|
144
|
+
* 留一个永久的洞,那比断流更糟。
|
|
100
145
|
*/
|
|
101
146
|
function writeSse(res, payload, frame) {
|
|
102
|
-
const buffered =
|
|
147
|
+
const buffered = bufferedBytesOf(res);
|
|
103
148
|
if (buffered > SSE_CLOSE_BUFFERED_BYTES) return { ok: false };
|
|
104
149
|
const id = stableEventId(payload, frame);
|
|
105
150
|
const hasSeq = Number.isFinite(payload && payload.seq);
|
|
@@ -108,7 +153,12 @@ function writeSse(res, payload, frame) {
|
|
|
108
153
|
if (id) fields.push(`id: ${id}`);
|
|
109
154
|
fields.push(`data: ${JSON.stringify(payload)}`);
|
|
110
155
|
fields.push(`: ${' '.repeat(SSE_FLUSH_PADDING_BYTES)}`, '', '');
|
|
111
|
-
try {
|
|
156
|
+
try {
|
|
157
|
+
// `write()` 返回 false 才是背压的正信号:数据已经堆到高水位,继续写只会把内存
|
|
158
|
+
// 堆起来。以前把它当成功吞掉,正是这次 4GB OOM 的直接原因。
|
|
159
|
+
const accepted = res.write(`${fields.join('\n')}\n`);
|
|
160
|
+
return { ok: true, needDrain: accepted === false };
|
|
161
|
+
} catch { return { ok: false }; }
|
|
112
162
|
}
|
|
113
163
|
|
|
114
164
|
/**
|
|
@@ -200,6 +250,19 @@ async function handleStream(ctx, req, res) {
|
|
|
200
250
|
// 这一条会话的 follow + 投影 + 任务三条流(见 mobile/host-bridge.js 的 muxFrames)。
|
|
201
251
|
// 不带就是设备级视角,只会给工作区基线与全局投影 —— 这里反正只要单会话。
|
|
202
252
|
const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: { sessionId } }, ac.signal);
|
|
253
|
+
// 写一帧并把三种结果统一收口:丢增量、需要等 drain、该收尾。
|
|
254
|
+
// 「需要等 drain」这一步是这次 OOM 的正解 —— 写不动就停手,等底层消化完再继续,
|
|
255
|
+
// 而不是把帧继续往内存里塞。
|
|
256
|
+
const push = async (payload, frameArg) => {
|
|
257
|
+
const sent = writeSse(res, payload, frameArg);
|
|
258
|
+
if (sent.shed) return 'shed';
|
|
259
|
+
if (!sent.ok) return 'closed';
|
|
260
|
+
if (sent.needDrain) {
|
|
261
|
+
const drained = await waitForDrain(res, ac.signal);
|
|
262
|
+
if (!drained) { logStream(sessionId, 'drain timeout', null); return 'closed'; }
|
|
263
|
+
}
|
|
264
|
+
return 'sent';
|
|
265
|
+
};
|
|
203
266
|
for await (const frame of frames) {
|
|
204
267
|
const p = frame.payload;
|
|
205
268
|
if (!p || p.sessionId !== sessionId) continue;
|
|
@@ -211,29 +274,29 @@ async function handleStream(ctx, req, res) {
|
|
|
211
274
|
const event = await resolveEventAttachments(ctx, sessionId, p.event);
|
|
212
275
|
// 被 shed 的只可能是没有 seq 的增量(见 writeSse 的软水位闸);它不推进水位,
|
|
213
276
|
// 同一轮收尾的 assistant/message 会把完整正文补上。
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
216
|
-
if (
|
|
277
|
+
const result = await push(event, { ...frame, payload: { ...p, event } });
|
|
278
|
+
if (result === 'shed') continue;
|
|
279
|
+
if (result === 'closed') { clientGone = true; break; }
|
|
217
280
|
if (seq !== null) lastDeliveredSeq = seq;
|
|
218
281
|
} else if (p.type === 'session/queue' || p.type === 'session/projection') {
|
|
219
282
|
// 队列/投影整帧转发;不假定存在可重放的宿主 API。
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
if (
|
|
283
|
+
const result = await push(p, frame);
|
|
284
|
+
if (result === 'shed') continue;
|
|
285
|
+
if (result === 'closed') { clientGone = true; break; }
|
|
223
286
|
} else if (p.type === 'session/jobs') {
|
|
224
287
|
// 后台任务整帧(桌面端会话头部任务面板的数据源)。宿主只在任务有变化时发,
|
|
225
288
|
// 且**空数组也要转发** —— 那是「任务都结束了」的唯一信号,丢掉它就永远清不掉。
|
|
226
289
|
const value = { type: 'session/jobs', sessionId, jobs: normalizeJobs(p.jobs) };
|
|
227
|
-
if (
|
|
290
|
+
if (await push(value, frame) === 'closed') { clientGone = true; break; }
|
|
228
291
|
} else if (p.type === 'question/requested') {
|
|
229
292
|
// 问题是可回答的 mux server-request,而非 session/event;保留 rpcId 供回答关联。
|
|
230
293
|
const value = { ...p, rpcId: frame.rpcId };
|
|
231
|
-
if (
|
|
294
|
+
if (await push(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
|
|
232
295
|
} else if (p.type === 'question/resolved' || p.type === 'approval/requested' || p.type === 'approval/asked' || p.type === 'approval/decided' || p.type === 'approval/resolved') {
|
|
233
296
|
// Approval frames are server requests/resolutions, not session/event frames;
|
|
234
297
|
// forward the rpcId and approval identity so the mobile client can answer safely.
|
|
235
298
|
const value = ANSWERABLE_APPROVAL_TYPES.has(p.type) && frame.rpcId ? { ...p, rpcId: frame.rpcId } : p;
|
|
236
|
-
if (
|
|
299
|
+
if (await push(value, { ...frame, payload: value }) === 'closed') { clientGone = true; break; }
|
|
237
300
|
}
|
|
238
301
|
}
|
|
239
302
|
if (!clientGone) loopEnded = true;
|
|
@@ -289,7 +352,10 @@ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
|
|
|
289
352
|
// Keep replay data identical to live SSE data: the client reducer consumes
|
|
290
353
|
// the event's own type (turn/start, assistant/message, etc.).
|
|
291
354
|
const payload = { type: 'session/event', sessionId, event };
|
|
292
|
-
|
|
355
|
+
const sent = writeSse(res, event, { payload });
|
|
356
|
+
if (!sent.ok) break;
|
|
357
|
+
// 回放是「一次性灌几千条」,最容易把写队列撑爆:写不动就在这里等,别一口气塞完。
|
|
358
|
+
if (sent.needDrain && !(await waitForDrain(res, signal))) break;
|
|
293
359
|
if (Number.isFinite(event.seq)) onDelivered(event.seq);
|
|
294
360
|
}
|
|
295
361
|
return { gap, oldestSeq };
|
package/lib/session-cleanup.cjs
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* (pruneOrphanedArchivedSessionsOnce)。理由写在那个函数上:桌面端的会话列表是
|
|
17
17
|
* 连上宿主时拉的一份快照 + 之后的事件增量,进程里擦集合等于把已经删掉、却还留在
|
|
18
18
|
* 快照里的行放回正常列表。
|
|
19
|
+
* - **Windows 上目录删不掉 ≠ 会话没删掉**:宿主对它打开过的会话一直握着 `session.lock`
|
|
20
|
+
* 的写句柄,日志 unlink 成功后那个名字要等句柄关闭才消失(delete-pending),rmdir
|
|
21
|
+
* 于是报 ENOTEMPTY。判据改成「目录里还有没有规范名日志」:没有就是删成了。
|
|
19
22
|
*/
|
|
20
23
|
'use strict'
|
|
21
24
|
|
|
@@ -90,6 +93,9 @@ async function readHeader(file) {
|
|
|
90
93
|
} finally {
|
|
91
94
|
stream.destroy()
|
|
92
95
|
source.destroy()
|
|
96
|
+
// Windows 上句柄没真的关掉之前,这个目录项删不掉(delete-pending → rmdir 报 ENOTEMPTY)。
|
|
97
|
+
// 删会话目录就紧跟在读 header 之后,所以先等 close 再往下走。
|
|
98
|
+
if (!source.closed) await new Promise(resolve => source.once('close', resolve))
|
|
93
99
|
}
|
|
94
100
|
return undefined
|
|
95
101
|
}
|
|
@@ -116,12 +122,16 @@ async function deleteSessionData(sessionIds) {
|
|
|
116
122
|
for (const file of sessionFiles(root)) {
|
|
117
123
|
const header = await readHeader(file)
|
|
118
124
|
if (!header || !ids.has(header.id)) continue
|
|
125
|
+
const directory = path.dirname(file)
|
|
119
126
|
try {
|
|
120
|
-
fs.rmSync(
|
|
121
|
-
removed.push(header.id)
|
|
127
|
+
fs.rmSync(directory, { recursive: true, force: true })
|
|
122
128
|
} catch (error) {
|
|
123
|
-
|
|
129
|
+
// Windows:宿主握着 session.lock,日志 unlink 成功了但名字还在(delete-pending),
|
|
130
|
+
// rmdir 报 ENOTEMPTY。日志都没了,这条会话对宿主就等于已经删掉,按删成记账、继续清
|
|
131
|
+
// 下一条;日志还在才算真失败(别把没删掉的说成删掉了)。
|
|
132
|
+
if (sessionFiles(directory).length > 0) throw new Error(`删除会话 ${header.id} 失败:${error.message}`)
|
|
124
133
|
}
|
|
134
|
+
removed.push(header.id)
|
|
125
135
|
}
|
|
126
136
|
const cacheRoot = path.join(dshHome(), 'storages', 'session_projcache', 'sessions')
|
|
127
137
|
for (const id of ids) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.86",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|