dsh-plugin-weapp-gateway 0.8.11 → 0.8.13

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/PROTOCOL.md CHANGED
@@ -1213,6 +1213,12 @@ Host 的 `plugin-manager/changed` 转为 `plugins-changed`(含 `reason`),`
1213
1213
  失败返回 `kind:error`、`requestType:session-create` 和原始 `requestId`。
1214
1214
  客户端不应自动重试超时的创建请求,以免重复创建会话。
1215
1215
 
1216
+ ### 实验性微信 ClawBot(`wechat-clawbot-control-v2`)
1217
+
1218
+ 此能力由网关在 DSH 进程内直接运行,不再转发到其他插件服务。小程序需使用已配对的 control 连接;所有操作响应都回显 `requestId`。微信凭据与收件状态保存在当前 DSH profile 的本地状态目录中,不经过小程序。
1219
+
1220
+ 请求类型为 `clawbot-control`,`action` 仅接受 `status`、`sessions`、`route`、`enabled`、`login.start`、`login.poll`、`login.cancel`、`disconnect`。有状态变更的路由、启停与解绑操作用 `expectedRevision` 做冲突检查;会话列表只暴露可选的普通 DSH 会话。状态目录有单实例锁;升级切换时如果仍被旧服务占用,网关会拒绝启动 ClawBot,避免同一微信账号被两个轮询器同时消费。
1221
+
1216
1222
 
1217
1223
  ### Independent conversation and control connections
1218
1224
 
package/README.md CHANGED
@@ -4,7 +4,18 @@
4
4
 
5
5
  # dsh-plugin-weapp-gateway
6
6
 
7
- ### v0.8.11 · 小程序 ClawBot 私聊接线控制
7
+ ### v0.8.13 · 网关内置微信 ClawBot
8
+
9
+ - 将 ClawBot 控制、iLink 收发和目标会话接入直接放进网关;不再调用 `dsh-wechat-remote` 的控制 RPC,也不要求安装该插件。
10
+ - 小程序「设置 → 实验性功能」管理微信绑定、接入会话和启停。微信凭据及消息收件状态留在电脑本地,移动端只通过配对网关发出控制请求。
11
+ - 复用本机已有 ClawBot 状态目录并加单实例锁;若旧服务仍占用状态,先停止旧的私聊接线服务并重启 DSH,再由网关接管,避免同一账号被两个轮询器同时处理。
12
+ - 新增 `wechat-clawbot-control-v2` 能力标记;升级网关后重新连接小程序。
13
+
14
+ ### v0.8.12 · 显示插件升级失败详情
15
+
16
+ - 安装器返回结构化错误时提取可读信息,不再显示 `[object Object]`;错误序列化会省略凭据、令牌和堆栈字段。
17
+
18
+ ### v0.8.11 · 小程序 ClawBot 私聊接线控制(旧版代理,已由 v0.8.13 取代)
8
19
 
9
20
  - 在已配对的小程序控制通道中增加受限的 ClawBot 控制 RPC,复用 `dsh-wechat-remote` 自身的访问授权、状态冲突检查和本地凭据存储。
10
21
  - 支持读取状态、绑定流程、选择普通 DSH 会话、启停与移除本地绑定;微信凭据不传回小程序。
@@ -110,7 +121,7 @@ endpoints:
110
121
  notificationServiceUrl: https://dsh.ahwe.top
111
122
  ```
112
123
 
113
- 小程序的「订阅通知」偏好开启后,网关会在对应的 `turn/end` 回调已授权的一次性通知票据。自托管公网服务时,将 `notificationServiceUrl` 改为小程序 `access-config.js` 中相同的 HTTPS 服务根地址;不要把任务正文放进通知回调。
124
+ 小程序的「订阅通知」偏好开启后,网关会在对应的 `turn/end` 回调已授权的一次性通知票据,并附上可用的会话标题、回合耗时、Token 用量和缓存命中率。Token 用量来自本回合 Assistant usage 的输入与输出总量;缓存命中率使用 `cacheReadTokens / (uncachedInputTokens + cacheReadTokens)`,缺少未缓存输入时回退到 `inputTokens`。通知不包含任务正文或模型回复。若希望 Token 显示为 `12.3K`,微信模板对应字段须为 `character_string`;`number` 类型只能显示纯数字。自托管公网服务时,将 `notificationServiceUrl` 改为小程序 `access-config.js` 中相同的 HTTPS 服务根地址。
114
125
 
115
126
  运行模式的优先级:**已保存的界面选择 > `gatewayMode` > 旧 `gatewayEnabled`**。没有保存选择时,旧配置 `gatewayEnabled: true` 对应常驻,false 对应关闭。
116
127
 
