@dshfly/remote-connector 0.2.1

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.
@@ -0,0 +1,556 @@
1
+ // packages/remote-connector/core/dsh-web.js
2
+ // DSH web 0.1.2-rc.1 进程内适配层(connector 是 dsh web 的 cordis 插件,同进程)。
3
+ //
4
+ // 背景(2026-09 升级):DSH 升级后 /api 协议结构性改动:
5
+ // · 所有 /api/* 与事件 upgrade 要求 browser-session 签名 cookie(裸请求 401)→ 不再走 HTTP,
6
+ // 改进程内直接调 DSH 的 typertGateway 服务(天然免认证)。
7
+ // · 端点从点号改斜杠 + 多处改名/删除:host.describe→settings/describe、session.models→
8
+ // session/modelCatalog、session.history→session/page(+follow)、workspace.list→workspace/follow
9
+ // (流 baseline)、agentPreset.*→agentPresets.*、
10
+ // · payload 必须是单一 { args: {...} } 字段(旧手机端发扁平 payload)。
11
+ // · /api/respond 被移除(审批/提问改 waterfall + $events/result)。
12
+ //
13
+ // 本模块把【手机端稳定协议】翻译成【进程内 DSH DSL】:
14
+ // - invoke(method, payload):点号/改名映射 + {args} 包装 → typertGateway.invokeRpc(...)
15
+ // - workspaceList():由 workspace/follow 流 baseline 合成旧版 workspace.list 响应
16
+ // - events:进程内 cordis 事件(api-session/*, approval/request, user-questions/request)→
17
+ // 手机端 server-request 帧
18
+ // - resolveApproval/resolveQuestion:把手机端 respond 结果结算为 waterfall 结果
19
+ //
20
+ // 纯翻译函数(mapInvoke / buildEventFrames / buildApprovalResult)可单测;gateway 与 ctx
21
+ // 依赖注入(测试注入 mock,生产传 Cordis 真服务)。
22
+
23
+ const LEGACY_OK_FIELD = Symbol('ok');
24
+
25
+ /** 生成 requestId(DSH session/prompt 必填字段;手机端未发时由 connector 补一个)。 */
26
+ export function makeRequestId() {
27
+ try {
28
+ return globalThis.crypto?.randomUUID?.() ?? `req-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
29
+ } catch {
30
+ return `req-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
31
+ }
32
+ }
33
+
34
+ /** 单字段 payload → DSH 要求的 { args: {...} }。非对象/已含 args → 原样(避免重复包装)。 */
35
+ export function wrapArgs(payload) {
36
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { args: {} };
37
+ if ('args' in payload && typeof payload.args === 'object' && payload.args !== null) return payload;
38
+ return { args: payload };
39
+ }
40
+
41
+ /**
42
+ * 映射手机端点号/旧名 → DSH 端点(斜杠)+ 该端点的 {args} 结构(参数名键控)。
43
+ * 参数结构经对本机 dsh web 0.1.2-rc.1 实测确认:
44
+ * · session/list → { _request:{} } (入参名 _request)
45
+ * · 多数 session.*、workspace.* → { request:{...} } (单 request 对象参数)
46
+ * · session/modelCatalog、settings/describe、agentPresets/list → {}(无参)
47
+ * 返回 { endpoint, args } | { synthesizeWorkspaceList:true } | null(未知)。纯函数,可单测。
48
+ */
49
+ export function mapInvoke(legacyMethod, payload) {
50
+ const p = payload || {};
51
+ const req = (fields) => ({ request: fields });
52
+ switch (legacyMethod) {
53
+ // host.describe:新 DSH 无 host 命名空间(settings/describe 只有 writable/hasDocument/namespaces,
54
+ // 无 version/cwd)→ 由 adapter 合成 { version, cwd, home }(version 读 DSH 包、cwd=进程 cwd)。
55
+ case 'host.describe': return { synthesizeHostInfo: true };
56
+
57
+ case 'session.list': return { endpoint: 'session/list', args: { _request: {} } };
58
+ case 'session.create': return { endpoint: 'session/create', args: req({
59
+ ...(p.sessionId != null ? { sessionId: p.sessionId } : {}),
60
+ ...(p.workspaceId != null ? { workspaceId: p.workspaceId } : {}),
61
+ ...(p.cwd != null ? { cwd: p.cwd } : {}),
62
+ ...(p.agentPreset != null ? { agentPreset: p.agentPreset } : {}),
63
+ }) };
64
+ case 'session.prompt': return { endpoint: 'session/prompt', args: req({
65
+ sessionId: p.sessionId,
66
+ requestId: p.requestId,
67
+ content: p.content,
68
+ ...(p.mode != null ? { mode: p.mode } : {}),
69
+ ...(p.clientTimeZone != null ? { clientTimeZone: p.clientTimeZone } : {}),
70
+ }) };
71
+ case 'session.cancel': return { endpoint: 'session/cancel', args: req({ sessionId: p.sessionId }) };
72
+ case 'session.search': return { endpoint: 'session/search', args: req({ query: p.query }) };
73
+ case 'session.rename': return { endpoint: 'session/rename', args: req({ sessionId: p.sessionId, title: p.title }) };
74
+ case 'session.selectModel': return { endpoint: 'session/selectModel', args: req({
75
+ sessionId: p.sessionId, provider: p.provider, model: p.model,
76
+ ...(p.reasoningEffort != null ? { reasoningEffort: p.reasoningEffort } : {}),
77
+ }) };
78
+ case 'session.attachment': return { endpoint: 'session/attachment', args: req({ sessionId: p.sessionId, attachmentId: p.attachmentId }) };
79
+ case 'session.models': return { endpoint: 'session/modelCatalog', args: {} }; // 实测:无参
80
+ // session.history → session/page:旧 {sessionId, maxMessages?, beforeSeq?} → 新 page request。
81
+ // ⚠️ 实测(本机 dsh web 0.1.2-rc.1):session/page 的 request.throughSeq **必填**且须 ≥-1 的安全整数;
82
+ // -1 是"最新尾页"哨兵(source: dsh-api-session-controller validatePageRequest + page 的
83
+ // `throughSeq === -1 ? -1`)。手机端 history 不传 throughSeq → 默认 -1(最新)。
84
+ case 'session.history': return {
85
+ endpoint: 'session/page',
86
+ args: req({
87
+ address: { kind: 'session', sessionId: p.sessionId },
88
+ throughSeq: Number.isSafeInteger(p.throughSeq) ? p.throughSeq : -1,
89
+ ...(Number.isSafeInteger(p.beforeSeq) ? { beforeSeq: p.beforeSeq } : {}),
90
+ ...(Number.isSafeInteger(p.maxMessages) ? { maxMessages: p.maxMessages } : {}),
91
+ }),
92
+ };
93
+
94
+ case 'workspace.create': return { endpoint: 'workspace/create', args: req({ path: p.path }) };
95
+ case 'workspace.list': return { synthesizeWorkspaceList: true };
96
+
97
+ case 'agentPreset.list': return { endpoint: 'agentPresets/list', args: {} };
98
+ // agentPresets/select 实测:实参**直接** {agentId, agentPreset}(不包 request;包了报
99
+ // `unexpected "request"`),返回**字符串** preset id(app 读 r.agentPreset → 由 translateResult 包装)。
100
+ case 'agentPreset.select': return {
101
+ endpoint: 'agentPresets/select',
102
+ args: { agentId: p.sessionId ?? p.agentId ?? p.agent, agentPreset: p.agentPreset ?? p.id },
103
+ };
104
+
105
+ // commands/execute:手机端 client 已用**斜杠**(call('commands/execute', …)),payload 为
106
+ // { args: { agentId, line, images } }。process 内直接作为 gateway 的 args(agentId 为参数键,
107
+ // 实测为 agentId 而非 agent)。同时兜住历史点号写法。
108
+ case 'commands.execute':
109
+ case 'commands/execute': {
110
+ const inner = p?.args ?? p;
111
+ return { endpoint: 'commands/execute', args: inner };
112
+ }
113
+ default:
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /* eslint-disable camelcase */
119
+ /**
120
+ * 把 typertGateway 的 {ok:true,value}|{ok:false,error} 转成手机端可回传的结果。
121
+ * 手机端 server-response 信封已在 connector-core 组装;此处只看 ok/error。
122
+ */
123
+ export function fromRpc(result) {
124
+ return { ok: !!result?.ok, value: result?.value, error: result?.error };
125
+ }
126
+
127
+ /**
128
+ * workspace/follow 流的 baseline → 手机端旧版 workspace.list 响应 value {items, archivedSessionIds}。
129
+ * workspaceView 的 path 即手机端允许根。纯函数,可单测。
130
+ */
131
+ export function buildWorkspaceList(baseline) {
132
+ const b = baseline || {};
133
+ const items = (b?.value?.items || []).map((w) => ({
134
+ workspaceId: w?.workspaceId,
135
+ path: w?.path,
136
+ title: w?.title,
137
+ sessionIds: w?.sessionIds || [],
138
+ updatedAt: w?.updatedAt,
139
+ createdAt: w?.createdAt,
140
+ }));
141
+ return { items, archivedSessionIds: b?.value?.archivedSessionIds || [] };
142
+ }
143
+ /* eslint-enable camelcase */
144
+
145
+ /**
146
+ * 进程内 cordis emit 事件 → 手机端 server-request 帧(method 即手机端消费名)。
147
+ * 返回帧数组;空数组表示无消费。纯函数,可单测。
148
+ */
149
+ export function buildEventFrames({ event, args, rpcId }) {
150
+ if (!event || !rpcId) return [];
151
+ const frame = (method, payload) => ({ type: 'server-request', rpcId, method, payload });
152
+ switch (event) {
153
+ case 'api-session/added': {
154
+ const s = args?.[0] || {};
155
+ return [frame('session/added', { sessionId: s.sessionId, summary: s })];
156
+ }
157
+ case 'api-session/removed':
158
+ return [frame('session/removed', { sessionId: args?.[0] })];
159
+ case 'api-session/status':
160
+ // 手机端 host/session-status 帧:会话运行状态翻转(running true/false)
161
+ return [frame('host/session-status', { sessionId: args?.[0], running: args?.[1] === true })];
162
+ case 'api-session/error':
163
+ return [frame('session/error', { sessionId: args?.[0], message: args?.[1] })];
164
+ case 'api-session/activity':
165
+ // 活动信号不单独成帧(手机端靠 follow 流拿消息级事件);此处仅返回空。
166
+ return [];
167
+ default:
168
+ return [];
169
+ }
170
+ }
171
+
172
+ /**
173
+ * 手机端 respond 结果 → waterfall 结算 outcome(近似 $events/result 语义)。
174
+ * approval: outcome ∈ 'allowed-once'|'rejected'|'cancelled'|'unavailable'
175
+ * question: answer 字符串(或 {answers:[...]})。
176
+ * 返回 { kind:'result', value } | { kind:'rejected', error }。纯函数,可单测。
177
+ */
178
+ export function buildApprovalResult(kind, outcome) {
179
+ if (kind === 'question') {
180
+ if (outcome === 'cancelled') {
181
+ return { kind: 'rejected', error: { name: 'Error', message: 'the user cancelled ask_user_question', code: 'cancelled', details: {} } };
182
+ }
183
+ const answers = typeof outcome === 'object' && outcome?.answers ? outcome.answers : [{ id: '0', selected: [String(outcome)] }];
184
+ return { kind: 'result', value: { answers } };
185
+ }
186
+ // approval:旧手机端 'allow'→allowed-once;'deny'/'rejected'→rejected;'cancelled'→cancelled
187
+ switch (String(outcome)) {
188
+ case 'deny':
189
+ case 'rejected':
190
+ return { kind: 'result', value: 'rejected' };
191
+ case 'cancelled':
192
+ return { kind: 'result', value: 'cancelled' };
193
+ default:
194
+ return { kind: 'result', value: 'allowed-once' };
195
+ }
196
+ }
197
+
198
+ /**
199
+ * $events 流的 waterfall 帧 → 手机端审批/提问 requested 帧。纯函数,可单测。
200
+ * rpcId 由调用方生成(connector 用于和手机端 respond 关联)。
201
+ */
202
+ export function buildWaterfallFrame({ event, eventId, agentId, request, rpcId }) {
203
+ if (event === 'approval/request') {
204
+ return {
205
+ type: 'server-request', rpcId, method: 'approval/requested',
206
+ payload: {
207
+ sessionId: agentId,
208
+ approvalId: eventId,
209
+ toolName: request?.toolName ?? null,
210
+ callId: request?.callId ?? null,
211
+ reason: request?.reason ?? null,
212
+ },
213
+ };
214
+ }
215
+ if (event === 'user-questions/request') {
216
+ return {
217
+ type: 'server-request', rpcId, method: 'question/requested',
218
+ payload: {
219
+ sessionId: agentId,
220
+ questions: request?.questions || [],
221
+ },
222
+ };
223
+ }
224
+ return null;
225
+ }
226
+
227
+ /** waterfall 被结算($events cancel 帧)→ 手机端 resolved 帧。纯函数,可单测。
228
+ * 必须带 sessionId(手机端 resolved 分支按 sid 清除卡片)+ approvalId/questionRpcId
229
+ * (connector 的 PendingApprovalCache 按这些 key 剪枝)。 */
230
+ export function buildResolvedFrame(kind, rpcId, { sessionId, eventId } = {}) {
231
+ const payload = { sessionId: sessionId ?? null };
232
+ if (kind === 'question') payload.questionRpcId = rpcId;
233
+ else payload.approvalId = eventId ?? rpcId;
234
+ return {
235
+ type: 'server-request', rpcId,
236
+ method: kind === 'question' ? 'question/resolved' : 'approval/resolved',
237
+ payload,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * session/follow 流的一个 item → 手机端帧数组。纯函数,可单测。
243
+ * - snapshot:仅取 projections(手机端已用 session/history→page 载入 records,避免重放重复)
244
+ * - event:→ session/event 帧(消息级实时)
245
+ * - 其余 item 忽略(返回 [])。
246
+ */
247
+ export function buildSessionStreamFrames(item, sessionId, rpcId) {
248
+ if (!item || !rpcId) return [];
249
+ const frame = (method, payload) => ({ type: 'server-request', rpcId, method, payload });
250
+ if (item.type === 'snapshot') {
251
+ const frames = [];
252
+ if (item.projections) frames.push(frame('session/projection', { sessionId, projections: item.projections }));
253
+ return frames;
254
+ }
255
+ if (item.type === 'event') {
256
+ return [frame('session/event', { sessionId, event: item.event })];
257
+ }
258
+ return [];
259
+ }
260
+
261
+ /**
262
+ * 手机端会话历史读取的新旧结果形状差异翻译。
263
+ * 新 DSH session/page 返回 { records, hasMore }(record = {type:'event'|'chunks', event});
264
+ * 手机端(@dshfly/client 不变)读 hist.events + hist.hasMore,且每个 entry 用 e.event。
265
+ * records 的元素已带 .event → 直接改名 records→events 即可。纯函数,可单测。
266
+ */
267
+ export function translateResult(method, value) {
268
+ if (method === 'session.history' && value && Array.isArray(value.records)) {
269
+ return { events: value.records, hasMore: value.hasMore };
270
+ }
271
+ // session.list:新 DSH 把 agentPreset/modelSelection 等放进 projections.values;手机端读取
272
+ // row.agentPreset(顶层)。把投影里的 agentPreset 提到顶层,保证"工作模式"显示。
273
+ if (method === 'session.list' && value && Array.isArray(value.items)) {
274
+ for (const it of value.items) {
275
+ const pv = it?.projections?.values;
276
+ if (pv?.agentPreset != null && it.agentPreset == null) it.agentPreset = pv.agentPreset;
277
+ }
278
+ }
279
+ // agentPresets/select:实测返回**字符串** preset id;app 读 r.agentPreset → 包装成 {agentPreset}。
280
+ if (method === 'agentPreset.select' && typeof value === 'string') {
281
+ return { agentPreset: value };
282
+ }
283
+ return value;
284
+ }
285
+
286
+ /**
287
+ * 适配器工厂:封装进程内 DSH 调用 + 事件订阅 + 审批结算。
288
+ * gateway:{ invokeRpc(endpoint, payload, signal), stream(request) }(typertGateway 服务)
289
+ * ctx:Cordis 上下文(on/waterfall),可选(事件订阅用;测试注入 mock)。
290
+ */
291
+ export function createDshAdapter({ gateway, ctx, dshVersion = null, connectorVersion = null }) {
292
+ // gateway 允许传对象或返回对象的惰性函数(Cordis 服务激活时序:typertGateway 依赖 connection,
293
+ // 可能晚于只有 webServer 依赖的 connector apply——惰性解析确保请求时拿到最新服务实例)。
294
+ const gw = () => (typeof gateway === 'function' ? gateway() : gateway);
295
+ return {
296
+ _clientId: null,
297
+ _pendingByEvent: new Map(), // eventId -> { rpcId, event, kind, clientId }
298
+ _pendingByRpc: new Map(), // rpcId -> { eventId, event, kind, clientId }
299
+
300
+ /** 手机端点号 method + 扁平 payload → 进程内 DSH 调用。返回 {ok,value}|{ok,error}。 */
301
+ async invoke(method, payload) {
302
+ const mapped = mapInvoke(method, payload);
303
+ if (!mapped) return { ok: false, error: { code: 'unknown-method', message: `dshfly: unknown DSH method ${method}`, details: {} } };
304
+ if (mapped.synthesizeWorkspaceList) return this.workspaceList();
305
+ if (mapped.synthesizeHostInfo) return { ok: true, value: this.hostInfo() };
306
+ const g = gw();
307
+ if (!g?.invokeRpc) return { ok: false, error: { code: 'no-gateway', message: 'dsh adapter: typertGateway unavailable', details: {} } };
308
+ // session.history→session/page:throughSeq 必填且 ≤ 游标;手机端不传 → 解析最新游标
309
+ // (-1 是空页哨兵而非"最新";用 MAX_INT 触发"past cursor N"再取 N 重试,冷安全、确定一次重试)。
310
+ if (method === 'session.history' && mapped.endpoint === 'session/page') {
311
+ const req = mapped.args.request;
312
+ const throughSeq = (Number.isSafeInteger(req.throughSeq) && req.throughSeq >= 0)
313
+ ? req.throughSeq
314
+ : await this._latestCursor(req.address?.sessionId);
315
+ const args = { request: { ...req, throughSeq } };
316
+ const r = await g.invokeRpc('session/page', { args });
317
+ if (!r?.ok) return r;
318
+ return { ok: true, value: translateResult('session.history', r.value) };
319
+ }
320
+ // session.prompt:requestId 必填(实测契约),手机端未发 → 补生成一个(mode:'queue' 已实测有效)
321
+ if (method === 'session.prompt' && mapped.endpoint === 'session/prompt' && !mapped.args.request?.requestId) {
322
+ mapped.args.request.requestId = makeRequestId();
323
+ }
324
+ // session.models → session/modelCatalog(全局、无 current)。手机端读 m.current 作为会话当前模型:
325
+ // 从会话投影 modelSelection.lastUsed 补 current(回退 default)。
326
+ if (method === 'session.models' && mapped.endpoint === 'session/modelCatalog') {
327
+ const cat = await g.invokeRpc('session/modelCatalog', { args: {} });
328
+ if (!cat?.ok) return cat;
329
+ if (cat.value && !cat.value.current) {
330
+ cat.value.current = (await this._sessionModel(payload?.sessionId)) ?? cat.value.default ?? null;
331
+ }
332
+ return { ok: true, value: cat.value };
333
+ }
334
+ const r = await g.invokeRpc(mapped.endpoint, { args: mapped.args });
335
+ if (!r?.ok) return r;
336
+ const value = translateResult(method, r.value);
337
+ return { ok: true, value };
338
+ },
339
+
340
+ /** 最新页游标:MAX_INT 探测 → 解析 "past cursor N" 里的当前游标。失败回退 0。 */
341
+ async _latestCursor(sessionId) {
342
+ if (!sessionId) return 0;
343
+ const g = gw();
344
+ if (!g?.invokeRpc) return 0;
345
+ try {
346
+ const probe = await g.invokeRpc('session/page', {
347
+ args: { request: { address: { kind: 'session', sessionId }, throughSeq: Number.MAX_SAFE_INTEGER, maxMessages: 1 } },
348
+ });
349
+ if (probe?.ok) return Number.MAX_SAFE_INTEGER;
350
+ const m = /past cursor (\d+)/.exec(String(probe?.error?.message || ''));
351
+ if (m) return Number(m[1]);
352
+ } catch {}
353
+ return 0;
354
+ },
355
+
356
+ /** 会话当前模型:从 session/list 对应行的投影 modelSelection 取(lastUsed 优先,次 next)。 */
357
+ async _sessionModel(sessionId) {
358
+ if (!sessionId) return null;
359
+ const g = gw();
360
+ if (!g?.invokeRpc) return null;
361
+ try {
362
+ const list = await g.invokeRpc('session/list', { args: { _request: {} } });
363
+ const item = (list?.value?.items || []).find((i) => i.sessionId === sessionId);
364
+ const ms = item?.projections?.values?.modelSelection;
365
+ return ms?.lastUsed ?? ms?.next ?? null;
366
+ } catch {
367
+ return null;
368
+ }
369
+ },
370
+
371
+ /** 由 workspace/follow baseline 合成旧版 workspace.list 响应。 */
372
+ async workspaceList() {
373
+ const g = gw();
374
+ if (!g?.stream) return { ok: false, error: { code: 'no-gateway', message: 'dsh adapter: typertGateway.stream unavailable', details: {} } };
375
+ try {
376
+ const controller = new AbortController();
377
+ const iter = await g.stream({ namespace: 'workspace', method: 'follow', args: {}, signal: controller.signal });
378
+ const first = await iter.next(); // 取 baseline
379
+ controller.abort();
380
+ if (first.done || !first.value) throw new Error('workspace/follow produced no baseline');
381
+ return { ok: true, value: buildWorkspaceList(first.value) };
382
+ } catch (e) {
383
+ return { ok: false, error: { code: 'workspace-list-failed', message: e?.message || String(e), details: {} } };
384
+ }
385
+ },
386
+
387
+ /**
388
+ * 合成 host.describe 响应(2026-09:新 DSH 无 host 命名空间)。手机端工作区详情页读
389
+ * host.version(DSH 版本)与 host.cwd(主机工作目录)。version 取传入的 DSH 包版本
390
+ * (index.js resolveDshVersion),cwd 取 dsh web 进程 cwd,home 兜底。
391
+ */
392
+ hostInfo() {
393
+ return {
394
+ version: typeof dshVersion === 'function' ? dshVersion() : (dshVersion ?? null),
395
+ // 本连接器插件(@dshfly/remote-connector)自身版本 —— 运行中已安装包的版本。
396
+ connectorVersion: connectorVersion ?? null,
397
+ cwd: typeof process !== 'undefined' ? (process.cwd?.() ?? null) : null,
398
+ home: typeof process !== 'undefined' ? (process.env?.HOME || null) : null,
399
+ };
400
+ },
401
+
402
+ /**
403
+ * 订阅进程内 cordis 事件并按映射推送(emit 事件直接映射;waterfall 由
404
+ * subscribeApproval/subscribeQuestion 单独结算)。
405
+ * onFrames: (frames:[]) => void。
406
+ */
407
+ subscribeEvents(onFrames) {
408
+ if (!ctx?.on) return () => {};
409
+ const disposers = [];
410
+ for (const event of ['api-session/added', 'api-session/removed', 'api-session/status', 'api-session/error']) {
411
+ disposers.push(ctx.on(event, (...args) => {
412
+ let frames;
413
+ try {
414
+ frames = buildEventFrames({ event, args, rpcId: `ev-${Date.now()}-${Math.floor(Math.random() * 1e6)}` });
415
+ } catch { frames = []; }
416
+ if (frames.length) onFrames(frames);
417
+ }));
418
+ }
419
+ return () => { for (const d of disposers) { try { d(); } catch {} } };
420
+ },
421
+
422
+ resolveApproval: (outcome) => buildApprovalResult('approval', outcome),
423
+ resolveQuestion: (outcome) => buildApprovalResult('question', outcome),
424
+
425
+ /**
426
+ * 以 DSH $events 远程流客户端身份运行:接收 emit 事件 + approval/request +
427
+ * user-questions/request 瀑布,转成手机端帧经 onFrames 送出;瀑布由 onResolved 结算。
428
+ * 返回 stop()。gateway.openRemoteEvents({args:{}}, signal) 预期产出
429
+ * {type:'ready',clientId} / {type:'emit',event,args} / {type:'waterfall',event,eventId,agentId,request}
430
+ * / {type:'cancel',eventId}。
431
+ */
432
+ startEventPump({ onFrames, onResolved, onError } = {}) {
433
+ const g = gw();
434
+ if (!g?.openRemoteEvents) return () => {};
435
+ const controller = new AbortController();
436
+ let active = true;
437
+ let backoff = 1000;
438
+ let clientId = null;
439
+ let pendingByEvent = this._pendingByEvent;
440
+ let pendingByRpc = this._pendingByRpc;
441
+ const rand = () => `rpc-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
442
+
443
+ const pump = async () => {
444
+ while (active && !controller.signal.aborted) {
445
+ try {
446
+ const iter = await g.openRemoteEvents({ args: {} }, controller.signal);
447
+ // 每次开流先消费 opening ready(有的实现首帧即 ready;跳过非 ready 直到遇到)
448
+ for await (const frame of iter) {
449
+ if (!active) break;
450
+ if (frame?.type === 'ready') { clientId = frame.clientId; continue; }
451
+ if (frame?.type === 'emit') {
452
+ const frames = buildEventFrames({ event: frame.event, args: frame.args, rpcId: rand() });
453
+ if (frames.length) onFrames?.(frames);
454
+ continue;
455
+ }
456
+ if (frame?.type === 'waterfall') {
457
+ const kind = frame.event === 'user-questions/request' ? 'question' : frame.event === 'approval/request' ? 'approval' : null;
458
+ if (!kind) continue;
459
+ const rpcId = rand();
460
+ const rec = { rpcId, eventId: frame.eventId, event: frame.event, kind, clientId, sessionId: frame.agentId ?? null };
461
+ pendingByEvent.set(frame.eventId, rec);
462
+ pendingByRpc.set(rpcId, rec);
463
+ const phoneFrame = buildWaterfallFrame({ event: frame.event, eventId: frame.eventId, agentId: frame.agentId, request: frame.request, rpcId });
464
+ onFrames?.([phoneFrame]);
465
+ continue;
466
+ }
467
+ if (frame?.type === 'cancel') {
468
+ const rec = pendingByEvent.get(frame.eventId);
469
+ if (rec) {
470
+ pendingByEvent.delete(frame.eventId);
471
+ pendingByRpc.delete(rec.rpcId);
472
+ onResolved?.(buildResolvedFrame(rec.kind, rec.rpcId, { sessionId: rec.sessionId, eventId: rec.eventId }));
473
+ }
474
+ }
475
+ }
476
+ // 流结束(非主动停止)→ 退避重开
477
+ if (active && !controller.signal.aborted) {
478
+ await new Promise((r) => setTimeout(r, backoff));
479
+ backoff = Math.min(backoff * 2, 30000);
480
+ }
481
+ } catch (e) {
482
+ onError?.(e);
483
+ if (active && !controller.signal.aborted) {
484
+ await new Promise((r) => setTimeout(r, backoff));
485
+ backoff = Math.min(backoff * 2, 30000);
486
+ }
487
+ }
488
+ }
489
+ };
490
+ const task = pump();
491
+ return () => { active = false; try { controller.abort(); } catch {} };
492
+ },
493
+
494
+ /**
495
+ * 为专注会话开 session/follow 流,把 snapshot/event 转成手机端帧(Phase 3 消息级实时)。
496
+ * 返回 stop()(取消流)。同一会话重复调用/换会话由调用方先 stop 旧流。
497
+ */
498
+ startSessionFollow(sessionId, { onFrames, onError } = {}) {
499
+ const g = gw();
500
+ if (!g?.stream) return () => {};
501
+ const controller = new AbortController();
502
+ let active = true;
503
+ const rpcId = `sf-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
504
+ (async () => {
505
+ try {
506
+ const iter = await g.stream({
507
+ namespace: 'session', method: 'follow',
508
+ // ⚠️ session/follow 的 args 也走 {request:{...}} 包装(实测定型:扁平 {address} 开流为空)。
509
+ args: { request: { address: { kind: 'session', sessionId }, maxMessages: 10 } },
510
+ signal: controller.signal,
511
+ });
512
+ for await (const item of iter) {
513
+ if (!active) break;
514
+ const frames = buildSessionStreamFrames(item, sessionId, rpcId);
515
+ if (frames.length) onFrames?.(frames);
516
+ }
517
+ } catch (e) {
518
+ if (!active) return;
519
+ onError?.(e);
520
+ }
521
+ })();
522
+ return () => { active = false; try { controller.abort(); } catch {} };
523
+ },
524
+
525
+ /**
526
+ * 手机端 /api/respond(client-response 信封)→ 结算对应 waterfall。
527
+ * message = { type:'client-response', rpcId, result:{ok,value|error} }。
528
+ * 返回 {ok:true}(已结算)或 {ok:false,error}(无待决项/网关缺失)。
529
+ */
530
+ async resolveRespond(message) {
531
+ const rpcId = message?.rpcId;
532
+ const rec = this._pendingByRpc.get(rpcId);
533
+ if (!rec) return { ok: false, error: { code: 'approval-not-found', message: '该审批/提问已结算或不存在', details: {} } };
534
+ if (!gw()?.dispatchRpc) return { ok: false, error: { code: 'no-gateway', message: 'dsh adapter: typertGateway unavailable', details: {} } };
535
+ let outcome;
536
+ if (rec.kind === 'question') {
537
+ const answer = message?.result?.value?.answer;
538
+ outcome = (!message?.result?.ok) || answer == null ? buildApprovalResult('question', 'cancelled') : buildApprovalResult('question', answer);
539
+ } else {
540
+ const raw = message?.result?.ok === false ? 'cancelled' : (message?.result?.value?.outcome ?? 'allow');
541
+ outcome = buildApprovalResult('approval', raw);
542
+ }
543
+ // ⚠️ $events/result 由网关专门的 dispatchRpc 处理(invokeRpc 会当普通业务方法解析 → 静默失败
544
+ // "identifies no active event stream")。必须走 dispatchRpc。
545
+ const r = await gw().dispatchRpc('$events/result', { args: { clientId: rec.clientId, eventId: rec.eventId, outcome } });
546
+ this._pendingByEvent.delete(rec.eventId);
547
+ this._pendingByRpc.delete(rpcId);
548
+ if (!r.ok) return { ok: false, error: r.error };
549
+ // 手机端作为 resolver 时,网关不会向"此客户端"广播 cancel(它在 deliveries 里已被移除),
550
+ // 但手机 UI 仍需要 resolved 帧来按 sid 清除卡片 → 回传 resolvedFrame 由 connector 注入。
551
+ return { ok: true, value: undefined, resolvedFrame: buildResolvedFrame(rec.kind, rpcId, { sessionId: rec.sessionId, eventId: rec.eventId }) };
552
+ },
553
+ };
554
+ }
555
+
556
+ export { LEGACY_OK_FIELD };
package/core/keys.js ADDED
@@ -0,0 +1,68 @@
1
+ // packages/remote-connector/core/keys.js
2
+ // 设备密钥的加载/生成/持久化(私钥只落本机文件,权限 0600)。自 services/connector/keys.js 迁入(单一代码源)。
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { randomBytes } from 'node:crypto';
8
+ import { generateIdentityKeypair, generateTransportKeypair } from '@dshfly/crypto';
9
+
10
+ export function defaultKeysFile() {
11
+ const dir = process.env.DSHFLY_HOME || path.join(os.homedir(), '.dshfly');
12
+ return path.join(dir, 'keys.json');
13
+ }
14
+
15
+ /** 自动生成的设备账号名(dev login 账号,配对的 user 隔离键)。首次生成后持久化,稳定不变。 */
16
+ export function deviceAccountName() {
17
+ return 'dshfly-' + randomBytes(6).toString('hex');
18
+ }
19
+
20
+ export function loadOrCreateKeys(file = defaultKeysFile()) {
21
+ if (fs.existsSync(file)) {
22
+ const k = JSON.parse(fs.readFileSync(file, 'utf8'));
23
+ k.sessionKeys = k.sessionKeys || {};
24
+ k.outSeq = k.outSeq || 0; // 兼容旧 keys 文件
25
+ if (!k.accountName) {
26
+ k.accountName = deviceAccountName(); // D1:仅兼容旧 keys 文件(不再参与任何认证/展示)
27
+ saveKeys(k, file);
28
+ }
29
+ return k;
30
+ }
31
+ const id = generateIdentityKeypair();
32
+ const tr = generateTransportKeypair();
33
+ const keys = {
34
+ identitySecretKey: id.secretKey,
35
+ identityPublicKey: id.publicKey,
36
+ transportSecretKey: tr.secretKey,
37
+ transportPublicKey: tr.publicKey,
38
+ deviceId: null,
39
+ deviceToken: null,
40
+ accountName: deviceAccountName(),
41
+ sessionKeys: {},
42
+ outSeq: 0,
43
+ };
44
+ saveKeys(keys, file);
45
+ return keys;
46
+ }
47
+
48
+ // 领取并预留 outSeq 窗口:返回本次会话的起始 seq,并把持久化水位推进 reserve 个。
49
+ // connector 重启/崩溃后,新会话从"旧水位 + reserve"开始 → 新 seq 必高于手机端
50
+ // ReplayGuard 记住的旧水位,事件流不会静默死亡(Kimi Blocker 修复)。
51
+ // 运行中的每次出站都用 nextOutSeq 推进并落盘(_markUsed 已有 500ms 防抖顺带持久化)。
52
+ export function claimOutSeq(keys, file, reserve = 1000) {
53
+ const start = Number(keys.outSeq || 0);
54
+ keys.outSeq = start + reserve;
55
+ saveKeys(keys, file);
56
+ return start;
57
+ }
58
+
59
+ export function saveKeys(keys, file = defaultKeysFile()) {
60
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); // 目录 0700(C2/P2-7)
61
+ fs.writeFileSync(file, JSON.stringify(keys, null, 2), { mode: 0o600 });
62
+ try {
63
+ // C2(P2-7):writeFileSync 的 mode 只对新建文件生效——已存在文件(旧版本/恢复)
64
+ // 覆盖写入保持原权限(可能 0644);每次保存后显式收紧
65
+ fs.chmodSync(file, 0o600);
66
+ fs.chmodSync(path.dirname(file), 0o700);
67
+ } catch {}
68
+ }