dsh-mobile-plugin 0.2.0 → 0.2.2
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/impl.js +9 -4
- package/lib/index.js +2 -1
- package/lib/mobile/credential-upgrade.js +11 -3
- package/lib/mobile/directory-listing.js +144 -0
- package/lib/mobile/history-derive.js +52 -13
- package/lib/mobile/host-bridge/event-synthesis.js +21 -9
- package/lib/mobile/monitor.js +140 -25
- package/lib/mobile/relay-control.js +13 -0
- package/lib/mobile/session-cache.js +41 -1
- package/lib/mobile/session-file.js +57 -32
- package/lib/mobile/session-last-turn.js +22 -2
- package/lib/mobile/session-send.js +10 -32
- package/lib/mobile/session-snapshot.js +5 -6
- package/lib/mobile/stream.js +281 -15
- package/lib/mobile/subagents.js +11 -2
- package/lib/mobile/uploads.js +12 -2
- package/lib/panel/index.html +1 -1
- package/lib/read-receipt.cjs +27 -7
- package/lib/relay/core.js +3 -0
- package/lib/relay/lan-network.cjs +47 -0
- package/lib/routes/control-plane.js +4 -0
- package/lib/routes/mobile-api.js +11 -3
- package/lib/shell/updater.js +8 -1
- package/lib/update-install.cjs +13 -5
- package/package.json +1 -1
|
@@ -75,6 +75,7 @@ function configView(state) {
|
|
|
75
75
|
lanPort: Number.isInteger(state.lanPort) ? state.lanPort : 18787,
|
|
76
76
|
pairingPolicy: pairingApproval.normalize(state.pairingPolicy),
|
|
77
77
|
preferredLanAddress: typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : '',
|
|
78
|
+
manualLanUrl: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
|
|
78
79
|
/** 注册密钥设没设(值本身不回)。 */
|
|
79
80
|
registerSecretSet: typeof state.registerSecret === 'string' && state.registerSecret !== '',
|
|
80
81
|
};
|
|
@@ -129,6 +130,16 @@ function configPatch(body) {
|
|
|
129
130
|
}
|
|
130
131
|
patch.preferredLanAddress = body.preferredLanAddress.trim();
|
|
131
132
|
}
|
|
133
|
+
// 用户手填的直连地址:**空串是合法值**(= 清掉这条,回到只认网卡候选)。
|
|
134
|
+
// 与 `relayUrl` 一样只挡类型与长度,格式由 `relay/lan-network.cjs` 的 `manualCandidate`
|
|
135
|
+
// 在产候选那一步判 —— 这里拒掉一条「看着不像地址」的字符串,只会让手机上那句错误提示
|
|
136
|
+
// 指向一个它自己也说不清的规则。
|
|
137
|
+
if (body.manualLanUrl !== undefined) {
|
|
138
|
+
if (typeof body.manualLanUrl !== 'string' || body.manualLanUrl.length > 200) {
|
|
139
|
+
return { ok: false, message: '直连地址必须是 200 字以内的字符串(空串表示清掉)' };
|
|
140
|
+
}
|
|
141
|
+
patch.manualLanUrl = body.manualLanUrl.trim().replace(/\/+$/, '');
|
|
142
|
+
}
|
|
132
143
|
if (Object.keys(patch).length === 0) return { ok: false, message: '没有任何可改的字段' };
|
|
133
144
|
return { ok: true, patch };
|
|
134
145
|
}
|
|
@@ -194,6 +205,8 @@ function createRelayControlHandlers(deps) {
|
|
|
194
205
|
lan: lanCandidates(port),
|
|
195
206
|
public: publicCandidates(port),
|
|
196
207
|
ipv6: ipv6Candidates(port),
|
|
208
|
+
/** 用户手填的那条直连地址(空串 = 没填)。与上面三份并列给出,界面不用拼。 */
|
|
209
|
+
manual: typeof state.manualLanUrl === 'string' ? state.manualLanUrl : '',
|
|
197
210
|
};
|
|
198
211
|
}
|
|
199
212
|
|
|
@@ -39,7 +39,8 @@ const lastTurnFacts = new WeakMap();
|
|
|
39
39
|
// Prevent duplicate prompt calls when a mobile request is retried after a timeout.
|
|
40
40
|
const sendIdempotency = new WeakMap();
|
|
41
41
|
// 写操作(新建/改名/归档/分叉)的 requestId 重放表:同一次写在换通道重投后不再执行第二遍。
|
|
42
|
-
// 与 sendIdempotency 分开是因为键与生命周期不同(那边按 sessionId+clientMessageId,
|
|
42
|
+
// 与 sendIdempotency 分开是因为键与生命周期不同(那边按 sessionId+clientMessageId,
|
|
43
|
+
// 成功之后留到进程结束;这里是每次写各自的 requestId、只活在重投窗口里)。
|
|
43
44
|
const writeIdempotency = new WeakMap();
|
|
44
45
|
|
|
45
46
|
// 工作区列表的条件刷新短路:上次构建留下的 revision / 生成时刻 / 当时的信号代号。
|
|
@@ -265,6 +266,44 @@ function sendIdempotencyStateOf(ctx) {
|
|
|
265
266
|
return state;
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
/**
|
|
270
|
+
* 同一个投递 id 在一次会话里只真正跑一次:成功之后记录留到进程结束,失败立刻释放。
|
|
271
|
+
*
|
|
272
|
+
* 要防的是「请求超时、但那一发其实已经交给宿主了」这一档:App 会拿着同一个
|
|
273
|
+
* clientMessageId 再发一次(内核重试或用户手点重试),第二次必须拿到第一次的结果,
|
|
274
|
+
* 而不是让模型 / 子代理把同一句话再说一遍。
|
|
275
|
+
*
|
|
276
|
+
* **失败不占位**:`run` 回的信封不是成功(宿主明确拒绝,如 model-unavailable /
|
|
277
|
+
* agent-busy)就当场删掉记录 —— 一次瞬时错误不能被固化成「这个 id 永远只回这一句错」;
|
|
278
|
+
* 同理 `run` 抛错(传输层失败)也删。两条都必须是「真的再试一次」,否则活的路径只剩
|
|
279
|
+
* 用户重开 App。
|
|
280
|
+
*
|
|
281
|
+
* @param {object} ctx 插件上下文(状态按 ctx 隔离)
|
|
282
|
+
* @param {string} key 投递身份:主会话 `sessionId:clientMessageId`,子代理额外加前缀
|
|
283
|
+
* @param {() => Promise<object>} run 真正发一次,回宿主信封(`{ result: { ok, value | error } }`)
|
|
284
|
+
*/
|
|
285
|
+
function promptOnceFor(ctx, key, run) {
|
|
286
|
+
const state = sendIdempotencyStateOf(ctx);
|
|
287
|
+
const existing = state.get(key);
|
|
288
|
+
if (existing) return existing.promise;
|
|
289
|
+
|
|
290
|
+
const promise = Promise.resolve().then(run);
|
|
291
|
+
const record = { promise };
|
|
292
|
+
state.set(key, record);
|
|
293
|
+
const release = () => { if (state.get(key) === record) state.delete(key); };
|
|
294
|
+
promise.then(
|
|
295
|
+
(response) => {
|
|
296
|
+
// 留下的这条记录就是「这一发已经交给宿主了」的回执;宿主明确拒绝(信封 ok 不为真)
|
|
297
|
+
// 就当场释放 —— 一次业务错误不能被固化成「这个 id 永远只回这一句」。这里只看收没
|
|
298
|
+
// 收下,不拆错误信封(拆信封在 envelope.js,本文件是零依赖的叶子)。
|
|
299
|
+
const accepted = Boolean(response && response.result && response.result.ok === true);
|
|
300
|
+
if (!accepted) release();
|
|
301
|
+
},
|
|
302
|
+
release,
|
|
303
|
+
);
|
|
304
|
+
return promise;
|
|
305
|
+
}
|
|
306
|
+
|
|
268
307
|
/** 写操作幂等表(实现在 mobile/idempotency.js,这里只保证按 ctx 隔离)。 */
|
|
269
308
|
function writeIdempotencyStateOf(ctx) {
|
|
270
309
|
let state = writeIdempotency.get(ctx);
|
|
@@ -356,5 +395,6 @@ module.exports = {
|
|
|
356
395
|
workspaceShortcutStateOf,
|
|
357
396
|
bumpCompletionGeneration,
|
|
358
397
|
sendIdempotencyStateOf,
|
|
398
|
+
promptOnceFor,
|
|
359
399
|
writeIdempotencyStateOf,
|
|
360
400
|
};
|
|
@@ -8,17 +8,24 @@
|
|
|
8
8
|
*
|
|
9
9
|
* 三条边界同时成立:
|
|
10
10
|
*
|
|
11
|
-
* 1.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* 1. **放行面 = 本会话工作区里的文件 ∪ 本会话 present 声明过的路径**(2026-10-25 用户拍板
|
|
12
|
+
* 的第二条,见 docs/decisions.md)。前者的判法是「解析后的落点在 cwd 之下」:相对路径
|
|
13
|
+
* 按 cwd 拼、绝对路径原样,然后整条路径必须在 cwd 里 —— 所以 `../x` 这种穿越落在
|
|
14
|
+
* cwd 之外,退回旧的那条白名单,不在声明里就 403。后者保持原样(`deliverables/presented`
|
|
15
|
+
* 里显式声明过的、**字符串全等**),它的用处是工作区之外的文件也能交付(绝对路径、
|
|
16
|
+
* cwd 变了的老会话)。
|
|
17
|
+
*
|
|
18
|
+
* **为什么敢放开工作区**:正文里模型写的 `[报告](docs/报告.md)` 就是这条路上的用户预期
|
|
19
|
+
* —— 手机上点一下能看,比「点了没反应」有意义得多;而这条会话的模型本来就能读同一个
|
|
20
|
+
* 工作区,手机与电脑是同一个使用者,放宽的是「同一份工作区能不能在手机上看」,不是
|
|
21
|
+
* 「能不能读这台电脑上的任意文件」(那仍然是 403,与不暴露 shell / 任意写入同一条边界)。
|
|
22
|
+
* 2. **相对路径按会话工作区解析**:cwd 取自会话列表摘要(与 /session 同一个来源)。没有
|
|
23
|
+
* cwd 的会话只能走 present 那条白名单,相对路径解析不出来就回 409。
|
|
17
24
|
* 3. **流式回写、不在插件进程里缓冲**:几十 MB 的录屏不该整包躺在内存里,与会话日志
|
|
18
25
|
* 导出同一个理由。
|
|
19
26
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
27
|
+
* 取不到时两种失败要分得开:两条口子都不认这个路径就回 403(`outside-workspace`),
|
|
28
|
+
* 认了、盘上没了回 404(`missing`)—— 前者是「这个路径不该被读」,后者是「文件被挪走了」。
|
|
22
29
|
*/
|
|
23
30
|
|
|
24
31
|
const fs = require('node:fs');
|
|
@@ -26,6 +33,7 @@ const { stat } = require('node:fs/promises');
|
|
|
26
33
|
const path = require('node:path');
|
|
27
34
|
const { pipeline } = require('node:stream/promises');
|
|
28
35
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
36
|
+
const { signedPath } = require('../shared/e2ee-request');
|
|
29
37
|
const { unwrap } = require('./envelope.js');
|
|
30
38
|
const { callSessions } = require('./rpc.js');
|
|
31
39
|
const { presentedPathsOf } = require('./history-derive.js');
|
|
@@ -97,6 +105,19 @@ async function declaredPathsOf(ctx, sessionId) {
|
|
|
97
105
|
return presentedPathsOf((value && value.events) || []);
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
/**
|
|
109
|
+
* 落点在不在工作区里。
|
|
110
|
+
*
|
|
111
|
+
* `path.relative` 出来是空的(就是 cwd 本身)或者以 `..` 开头(爬出去了)都算不在,
|
|
112
|
+
* 这样 `../x`、`a/../../x` 这些写法一并挡住 —— 判的是**解析后的真实落点**,不是输入字符串,
|
|
113
|
+
* 所以「看起来没写过 ..」最终指到外面的路径同样过不去。
|
|
114
|
+
*/
|
|
115
|
+
function isInsideWorkspace(cwd, target) {
|
|
116
|
+
if (!cwd) return false;
|
|
117
|
+
const rel = path.relative(path.resolve(cwd), target);
|
|
118
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
119
|
+
}
|
|
120
|
+
|
|
100
121
|
/** 会话的工作区目录(相对路径的解析基准)。取不到就回空串,由调用方决定怎么办。 */
|
|
101
122
|
async function cwdOf(ctx, sessionId) {
|
|
102
123
|
try {
|
|
@@ -124,34 +145,35 @@ async function handleSessionFile(ctx, req, res) {
|
|
|
124
145
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, path }' } });
|
|
125
146
|
}
|
|
126
147
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return sendJson(res, 502, {
|
|
132
|
-
ok: false,
|
|
133
|
-
error: { code: 'history-failed', message: String((error && error.message) || error) },
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
if (!declaredPaths.has(declared)) {
|
|
137
|
-
return sendJson(res, 403, {
|
|
138
|
-
ok: false,
|
|
139
|
-
error: { code: 'not-presented', message: '这个文件不在本会话的交付声明里' },
|
|
140
|
-
});
|
|
141
|
-
}
|
|
148
|
+
// 先把落点算出来:工作区那一侧判的是**解析后的真实路径**,不是输入字符串。
|
|
149
|
+
const cwd = await cwdOf(ctx, sessionId);
|
|
150
|
+
let target = path.isAbsolute(declared) ? path.resolve(declared) : '';
|
|
151
|
+
if (!target && cwd) target = path.resolve(cwd, declared);
|
|
142
152
|
|
|
143
|
-
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
// 工作区里的文件直接放行,一次历史都不读 —— 这是正文里点的链接走的那条路。
|
|
154
|
+
if (!target || !isInsideWorkspace(cwd, target)) {
|
|
155
|
+
// 工作区之外只剩交付声明这一条口子(绝对路径、或 cwd 变了的老会话)。
|
|
156
|
+
let declaredPaths;
|
|
157
|
+
try {
|
|
158
|
+
declaredPaths = await declaredPathsOf(ctx, sessionId);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return sendJson(res, 502, {
|
|
161
|
+
ok: false,
|
|
162
|
+
error: { code: 'history-failed', message: String((error && error.message) || error) },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (!declaredPaths.has(declared)) {
|
|
166
|
+
return sendJson(res, 403, {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: { code: 'outside-workspace', message: '这个路径不在本会话的工作区里,手机上打不开' },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (!target) {
|
|
149
172
|
return sendJson(res, 409, {
|
|
150
173
|
ok: false,
|
|
151
174
|
error: { code: 'no-cwd', message: '这条会话没有工作区目录,没法解析相对路径' },
|
|
152
175
|
});
|
|
153
176
|
}
|
|
154
|
-
target = path.join(cwd, declared);
|
|
155
177
|
}
|
|
156
178
|
|
|
157
179
|
let info;
|
|
@@ -221,14 +243,17 @@ async function handleSessionFile(ctx, req, res) {
|
|
|
221
243
|
*/
|
|
222
244
|
async function writeEncryptedFile(req, res, target, opened) {
|
|
223
245
|
const e2ee = require('../shared/e2ee');
|
|
224
|
-
const path0 = e2ee.signedPath(req);
|
|
225
246
|
const requestNonce = opened.req.nonce;
|
|
226
247
|
let seq = 0;
|
|
227
248
|
let pending = Buffer.alloc(0);
|
|
228
249
|
let closed = false;
|
|
229
250
|
res.on('close', () => { closed = true; });
|
|
230
|
-
|
|
251
|
+
// **整段都在 try 里**:调用点已经 `writeHead(200)` 过了,此后任何一步抛错都改不了状态码,
|
|
252
|
+
// 而 `sendJson` 再想写一个 500 只会撞上「头已发出」被静默吞掉 —— 响应就永远不结束,
|
|
253
|
+
// 手机上表现成「下载中」一直转、首字节等到超时。所以只能主动断开,让对端按截断处理。
|
|
231
254
|
try {
|
|
255
|
+
const path0 = signedPath(req);
|
|
256
|
+
const stream = fs.createReadStream(target, { highWaterMark: FILE_CHUNK_BYTES });
|
|
232
257
|
for await (const chunk of stream) {
|
|
233
258
|
if (closed) break;
|
|
234
259
|
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const { lastTurnFactsOf } = require('./session-cache.js');
|
|
17
|
+
const readReceipts = require('../read-receipt.cjs');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 本进程的起点:只有它**之前**留下的已读水位才需要从盘上补回来(见 `lastTurnOf`)。
|
|
21
|
+
* 本进程内的读一律由 `recordSessionRead` 当场写进内存那张表,不必再查盘。
|
|
22
|
+
*/
|
|
23
|
+
const PROCESS_STARTED_AT = Date.now();
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
26
|
* 一轮的结局:纯计算,不碰水位、不碰设备。
|
|
@@ -86,6 +93,9 @@ function recordSessionRead(ctx, sessionId, at = Date.now()) {
|
|
|
86
93
|
const facts = lastTurnFactsOf(ctx);
|
|
87
94
|
const previous = facts.get(sessionId) || {};
|
|
88
95
|
const current = typeof previous.readAt === 'number' ? previous.readAt : 0;
|
|
96
|
+
// 同时落一份到盘上(lib/read-receipt.cjs):内存这张表随进程消失,而 App 侧栏的绿点
|
|
97
|
+
// 靠的正是这里的水位 —— 桌面读过之后宿主一重启,同一轮又被算成未读(2026-10-26 真机)。
|
|
98
|
+
try { readReceipts.recordRead(sessionId, value); } catch { /* 台账是旁路 */ }
|
|
89
99
|
if (current >= value) return false;
|
|
90
100
|
facts.set(sessionId, { ...previous, readAt: value });
|
|
91
101
|
return true;
|
|
@@ -96,11 +106,21 @@ function recordSessionRead(ctx, sessionId, at = Date.now()) {
|
|
|
96
106
|
*
|
|
97
107
|
* 只回带 `turn` 的那些:表里也可能只存着一个已读水位(读过、但插件重启后还没折过
|
|
98
108
|
* 那一轮的结局),那不是「上一轮发生了什么」,不该下发给 App。
|
|
109
|
+
*
|
|
110
|
+
* 进程刚起来时那张表是空的,「桌面读过」只留在盘上那本台账里 —— 不补回来的话 App 会按
|
|
111
|
+
* 「没人读过」重算,早就读过的一轮又亮成绿点。只补上一次进程留下的那些(本进程里的读
|
|
112
|
+
* 已经在表里了),补到的值写回表里,让后面的折叠沿用。
|
|
99
113
|
*/
|
|
100
114
|
function lastTurnOf(ctx, sessionId) {
|
|
101
|
-
const
|
|
115
|
+
const facts = lastTurnFactsOf(ctx);
|
|
116
|
+
const fact = facts.get(sessionId);
|
|
102
117
|
if (!fact || typeof fact.turn !== 'number') return undefined;
|
|
103
|
-
|
|
118
|
+
const restored = readReceipts.readAtOf(sessionId);
|
|
119
|
+
const current = typeof fact.readAt === 'number' ? fact.readAt : 0;
|
|
120
|
+
if (!(restored > 0 && restored < PROCESS_STARTED_AT) || current >= restored) return fact;
|
|
121
|
+
const next = { ...fact, readAt: restored };
|
|
122
|
+
facts.set(sessionId, next);
|
|
123
|
+
return next;
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
module.exports = { settleOutcome, recordLastTurn, recordSessionRead, lastTurnOf };
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 发消息、slash 命令、会话的读/停/改名/分叉。
|
|
5
5
|
*
|
|
6
|
-
* promptOnce
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* promptOnce 的去重是这里唯一非平凡的逻辑:手机端发消息时网络重试与用户连点会产生
|
|
7
|
+
* 同一个 clientMessageId 的多次请求,同一会话内它们共享同一个 prompt,而不是让模型跑
|
|
8
|
+
* 两遍。成功之后记录一直留着(不按时间窗过期):请求超时的那一发很可能已经交给宿主了,
|
|
9
|
+
* 用户隔几分钟再点一次重试,过期的窗口正好把同一条消息又发一遍。
|
|
10
|
+
* **只缓存成功**:失败必须能被重试,不能把一次瞬时错误固化成一条永远发不出去的消息。
|
|
10
11
|
*
|
|
11
12
|
* 文本块恰好一个且以 / 开头时派发为 slash 命令(commands.execute,不发给模型);
|
|
12
13
|
* 未知命令或网关未组合时回退普通消息,与桌面 miss 落入默认 sink 的行为一致。
|
|
@@ -18,12 +19,11 @@ const { unwrap } = require('./envelope.js');
|
|
|
18
19
|
const { callSessions, mobilePromptRpcId } = require('./rpc.js');
|
|
19
20
|
const {
|
|
20
21
|
canonicalDeviceKey, invalidateSessionList, invalidateSessionModels,
|
|
21
|
-
markCompletionRead,
|
|
22
|
+
markCompletionRead, promptOnceFor,
|
|
22
23
|
} = require('./session-cache.js');
|
|
23
24
|
const { promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
|
|
24
25
|
const { withIdempotency, requestIdOf } = require('./idempotency.js');
|
|
25
26
|
const { invoke, UNAVAILABLE: GATEWAY_UNAVAILABLE } = require('./host-bridge/transport.js');
|
|
26
|
-
const readReceipt = require('../read-receipt.cjs');
|
|
27
27
|
const { recordSessionRead } = require('./session-last-turn.js');
|
|
28
28
|
|
|
29
29
|
/**
|
|
@@ -86,25 +86,9 @@ function promptOnce(ctx, clientMessageId, payload) {
|
|
|
86
86
|
// 因为实时帧里的图还被漏读,张数一对不上就彻底认领不了)。
|
|
87
87
|
const request = clientMessageId ? { ...payload, requestId: rpcId } : payload;
|
|
88
88
|
if (!clientMessageId) return callSessions(ctx, 'prompt', request, rpcId);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const existing = state.get(key);
|
|
93
|
-
if (existing && now - existing.createdAt < 60_000) return existing.promise;
|
|
94
|
-
if (existing) state.delete(key);
|
|
95
|
-
|
|
96
|
-
const promise = Promise.resolve(callSessions(ctx, 'prompt', request, rpcId));
|
|
97
|
-
// Keep the short-lived record so an in-flight/concurrent send with the same
|
|
98
|
-
// clientMessageId shares one prompt. Only cache a SUCCESS: a rejection must
|
|
99
|
-
// not poison later retries (a transient error should be retryable), so the
|
|
100
|
-
// record is released on failure.
|
|
101
|
-
const record = { createdAt: now, promise };
|
|
102
|
-
state.set(key, record);
|
|
103
|
-
promise.then(
|
|
104
|
-
() => { /* resolved stays cached until the 60s window expires */ },
|
|
105
|
-
() => { if (state.get(key) === record) state.delete(key); },
|
|
106
|
-
);
|
|
107
|
-
return promise;
|
|
89
|
+
// 同一个 clientMessageId 在本会话里只承诺一次:内核重试、以及隔了几分钟用户手点的
|
|
90
|
+
// 那次,都拿同一份结果(口径见 session-cache 的 `promptOnceFor`)。
|
|
91
|
+
return promptOnceFor(ctx, `${payload.sessionId}:${clientMessageId}`, () => callSessions(ctx, 'prompt', request, rpcId));
|
|
108
92
|
}
|
|
109
93
|
|
|
110
94
|
async function handleSend(ctx, req, res) {
|
|
@@ -230,17 +214,11 @@ async function handleSessionRead(ctx, req, res, fallbackDeviceKey = 'legacy') {
|
|
|
230
214
|
markCompletionRead(ctx, deviceKey, body.sessionId);
|
|
231
215
|
// 「这一轮被读过了」还要记一笔**设备无关**的水位:列表要喂镜像快照(全局一份),
|
|
232
216
|
// 上面那份按设备的提醒进不了下行清单,App 那边根本看不到这次已读(见 session-last-turn.js)。
|
|
217
|
+
// 这一笔同时会落进盘上的已读台账(桌面端绿点靠它),所以这里不再单独写一遍。
|
|
233
218
|
recordSessionRead(ctx, body.sessionId);
|
|
234
219
|
// Invalidate the cache and any build already reading history. Its generation
|
|
235
220
|
// check prevents a stale build from restoring `completed` after this ack.
|
|
236
221
|
invalidateSessionList(ctx, deviceKey);
|
|
237
|
-
// 桌面端的未读绿点不在宿主里(它在浏览器渲染进程的内存里),这里只记一条水位,
|
|
238
|
-
// 由插件的客户端半边拉走、在桌面页面里就地清掉。写盘失败不该让「已读」这个动作失败。
|
|
239
|
-
try {
|
|
240
|
-
readReceipt.recordRead(body.sessionId);
|
|
241
|
-
} catch {
|
|
242
|
-
// 回执台账只是尽力而为的旁路,读不到/写不动都不影响本次已读本身。
|
|
243
|
-
}
|
|
244
222
|
sendJson(res, 200, { ok: true, accepted: true });
|
|
245
223
|
}
|
|
246
224
|
|
|
@@ -171,12 +171,11 @@ async function handleMessages(ctx, req, res) {
|
|
|
171
171
|
const messages = deriveMessages(resolvedEvents);
|
|
172
172
|
const hasMore = !!(value && value.hasMore);
|
|
173
173
|
const items = wantThin ? thinEntryMessages(messages, { hasMore }) : messages;
|
|
174
|
-
// 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
const toolResults = wantThin ? [] : toolResultPatchesFor(events, items);
|
|
174
|
+
// 工具结果不产生消息行,但会把**缓存里已有的**工具卡从 running 补成最终态。补丁取自
|
|
175
|
+
// 整个窗口(调用方游标可能已越过结果 seq);已随消息块下发的只回状态(见 toolResultPatchesFor)。
|
|
176
|
+
// 增量那一发(带 afterSeq)一律只回状态:调用方手上已有一份会话,整窗重发等于每 15 秒
|
|
177
|
+
// 把最近两百条的工具输出再传一次;入口裁剪那一发不带补丁(过程块已摘掉、无处可打)。
|
|
178
|
+
const toolResults = wantThin ? [] : toolResultPatchesFor(events, items, { bodies: afterSeq === null });
|
|
180
179
|
const seqs = events.map(eventSeqOf).filter((seq) => Number.isSafeInteger(seq) && seq >= 0);
|
|
181
180
|
const oldestSeq = seqs.length ? Math.min(...seqs) : null;
|
|
182
181
|
// A truncated history window is not a complete incremental answer. Keep the
|