@@ -0,0 +1,31 @@
1
+ This notice applies to the gateway-owned ClawBot integration under lib/clawbot/.
2
+
3
+ 1. ClawBot runtime, DSH host adapter, contracts, storage, reply handling and
4
+ service structure are adapted from dsh-wechat-remote 1.7.14.
5
+ Copyright (c) 2026 martinbear1. Licensed under the MIT License.
6
+ Source project: https://github.com/martinbear1/dsh-wechat-remote
7
+
8
+ 2. iLink request and media protocol portions are adapted from Tencent's
9
+ openclaw-weixin at commit 24de5c9.
10
+ Copyright (C) 2026 Tencent. Licensed under the MIT License.
11
+ Source project: https://github.com/Tencent/openclaw-weixin
12
+
13
+ MIT License
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
package/bin/setup-ip.mjs CHANGED
File without changes
@@ -0,0 +1,8 @@
1
+ export class ClawbotError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = 'ClawbotError';
7
+ }
8
+ }
@@ -0,0 +1,560 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { ClawbotError } from './contracts.mjs';
3
+ import { assertColdSessionPresetAvailable } from './host-admission.mjs';
4
+ import { withNativePeer } from './native-peer.mjs';
5
+ import { REPLY_TEXT_MAX_BYTES } from './reply-text.mjs';
6
+ /** A receipt for observing a run, never authority to replay its prompt. */
7
+ export class DshClawbotHostError extends ClawbotError {
8
+ submission;
9
+ constructor(code, message, submission) {
10
+ super('clawbot-host/' + code, message);
11
+ this.submission = submission;
12
+ this.name = 'DshClawbotHostError';
13
+ }
14
+ }
15
+ const row = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
16
+ const id = (value) => typeof value === 'string' && value.length > 0
17
+ && value.length <= 256 && !/[\u0000-\u001f]/.test(value);
18
+ const fail = (code, message, submission = 'not-submitted') => new DshClawbotHostError(code, message, submission);
19
+ const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
20
+ const MAX_INPUT_BYTES = 30 * 1024 * 1024;
21
+ const MAX_OBSERVATION_MS = 15 * 60 * 1000;
22
+ const imageType = (data) => {
23
+ const bytes = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
24
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
25
+ return 'image/png';
26
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
27
+ return 'image/jpeg';
28
+ if (/^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('ascii')))
29
+ return 'image/gif';
30
+ if (bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF'
31
+ && bytes.subarray(8, 12).toString('ascii') === 'WEBP')
32
+ return 'image/webp';
33
+ };
34
+ const nativeImage = (value, name) => row(value) && value.name === name
35
+ && /^sha256:[a-f0-9]{64}$/.test(value.attachmentId) && ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(value.mediaType)
36
+ && [value.bytes, value.width, value.height].every(number => Number.isSafeInteger(number) && number > 0);
37
+ const nativeFile = (value) => row(value) && /^sha256:[a-f0-9]{64}$/.test(value.attachmentId)
38
+ && typeof value.name === 'string' && !!value.name && !/[\\/\u0000-\u001f]/.test(value.name)
39
+ && Number.isSafeInteger(value.bytes) && value.bytes > 0 && value.bytes <= MAX_MEDIA_BYTES;
40
+ /**
41
+ * Native contracts inspected in installed DSH 0.2.0-rc.1:
42
+ * dsh-api-session-controller/lib/types/commands.js:290-351,547-560:
43
+ * prompt resumes the ordinary Agent, stores requestId as user source.rpcId,
44
+ * and acknowledges admission (not completion). No source credential is added.
45
+ * dsh-agent-loop/lib/index.js:943,1061,1144,1027: committed turn/start,
46
+ * user/message, assistant/message, turn/end are the correlation boundaries.
47
+ * dsh-session/lib/index.js:1400-1473: seq is the next append offset and
48
+ * session/event observers run synchronously after the event enters the log.
49
+ * This adapter owns no Agent/preset/tool; it never opens the phone mux/$events
50
+ * or registers a user-questions/approval answerer. Native cards keep ownership.
51
+ */
52
+ export class DshClawbotHost {
53
+ ctx;
54
+ assertAccess;
55
+ running = new Set();
56
+ constructor(ctx, assertAccess) {
57
+ this.ctx = ctx;
58
+ this.assertAccess = assertAccess;
59
+ }
60
+ gateway() {
61
+ const gateway = this.ctx.get('typertGateway');
62
+ if (typeof gateway?.invoke !== 'function')
63
+ throw fail('unavailable', '当前节点的原生会话接口尚未就绪。');
64
+ return { invoke: (request) => withNativePeer(this.ctx, request.signal, lease => gateway.invoke(lease.peer ? { ...request, peer: lease.peer, signal: lease.signal } : request)) };
65
+ }
66
+ archived() {
67
+ const values = this.ctx.get('workspaceRegistry')?.archivedSessionIds;
68
+ if (!Array.isArray(values) || !values.every(id))
69
+ throw fail('unavailable', '无法核对原生会话归档状态。');
70
+ return new Set(values);
71
+ }
72
+ /** Selectable ordinary sessions, including cold ones; admission decides writability. */
73
+ async listSessions(signal) {
74
+ signal.throwIfAborted();
75
+ const result = await this.gateway().invoke({ namespace: 'session', method: 'list', args: { _request: {} }, signal });
76
+ signal.throwIfAborted();
77
+ const archived = this.archived();
78
+ if (!Array.isArray(result?.items))
79
+ throw fail('unavailable', '无法读取原生会话列表。');
80
+ const seen = new Set(), items = [];
81
+ const registry = this.ctx.get('workspaceRegistry');
82
+ const workspaces = typeof registry?.list === 'function' ? registry.list() : [];
83
+ for (const item of result.items) {
84
+ if (!row(item) || !id(item.sessionId) || seen.has(item.sessionId))
85
+ throw fail('unavailable', '原生会话列表不完整,请刷新后重试。');
86
+ seen.add(item.sessionId);
87
+ if (item.origin === 'subagent' || archived.has(item.sessionId) || typeof item.cwd !== 'string' || !item.cwd)
88
+ continue;
89
+ const title = item.projections?.values?.title;
90
+ const matches = Array.isArray(workspaces) ? workspaces.filter(workspace => row(workspace) && id(workspace.id)
91
+ && Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(item.sessionId)) : [];
92
+ const workspace = matches.length === 1 ? matches[0] : undefined;
93
+ const workspaceTitle = workspace && (typeof workspace.name === 'string' && workspace.name.trim() ? workspace.name
94
+ : typeof workspace.path === 'string' ? workspace.path.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1) : undefined);
95
+ items.push({ id: item.sessionId, title: typeof title === 'string' && title.trim() ? title.slice(0, 200) : '未命名会话',
96
+ ...(workspace && workspaceTitle ? { workspaceId: workspace.id, workspaceTitle: workspaceTitle.slice(0, 200) } : {}) });
97
+ }
98
+ return items;
99
+ }
100
+ async metadata(sessionId, signal) {
101
+ const query = this.ctx.get('sessionQuery');
102
+ if (typeof query?.observeSession !== 'function')
103
+ throw fail('unavailable', '当前宿主缺少可核对的原生会话观察接口。');
104
+ // Metadata only: selecting a cold Session must not read or expose its transcript.
105
+ const observation = await query.observeSession(sessionId, { signal, projectionMode: 'none' });
106
+ try {
107
+ const header = observation?.header;
108
+ if (!row(header) || header.id !== sessionId || header.origin === 'subagent'
109
+ || !Number.isSafeInteger(header.createdAt) || typeof header.cwd !== 'string' || !header.cwd
110
+ || !Number.isSafeInteger(observation.cursor) || observation.cursor < -1
111
+ || typeof observation[Symbol.dispose] !== 'function')
112
+ throw fail('unavailable', '无法核对本次会话身份。');
113
+ return { generation: header.createdAt, cursor: observation.cursor };
114
+ }
115
+ finally {
116
+ observation?.[Symbol.dispose]?.();
117
+ }
118
+ }
119
+ async describeSession(sessionId, signal) {
120
+ if (!id(sessionId))
121
+ throw fail('invalid-input', '会话标识无效。');
122
+ const selected = (await this.listSessions(signal)).find(item => item.id === sessionId);
123
+ if (!selected)
124
+ throw fail('not-found', '此会话已不存在、已归档或属于子代理,请重新选择普通会话。');
125
+ await this.checkPreset(sessionId, signal);
126
+ const { generation } = await this.metadata(sessionId, signal);
127
+ signal.throwIfAborted();
128
+ if (this.archived().has(sessionId))
129
+ throw fail('not-found', '此会话已归档,请重新选择。');
130
+ return { ...selected, generation };
131
+ }
132
+ async checkPreset(sessionId, signal) {
133
+ try {
134
+ await assertColdSessionPresetAvailable(this.ctx, sessionId, signal);
135
+ }
136
+ catch (error) {
137
+ signal.throwIfAborted();
138
+ if (row(error) && error.code === 'adapter/session-preset-unavailable')
139
+ throw fail('preset-unavailable', '此会话的原模式已停用或不可用,本条消息未发送。请在电脑恢复原模式,或选择其他可用会话。');
140
+ throw fail('unavailable', '暂时无法核对会话模式,本条消息未发送,请在电脑或小程序核对。');
141
+ }
142
+ }
143
+ async run(input, signal, onAttention) {
144
+ if (!id(input.sessionId) || !id(input.requestId) || typeof input.text !== 'string'
145
+ || !Number.isSafeInteger(input.expectedGeneration)
146
+ || Buffer.byteLength(input.text) > REPLY_TEXT_MAX_BYTES || input.attachments !== undefined && !Array.isArray(input.attachments)
147
+ || (input.attachments?.length ?? 0) > 6 || !input.text.trim() && !input.attachments?.length)
148
+ throw fail('invalid-input', '消息或会话标识无效。');
149
+ let total = Buffer.byteLength(input.text);
150
+ for (const part of input.attachments ?? []) {
151
+ if (!row(part) || !['image', 'file', 'voice', 'video'].includes(part.kind)
152
+ || typeof part.name !== 'string' || !part.name.trim() || Buffer.byteLength(part.name) > 240 || /[\\/\u0000-\u001f]/.test(part.name)
153
+ || !(part.data instanceof Uint8Array) || part.data.byteLength < 1 || part.data.byteLength > MAX_MEDIA_BYTES
154
+ || (total += part.data.byteLength) > MAX_INPUT_BYTES || part.kind === 'image' && !imageType(part.data))
155
+ throw fail('invalid-input', '附件格式或大小不符合要求:最多 6 个,单个 10 MB、合计 30 MB。');
156
+ }
157
+ // Own one bounded immutable snapshot across asynchronous admission checks.
158
+ input = { ...input, attachments: input.attachments?.map(part => ({ ...part, data: Uint8Array.from(part.data) })) };
159
+ signal.throwIfAborted();
160
+ if (this.running.has(input.sessionId))
161
+ throw fail('busy', '此会话仍在处理上一条渠道消息,请先在电脑或小程序核对。');
162
+ this.running.add(input.sessionId);
163
+ try {
164
+ if (!(await this.listSessions(signal)).some(item => item.id === input.sessionId)) {
165
+ throw fail('not-found', '此会话已不存在、已归档或属于子代理,请重新选择普通会话。');
166
+ }
167
+ const agents = this.ctx.get('agents'), sessions = this.ctx.get('sessions');
168
+ if (typeof agents?.get !== 'function'
169
+ || typeof sessions?.get !== 'function')
170
+ throw fail('unavailable', '当前宿主缺少可核对的原生会话观察接口。');
171
+ const { generation, cursor } = await this.metadata(input.sessionId, signal);
172
+ if (generation !== input.expectedGeneration)
173
+ throw fail('session-changed', '所选会话已重置,本条旧消息没有发送;请重新选择会话。');
174
+ signal.throwIfAborted();
175
+ if (this.archived().has(input.sessionId))
176
+ throw fail('not-found', '此会话已归档,请重新选择。');
177
+ await this.checkPreset(input.sessionId, signal);
178
+ signal.throwIfAborted();
179
+ const prepared = input.attachments?.length ? await this.prepareInput(input, generation, signal)
180
+ : { content: [{ type: 'text', text: input.text }] };
181
+ const result = await this.observeAndSubmit(input, generation, cursor, prepared, signal, onAttention);
182
+ return await this.readOutputs(input.sessionId, generation, result, signal, input.checkAdmission);
183
+ }
184
+ finally {
185
+ this.running.delete(input.sessionId);
186
+ }
187
+ }
188
+ /** Read only this completed turn's native presentation declarations. Paths
189
+ * never come from model prose or tool reads and never leave this adapter. */
190
+ async readOutputs(sessionId, generation, result, signal, checkAdmission) {
191
+ signal = AbortSignal.any([signal, AbortSignal.timeout(60_000)]);
192
+ const attachments = [];
193
+ let omitted = result.omitted, total = 0;
194
+ const check = () => {
195
+ signal.throwIfAborted();
196
+ checkAdmission?.();
197
+ this.assertAccess?.();
198
+ const session = this.ctx.get('sessions')?.get(sessionId);
199
+ if (session !== result.session || session.header?.createdAt !== generation || this.archived().has(sessionId))
200
+ throw fail('session-changed', '会话已变化,文件未回传;请在原会话查看。', 'accepted');
201
+ };
202
+ const append = (name, bytes) => {
203
+ if (typeof name !== 'string' || !name.trim() || /[\\/\u0000-\u001f]/.test(name)
204
+ || !(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > MAX_MEDIA_BYTES
205
+ || attachments.length >= 6 || total + bytes.byteLength > MAX_INPUT_BYTES)
206
+ throw Error('output limit');
207
+ const data = Buffer.from(bytes), image = imageType(data);
208
+ const mp4 = /\.mp4$/i.test(name) && data.length >= 16 && data.readUInt32BE(0) >= 16
209
+ && data.readUInt32BE(0) <= data.length && data.subarray(4, 8).toString('ascii') === 'ftyp'
210
+ && /^(?:isom|iso[2-9]|mp4[12]|avc1|M4V )$/.test(data.subarray(8, 12).toString('ascii'));
211
+ total += data.length;
212
+ attachments.push({ kind: image ? 'image' : mp4 ? 'video' : 'file', name, data });
213
+ };
214
+ for (const reference of result.files) {
215
+ let transferId, resources;
216
+ try {
217
+ check();
218
+ resources = this.ctx.get('agentResources');
219
+ if (!['resolve', 'prepare', 'chunk', 'release'].every(key => typeof resources?.[key] === 'function'))
220
+ throw Error('no native resource reader');
221
+ const unpack = (value) => {
222
+ if (!value?.ok || typeof value.valueJson !== 'string')
223
+ throw Error('resource unavailable');
224
+ const parsed = JSON.parse(value.valueJson);
225
+ if (!row(parsed))
226
+ throw Error('resource unavailable');
227
+ return parsed;
228
+ };
229
+ const resolved = unpack(await resources.resolve({ scope: sessionId, reference }, signal));
230
+ check();
231
+ if (resolved.kind !== 'file' || typeof resolved.id !== 'string' || !resolved.id || resolved.id.length > 12000
232
+ || !Number.isSafeInteger(resolved.bytes) || resolved.bytes < 1 || resolved.bytes > MAX_MEDIA_BYTES
233
+ || total + resolved.bytes > MAX_INPUT_BYTES || attachments.length >= 6)
234
+ throw Error('output limit');
235
+ const prepared = unpack(await resources.prepare({ scope: sessionId, id: resolved.id, delivery: 'chunks' }, signal));
236
+ transferId = prepared.transferId;
237
+ check();
238
+ if (!id(transferId) || prepared.delivery !== 'chunks' || !Number.isSafeInteger(prepared.bytes)
239
+ || prepared.bytes < 1 || prepared.bytes > MAX_MEDIA_BYTES || total + prepared.bytes > MAX_INPUT_BYTES
240
+ || !Number.isSafeInteger(prepared.chunkBytes) || prepared.chunkBytes < 1 || prepared.chunkBytes > 1024 * 1024
241
+ || !/^[a-f0-9]{64}$/.test(prepared.sha256))
242
+ throw Error('invalid native snapshot');
243
+ const bytes = Buffer.alloc(prepared.bytes);
244
+ for (let offset = 0; offset < bytes.length; offset += prepared.chunkBytes) {
245
+ const chunk = unpack(await resources.chunk({ scope: sessionId, transferId, offset }, signal));
246
+ check();
247
+ if (chunk.offset !== offset || typeof chunk.data !== 'string')
248
+ throw Error('invalid native chunk');
249
+ const data = Buffer.from(chunk.data, 'base64');
250
+ if (data.toString('base64') !== chunk.data || data.length !== Math.min(prepared.chunkBytes, bytes.length - offset))
251
+ throw Error('invalid native chunk');
252
+ data.copy(bytes, offset);
253
+ }
254
+ if (createHash('sha256').update(bytes).digest('hex') !== prepared.sha256)
255
+ throw Error('invalid native snapshot');
256
+ append(prepared.name, bytes);
257
+ }
258
+ catch {
259
+ omitted = true;
260
+ }
261
+ finally {
262
+ if (id(transferId)) {
263
+ try {
264
+ await resources?.release({ scope: sessionId, transferId });
265
+ }
266
+ catch { /* bounded native snapshot expires */ }
267
+ }
268
+ }
269
+ }
270
+ for (const ref of result.images) {
271
+ try {
272
+ check();
273
+ if (attachments.length >= 6 || ref.bytes > MAX_MEDIA_BYTES || total + ref.bytes > MAX_INPUT_BYTES)
274
+ throw Error('output limit');
275
+ const value = await this.ctx.get('attachments')?.readImage(ref, signal);
276
+ check();
277
+ if (!(value?.data instanceof Uint8Array) || value.data.byteLength !== ref.bytes
278
+ || 'sha256:' + createHash('sha256').update(value.data).digest('hex') !== ref.attachmentId)
279
+ throw Error('image unavailable');
280
+ append(ref.name, value.data);
281
+ }
282
+ catch {
283
+ omitted = true;
284
+ }
285
+ }
286
+ // Missing, moved or oversized files do not turn a completed DSH task into
287
+ // an error or cause replay. The final text remains usable on every host.
288
+ const notices = [...result.notices, ...(omitted ? ['部分文件未能回传微信,请在电脑或小程序查看。'] : [])];
289
+ return { text: result.text, ...(attachments.length ? { attachments } : {}), ...(notices.length ? { notices } : {}) };
290
+ }
291
+ async prepareInput(input, generation, signal) {
292
+ const content = input.text.trim() ? [{ type: 'text', text: input.text }] : [];
293
+ let session, agent;
294
+ const check = () => {
295
+ signal.throwIfAborted();
296
+ input.checkAdmission?.();
297
+ this.assertAccess?.();
298
+ if (this.archived().has(input.sessionId))
299
+ throw fail('not-found', '此会话已归档,本条消息未发送。');
300
+ const live = this.ctx.get('sessions')?.get(input.sessionId), current = this.ctx.get('agents')?.get(input.sessionId);
301
+ if (live && (live.header?.createdAt !== generation || live.header?.origin === 'subagent')
302
+ || session && session !== live || agent && agent !== current || current?.session && current.session !== live)
303
+ throw fail('session-changed', '准备附件时会话已变化,本条消息未发送。');
304
+ session ??= live;
305
+ agent ??= current;
306
+ };
307
+ check();
308
+ for (const part of input.attachments ?? []) {
309
+ check();
310
+ if (part.kind === 'image') {
311
+ content.push({ type: 'image', mediaType: imageType(part.data), data: Buffer.from(part.data).toString('base64'), name: part.name });
312
+ }
313
+ else {
314
+ const uploads = this.ctx.get('fileUploads');
315
+ if (typeof uploads?.uploadStream !== 'function')
316
+ throw fail('unavailable', '此节点尚不支持原生文件附件,请更新电脑端后重试。');
317
+ let saved;
318
+ try {
319
+ saved = await uploads.uploadStream({ sessionId: input.sessionId, name: part.name, signal,
320
+ data: (async function* () { check(); yield part.data; })() });
321
+ }
322
+ catch {
323
+ signal.throwIfAborted();
324
+ throw fail('attachment-unavailable', '附件未能保存到原会话,本条消息未发送。');
325
+ }
326
+ check();
327
+ if (!id(saved?.receiptId) || !nativeFile(saved?.file) || saved.file.bytes !== part.data.byteLength
328
+ || saved.file.attachmentId !== 'sha256:' + createHash('sha256').update(part.data).digest('hex'))
329
+ throw fail('attachment-unavailable', '无法确认原生附件保存结果,本条消息未发送。');
330
+ content.push({ type: 'file', receiptId: saved.receiptId });
331
+ }
332
+ }
333
+ const current = await this.metadata(input.sessionId, signal);
334
+ if (current.generation !== generation)
335
+ throw fail('session-changed', '准备附件时会话已重置,本条消息未发送。');
336
+ check();
337
+ // Native uploads own their durable objects and staged receipts. Never delete
338
+ // shared attachment files or edit the host's receipt table on a failed send.
339
+ return { content, session };
340
+ }
341
+ observeAndSubmit(input, generation, cursor, prepared, signal, onAttention) {
342
+ return new Promise((resolve, reject) => {
343
+ let settled = false, dispatched = false, accepted = false, currentTurn;
344
+ let matchedTurn, finalText, completion;
345
+ let observedSession = prepared.session, eventCount = 0;
346
+ const files = new Set();
347
+ let images = [], omitted = false, finalInterrupted = false;
348
+ const notices = [];
349
+ const attention = new Set();
350
+ const offs = [];
351
+ const submission = () => accepted ? 'accepted' : dispatched ? 'unknown' : 'not-submitted';
352
+ const finish = (error, text) => {
353
+ if (settled)
354
+ return;
355
+ settled = true;
356
+ clearTimeout(timer);
357
+ signal.removeEventListener('abort', abort);
358
+ for (const off of offs.splice(0)) {
359
+ try {
360
+ off();
361
+ }
362
+ catch { /* no observer may remain */ }
363
+ }
364
+ if (error)
365
+ reject(error);
366
+ else
367
+ resolve({ text: text, session: observedSession, files: [...files], images, omitted, notices });
368
+ };
369
+ const stop = (code, message) => finish(fail(code, message, submission()));
370
+ const notify = (kind) => {
371
+ if (!onAttention)
372
+ return stop(kind + '-required', kind === 'approval'
373
+ ? '此任务需要你的原生审批,请回电脑或小程序处理;任务未被取消。'
374
+ : '此任务需要回答问题,请回电脑或小程序处理;任务未被取消。');
375
+ if (attention.has(kind))
376
+ return;
377
+ attention.add(kind);
378
+ // The durable matching input proves ownership. The notice does not
379
+ // decide an approval, answer a question or end this run's observation.
380
+ try {
381
+ onAttention(kind);
382
+ }
383
+ catch {
384
+ stop('attention-unavailable', '未能保存待处理提醒。任务未被取消,请在电脑或小程序处理并查看结果。');
385
+ }
386
+ };
387
+ const abort = () => stop('observation-stopped', dispatched
388
+ ? '已停止等待回复;电脑端任务可能仍在继续,请在电脑或小程序核对,勿重复发送。'
389
+ : '已停止发送。');
390
+ const timer = setTimeout(() => stop('observation-timeout', '等待本次回复超时;任务可能仍在继续,请在电脑或小程序核对,勿重复发送。'), MAX_OBSERVATION_MS);
391
+ timer.unref();
392
+ const checkSubject = (session) => {
393
+ if (session?.id !== input.sessionId)
394
+ return false;
395
+ if (session.header?.createdAt !== generation || session.header?.origin === 'subagent'
396
+ || this.ctx.get('sessions')?.get(input.sessionId) !== session
397
+ || observedSession && observedSession !== session) {
398
+ stop('session-changed', '会话实例已变化,未转发无法确认归属的回复;请在电脑或小程序核对。');
399
+ return false;
400
+ }
401
+ observedSession = session;
402
+ return true;
403
+ };
404
+ try {
405
+ offs.push(this.ctx.on('session/event', (session, event) => {
406
+ if (settled || completion !== undefined || !dispatched || !checkSubject(session) || !row(event))
407
+ return;
408
+ if (!Number.isSafeInteger(event.seq) || event.seq <= cursor)
409
+ return;
410
+ if (++eventCount > 100_000)
411
+ return stop('output-unavailable', '本次任务事件过多,请在电脑或小程序查看结果。');
412
+ const data = event.data;
413
+ if (!row(data))
414
+ return;
415
+ if (event.type === 'turn/start') {
416
+ if (matchedTurn !== undefined)
417
+ return stop('ambiguous-turn', '本次轮次边界发生变化,未转发无法确认归属的回复。');
418
+ currentTurn = Number.isSafeInteger(data.turn) ? data.turn : undefined;
419
+ finalText = undefined;
420
+ }
421
+ else if (event.type === 'user/message') {
422
+ // The host's committed request receipt, not a second comparison of
423
+ // normalized prompt content, identifies our turn. Native context,
424
+ // skill/subagent messages and the owner's same-session follow-ups
425
+ // are ordinary inputs to that turn, not a reason to hide its reply.
426
+ if (data.source?.kind !== 'user' || data.source.rpcId !== input.requestId)
427
+ return;
428
+ if (matchedTurn !== undefined || currentTurn === undefined)
429
+ return stop('ambiguous-turn', '无法确认本次消息的轮次,请在原会话查看。');
430
+ matchedTurn = currentTurn;
431
+ }
432
+ else if (matchedTurn !== undefined && currentTurn === matchedTurn) {
433
+ if (event.type === 'deliverables/presented' && data.turn === matchedTurn && id(data.callId) && Array.isArray(data.files)) {
434
+ for (const file of data.files) {
435
+ if (!row(file) || typeof file.path !== 'string' || !file.path.trim() || file.path.length > 4096 || /[\u0000-\u001f]/.test(file.path)) {
436
+ omitted = true;
437
+ continue;
438
+ }
439
+ if (files.has(file.path))
440
+ continue;
441
+ if (files.size >= 6) {
442
+ omitted = true;
443
+ continue;
444
+ }
445
+ files.add(file.path);
446
+ }
447
+ return;
448
+ }
449
+ if (event.type === 'approval/asked')
450
+ return notify('approval');
451
+ if (event.type === 'tool/call' && data.name === 'ask_user_question') {
452
+ return notify('question');
453
+ }
454
+ if (event.type === 'assistant/message' && data.turn === matchedTurn) {
455
+ const content = data.message?.content;
456
+ finalText = undefined;
457
+ images = [];
458
+ finalInterrupted = data.interrupted === true;
459
+ if (!Array.isArray(content))
460
+ return;
461
+ const texts = content.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text);
462
+ const text = texts.join('');
463
+ if (Buffer.byteLength(text) > REPLY_TEXT_MAX_BYTES)
464
+ return stop('output-unavailable', '回复超过本地传输上限,请在原会话查看。');
465
+ if (text)
466
+ finalText = text;
467
+ for (const part of content)
468
+ if (part?.type === 'image') {
469
+ const ref = part.attachment;
470
+ if (!nativeImage(ref, ref?.name) || images.length >= 6) {
471
+ omitted = true;
472
+ continue;
473
+ }
474
+ const suffix = ref.mediaType === 'image/jpeg' ? 'jpg' : ref.mediaType.slice(6);
475
+ images.push({ ...ref, name: typeof ref.name === 'string' && ref.name.trim() ? ref.name : `image-${images.length + 1}.${suffix}` });
476
+ }
477
+ }
478
+ else if (event.type === 'turn/end' && data.turn === matchedTurn) {
479
+ const interrupted = data.reason?.kind !== 'completed' || finalInterrupted;
480
+ if (interrupted) {
481
+ const message = data.reason?.kind === 'max-tokens' ? '本轮回复达到长度上限。'
482
+ : ['aborted', 'cancelled'].includes(data.reason?.kind) || finalInterrupted ? '本轮已停止。'
483
+ : '本轮未正常完成,请在原会话查看状态。';
484
+ if (!finalText && !files.size && !images.length)
485
+ return stop('interrupted', message);
486
+ // DSH displays durable partial text/images too, with a separate
487
+ // stop marker. Preserve that content instead of replacing it.
488
+ notices.push(message);
489
+ }
490
+ if (!finalText && !files.size && !images.length)
491
+ return stop('output-unavailable', '本次任务已结束,但没有可确认的最终文字回复,请在电脑或小程序查看。');
492
+ completion = finalText || '';
493
+ if (accepted)
494
+ finish(undefined, completion);
495
+ }
496
+ }
497
+ if (event.type === 'turn/end' && data.turn === currentTurn && matchedTurn === undefined)
498
+ currentTurn = undefined;
499
+ }, { global: true }));
500
+ offs.push(this.ctx.on('session/disposed', (session) => {
501
+ if (!settled && session?.id === input.sessionId)
502
+ stop('session-changed', '会话已卸载,无法确认本次回复,请在电脑或小程序核对。');
503
+ }, { global: true }));
504
+ signal.addEventListener('abort', abort, { once: true });
505
+ if (signal.aborted) {
506
+ abort();
507
+ return;
508
+ }
509
+ // Native prompt checks its signal only before admission. Deliberately
510
+ // detach this signal from the external observer once dispatch starts.
511
+ // No cancel, preset selection, ownerInput proof or fallback retry exists.
512
+ const gateway = this.gateway();
513
+ if (this.archived().has(input.sessionId)) {
514
+ stop('not-found', '此会话已归档,本条消息未发送。');
515
+ return;
516
+ }
517
+ const live = this.ctx.get('sessions')?.get(input.sessionId);
518
+ if (live && !checkSubject(live))
519
+ return;
520
+ // Queue through the host even while an earlier turn is running. The
521
+ // native inbox owns scheduling; only our committed rpcId starts reply
522
+ // collection, so an earlier or unrelated turn is never forwarded.
523
+ // The installed native prompt has no generation precondition. This
524
+ // immediate check plus event identity checks fail closed for observed
525
+ // resets; they cannot provide CAS inside the native async dispatcher.
526
+ input.checkAdmission?.();
527
+ this.assertAccess?.();
528
+ dispatched = true;
529
+ void Promise.resolve(gateway.invoke({ namespace: 'session', method: 'prompt', args: { request: {
530
+ sessionId: input.sessionId, requestId: input.requestId, mode: 'queue', content: prepared.content,
531
+ } }, signal: new AbortController().signal })).then((receipt) => {
532
+ if (settled)
533
+ return;
534
+ if (!row(receipt) || receipt.accepted !== true)
535
+ return stop('delivery-unknown', '无法确认电脑是否接收本次消息,请在电脑或小程序核对,勿重复发送。');
536
+ accepted = true;
537
+ if (completion !== undefined)
538
+ finish(undefined, completion);
539
+ }, (error) => {
540
+ if (settled)
541
+ return;
542
+ const code = row(error) ? error.code : undefined;
543
+ if (code === 'session/agent-busy' || code === 'session/writer-held') {
544
+ stop('busy', '原生会话当前被占用或不能接收此消息,请在电脑或小程序核对;没有自动重发。');
545
+ }
546
+ else if (code === 'session/attachment-invalid') {
547
+ finish(fail('attachment-rejected', '当前模型或原生会话不支持此附件,本条消息未发送。请在电脑或小程序检查附件和所选模型。', 'not-submitted'));
548
+ }
549
+ else
550
+ stop('delivery-unknown', '本次发送未获得可确认的接收结果,请在电脑或小程序核对,勿重复发送。');
551
+ });
552
+ }
553
+ catch {
554
+ stop('delivery-unknown', dispatched
555
+ ? '本次发送结果不明,请在电脑或小程序核对,勿重复发送。'
556
+ : '未能建立本次回复观察,请稍后在电脑或小程序核对。');
557
+ }
558
+ });
559
+ }
560
+ }