dsh-mobile-plugin 0.0.0-stage → 0.1.57

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.
Files changed (87) hide show
  1. package/README.md +128 -2
  2. package/config/agent-presets/standard/agent.cordis.yml +170 -0
  3. package/config/agent-presets/standard/preset.yml +3 -0
  4. package/cordis.mobile-host.yml +48 -0
  5. package/cordis.patch.yml +21 -0
  6. package/dsh-mobile-credentials/lib/credentials.js +135 -0
  7. package/dsh-mobile-credentials/package.json +13 -0
  8. package/host.js +128 -0
  9. package/lib/auto-archive.cjs +98 -0
  10. package/lib/client.js +690 -0
  11. package/lib/desktop-half-stats.cjs +101 -0
  12. package/lib/desktop-view.cjs +109 -0
  13. package/lib/host-guard.cjs +230 -0
  14. package/lib/host-patches.cjs +46 -0
  15. package/lib/http/package.json +3 -0
  16. package/lib/http/routing.js +84 -0
  17. package/lib/impl.js +406 -0
  18. package/lib/mirror/pusher.cjs +706 -0
  19. package/lib/mirror-snapshot.cjs +39 -0
  20. package/lib/mobile/agent-presets.js +182 -0
  21. package/lib/mobile/attachments.js +116 -0
  22. package/lib/mobile/auth.js +78 -0
  23. package/lib/mobile/completion.js +114 -0
  24. package/lib/mobile/credentials.js +150 -0
  25. package/lib/mobile/directory-browse.js +295 -0
  26. package/lib/mobile/envelope.js +51 -0
  27. package/lib/mobile/events.js +293 -0
  28. package/lib/mobile/history-derive.js +608 -0
  29. package/lib/mobile/host-bridge/endpoints.js +189 -0
  30. package/lib/mobile/host-bridge/event-synthesis.js +232 -0
  31. package/lib/mobile/host-bridge/shims.js +218 -0
  32. package/lib/mobile/host-bridge/transport.js +168 -0
  33. package/lib/mobile/host-bridge.js +71 -0
  34. package/lib/mobile/host-settings.js +282 -0
  35. package/lib/mobile/idempotency.js +78 -0
  36. package/lib/mobile/jobs.js +97 -0
  37. package/lib/mobile/local-images.js +153 -0
  38. package/lib/mobile/package.json +3 -0
  39. package/lib/mobile/plugins.js +149 -0
  40. package/lib/mobile/questions.js +71 -0
  41. package/lib/mobile/references.js +209 -0
  42. package/lib/mobile/rpc.js +76 -0
  43. package/lib/mobile/session-cache.js +263 -0
  44. package/lib/mobile/session-goal.js +44 -0
  45. package/lib/mobile/session-log.js +94 -0
  46. package/lib/mobile/session-mirror.js +177 -0
  47. package/lib/mobile/session-models.js +153 -0
  48. package/lib/mobile/session-presets.js +52 -0
  49. package/lib/mobile/session-search.js +65 -0
  50. package/lib/mobile/session-send.js +278 -0
  51. package/lib/mobile/session-snapshot.js +142 -0
  52. package/lib/mobile/sessions.js +462 -0
  53. package/lib/mobile/skills.js +80 -0
  54. package/lib/mobile/stream.js +268 -0
  55. package/lib/mobile/subagents.js +265 -0
  56. package/lib/mobile/workspaces.js +214 -0
  57. package/lib/panel/index.html +33 -0
  58. package/lib/read-receipt.cjs +124 -0
  59. package/lib/reconnect-scheduler.cjs +37 -0
  60. package/lib/relay/core.js +77 -0
  61. package/lib/relay/lan-network.cjs +85 -0
  62. package/lib/relay/lan-proxy.js +593 -0
  63. package/lib/relay/package.json +3 -0
  64. package/lib/relay/pairing-policy.js +45 -0
  65. package/lib/relay/port-allocator.cjs +60 -0
  66. package/lib/relay/preview.js +374 -0
  67. package/lib/relay/protocol.js +177 -0
  68. package/lib/relay/status.js +145 -0
  69. package/lib/relay/tunnel.js +721 -0
  70. package/lib/routes/control-plane.js +311 -0
  71. package/lib/routes/mobile-api.js +208 -0
  72. package/lib/routes/package.json +3 -0
  73. package/lib/session-cleanup.cjs +153 -0
  74. package/lib/shared/credentials-entry.js +48 -0
  75. package/lib/shared/dshtoken.js +62 -0
  76. package/lib/shared/fresh-request.js +35 -0
  77. package/lib/shared/http-util.js +71 -0
  78. package/lib/shared/list-revision.js +51 -0
  79. package/lib/shared/list-signal.js +87 -0
  80. package/lib/shared/package.json +3 -0
  81. package/lib/shared/paths.js +27 -0
  82. package/lib/shared/session-signal.js +110 -0
  83. package/lib/shared/workspace-guard.js +127 -0
  84. package/lib/sidecar-facade-worker.cjs +353 -0
  85. package/lib/sidecar-facade.cjs +141 -0
  86. package/lib/sidecar-lifecycle.cjs +246 -0
  87. package/package.json +74 -4
@@ -0,0 +1,268 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * dsh-mobile 流式:SSE 转发目标会话的增量事件。
5
+ *
6
+ * 订阅宿主事件流,仅转发目标会话的 session/event 帧(增量事件),
7
+ * 每 15s 发一行注释保持连接;客户端断开即取消订阅。
8
+ *
9
+ * 断线恢复:SSE 客户端重连时携带 `Last-Event-ID: <sessionId>:<seq>`(或查询参数
10
+ * `lastSeq=<seq>`)。服务端先在 history 中回放 `seq > lastSeq` 的事件(收敛状态),
11
+ * 再无缝接入实时流,并用 seq 去重避免回放/实时交界处重复交付。由于宿主 history
12
+ * 只支持窗口回放,若断线窗口超过回放上限,客户端仍应在其 SSRD 重连补偿刷新
13
+ * 权威快照(/session/messages、/workspaces)兜底。
14
+ *
15
+ * 生命周期:所有失败路径都必须经 logStream 留痕(守卫 test/stream-liveness.test.cjs
16
+ * 逐条钉着字面量),收尾一定给客户端一帧 `session/resync-required`;keepAlive 定时器
17
+ * 要 unref —— 它只是保活,不该把进程退出按在 15s 一拍上。
18
+ */
19
+
20
+ const crypto = require('crypto');
21
+
22
+ const { sendJson } = require('../shared/http-util');
23
+ const { resolveEventAttachments } = require('./sessions.js');
24
+ const { callSessions } = require('./rpc.js');
25
+ const { unwrap } = require('./envelope.js');
26
+ const { normalizeJobs } = require('./jobs.js');
27
+
28
+ const SSE_RETRY_MS = 3000;
29
+ // Android React Native's XHR may buffer tiny chunked responses until its
30
+ // internal progress threshold. Pad each SSE record so onprogress exposes it
31
+ // promptly; the padding is an SSE comment and is ignored by clients.
32
+ const SSE_FLUSH_PADDING_BYTES = 2048;
33
+ const REPLAY_WINDOW = 3000; // 断线补偿回放的最大事件数
34
+
35
+ /**
36
+ * 可回答的审批请求帧类型。宿主 apiProxy 的 `requestedFrame()` 发的是 mux 帧
37
+ * `approval/requested`(`rpcId` 在帧级、`approvalId` 在 payload);会话事件
38
+ * `approval/asked` 只是同一件事的持久化形态,且**不带** rpcId。
39
+ *
40
+ * 两种形态都必须把帧级 rpcId 透给客户端:`/approval/respond` 的 rpcId 是宿主
41
+ * `pendingApprovals` 表的键,用 approvalId 冒充它会被判 `not-pending` 并回 409
42
+ * 「该审批已完成或不可回答」。旧实现只给 `approval/asked` 合入 rpcId,而宿主
43
+ * 实际发的是 `approval/requested` —— 于是 App 拿到的审批帧永远没有 rpcId。
44
+ */
45
+ const ANSWERABLE_APPROVAL_TYPES = new Set(['approval/requested', 'approval/asked']);
46
+
47
+ /**
48
+ * 诊断日志。这条流过去把三处失败都静默吞掉(keepalive 写失败、mux 循环抛错、
49
+ * res.end 失败),于是「preamble 之后既不产出也不终止」这种状态在宿主侧没有任何
50
+ * 痕迹,只能靠 relay/nginx 侧反推。任何一条异常路径都必须留下可 grep 的行。
51
+ */
52
+ function logStream(sessionId, message, error) {
53
+ const detail = error instanceof Error ? (error.stack || error.message) : (error == null ? '' : String(error));
54
+ try {
55
+ process.stderr.write(`[dsh-mobile-plugin] session/stream ${sessionId} ${message}${detail ? `: ${detail}` : ''}\n`);
56
+ } catch { /* ignore */ }
57
+ }
58
+
59
+ function stableEventId(payload, frame) {
60
+ const source = payload && payload.type === 'session/event' ? payload : frame?.payload;
61
+ if (source && typeof source === 'object') {
62
+ if (source.type === 'session/event' && source.sessionId && source.event && Number.isFinite(source.event.seq)) {
63
+ return `${source.sessionId}:${source.event.seq}`;
64
+ }
65
+ }
66
+ if (payload && typeof payload === 'object') {
67
+ for (const key of ['eventId', 'rpcId', 'id', 'approvalId']) {
68
+ if (typeof payload[key] === 'string' && payload[key]) return payload[key];
69
+ }
70
+ const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : 'session';
71
+ const digest = crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 24);
72
+ return `${sessionId}:${payload.type || 'event'}:${digest}`;
73
+ }
74
+ return frame && typeof frame.rpcId === 'string' && frame.rpcId ? frame.rpcId : undefined;
75
+ }
76
+
77
+ function writeSse(res, payload, frame) {
78
+ const id = stableEventId(payload, frame);
79
+ const fields = [`retry: ${SSE_RETRY_MS}`];
80
+ if (id) fields.push(`id: ${id}`);
81
+ fields.push(`data: ${JSON.stringify(payload)}`);
82
+ fields.push(`: ${' '.repeat(SSE_FLUSH_PADDING_BYTES)}`, '', '');
83
+ try { res.write(`${fields.join('\n')}\n`); return true; } catch { return false; }
84
+ }
85
+
86
+ /**
87
+ * 解析断线续传位置:
88
+ * - `Last-Event-ID: <sessionId>:<seq>`(标准 SSE 头);
89
+ * - 或查询参数 `lastSeq=<seq>`。
90
+ * 返回安全非负整数 seq,缺失/非法返回 null。
91
+ */
92
+ function parseResumeSeq(req, sessionId) {
93
+ const header = req?.headers?.['last-event-id'];
94
+ if (typeof header === 'string' && header) {
95
+ // 只认 `<sessionId>:<seq>` 形式,防止别的 `id:` 误判。
96
+ const m = /^(?:[^:]*):([0-9]+)$/.exec(header.trim());
97
+ if (m) {
98
+ const seq = Number(m[1]);
99
+ if (Number.isSafeInteger(seq) && seq >= 0) return seq;
100
+ }
101
+ }
102
+ const lastSeq = new URL(req.url, 'http://x').searchParams.get('lastSeq');
103
+ if (lastSeq !== null && lastSeq !== '') {
104
+ const seq = Number(lastSeq);
105
+ if (Number.isSafeInteger(seq) && seq >= 0) return seq;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * GET /api/mobile/v1/session/stream?sessionId=…&lastSeq=…(token)
112
+ * SSE:订阅宿主事件流,仅转发目标会话的 session/event 帧(增量事件),
113
+ * 每 15s 发一行注释保持连接;客户端断开即取消订阅。
114
+ */
115
+ async function handleStream(ctx, req, res) {
116
+ const url = new URL(req.url, 'http://x');
117
+ const sessionId = url.searchParams.get('sessionId');
118
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
119
+ try {
120
+ res.writeHead(200, {
121
+ 'content-type': 'text/event-stream; charset=utf-8',
122
+ 'cache-control': 'no-cache',
123
+ connection: 'keep-alive',
124
+ 'x-accel-buffering': 'no',
125
+ 'content-encoding': 'identity',
126
+ });
127
+ } catch (error) { logStream(sessionId, 'writeHead failed', error); return; }
128
+ res.write(': connected\n\n');
129
+ const ac = new AbortController();
130
+ res.on('close', () => ac.abort());
131
+ // 与设备级流(mobile/events.js)对齐:写坏掉的连接不该让 'error' 在 res 上无人接住
132
+ // 而变成未捕获异常,那时 aborted 仍是 false,收尾分支会以为客户端还在、白写一帧 resync。
133
+ res.on('error', () => ac.abort());
134
+ const keepAlive = setInterval(() => {
135
+ try {
136
+ res.write(': keepalive\n\n');
137
+ } catch (error) {
138
+ // 写失败 = 客户端已经不在了。旧实现把它吞掉,interval 继续空转,连接既不
139
+ // 产出也不终止;这里立刻收尾,别把它留在 relay 的 pending 里等超时。
140
+ logStream(sessionId, 'keepalive write failed', error);
141
+ clearInterval(keepAlive);
142
+ ac.abort();
143
+ try { res.end(); } catch { /* ignore */ }
144
+ }
145
+ }, 15_000);
146
+ // 定时器不该拖住进程退出:宿主/worker 关停时若这条 SSE 还挂着,事件循环会被它按在
147
+ // 15s 一拍上,收尾要等到下一次 tick 才发生(lib/sidecar-facade-worker.cjs 的
148
+ // shutdown 就是这么做的)。测试里的 setInterval 桩没有 unref,所以先判类型。
149
+ if (keepAlive && typeof keepAlive.unref === 'function') keepAlive.unref();
150
+
151
+ const resumeSeq = parseResumeSeq(req, sessionId);
152
+ // 已交付的 seq 水位:从续传位置开始,重连时先回放、后接入实时流,并用它去重。
153
+ let lastDeliveredSeq = resumeSeq != null ? resumeSeq : 0;
154
+ // 「客户端已经走了」与「mux 循环正常结束」必须分开:前者不必再回写 resync,
155
+ // 后者必须回写,否则客户端会把一次静默断流当成正常状态一直等下去。
156
+ let clientGone = false;
157
+ let loopEnded = false;
158
+
159
+ try {
160
+ // 断线续传:先回放 history 中 seq > resumeSeq 的事件(收敛状态)。
161
+ if (resumeSeq != null && resumeSeq > 0) {
162
+ const replay = await replaySince(ctx, sessionId, resumeSeq, res, ac.signal, (seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq); });
163
+ if (replay.gap && !ac.signal.aborted) {
164
+ const value = { type: 'session/resync-required', sessionId, resumeSeq, oldestSeq: replay.oldestSeq };
165
+ // 旧实现在这里直接 `return`:既不清 keepAlive 也不 end,漏出一条永不收尾的连接。
166
+ if (!writeSse(res, value, { payload: value })) clientGone = true;
167
+ }
168
+ }
169
+
170
+ if (!clientGone) {
171
+ // payload 里带 sessionId:0.2.0 的宿主没有设备级事件流了,兼容桥据此收窄成
172
+ // 这一条会话的 follow + 投影 + 任务三条流(见 mobile/host-bridge.js 的 muxFrames)。
173
+ // 不带就是设备级视角,只会给工作区基线与全局投影 —— 这里反正只要单会话。
174
+ const frames = ctx.apiProxy.events.mux({ rpcId: 'mobile-stream', payload: { sessionId } }, ac.signal);
175
+ for await (const frame of frames) {
176
+ const p = frame.payload;
177
+ if (!p || p.sessionId !== sessionId) continue;
178
+ // 增量事件保持原 wire:data 仍是原事件 JSON,SSE 元数据独立于 data。
179
+ if (p.type === 'session/event') {
180
+ const seq = p.event && Number.isFinite(p.event.seq) ? p.event.seq : null;
181
+ // 回放已覆盖该 seq(回放与实时交界处)→ 跳过,避免重复交付。
182
+ if (seq !== null && seq <= lastDeliveredSeq) continue;
183
+ const event = await resolveEventAttachments(ctx, sessionId, p.event);
184
+ if (!writeSse(res, event, { ...frame, payload: { ...p, event } })) { clientGone = true; break; }
185
+ if (seq !== null) lastDeliveredSeq = seq;
186
+ } else if (p.type === 'session/queue' || p.type === 'session/projection') {
187
+ // 队列/投影整帧转发;不假定存在可重放的宿主 API。
188
+ if (!writeSse(res, p, frame)) { clientGone = true; break; }
189
+ } else if (p.type === 'session/jobs') {
190
+ // 后台任务整帧(桌面端会话头部任务面板的数据源)。宿主只在任务有变化时发,
191
+ // 且**空数组也要转发** —— 那是「任务都结束了」的唯一信号,丢掉它就永远清不掉。
192
+ const value = { type: 'session/jobs', sessionId, jobs: normalizeJobs(p.jobs) };
193
+ if (!writeSse(res, value, frame)) { clientGone = true; break; }
194
+ } else if (p.type === 'question/requested') {
195
+ // 问题是可回答的 mux server-request,而非 session/event;保留 rpcId 供回答关联。
196
+ const value = { ...p, rpcId: frame.rpcId };
197
+ if (!writeSse(res, value, { ...frame, payload: value })) { clientGone = true; break; }
198
+ } else if (p.type === 'question/resolved' || p.type === 'approval/requested' || p.type === 'approval/asked' || p.type === 'approval/decided' || p.type === 'approval/resolved') {
199
+ // Approval frames are server requests/resolutions, not session/event frames;
200
+ // forward the rpcId and approval identity so the mobile client can answer safely.
201
+ const value = ANSWERABLE_APPROVAL_TYPES.has(p.type) && frame.rpcId ? { ...p, rpcId: frame.rpcId } : p;
202
+ if (!writeSse(res, value, { ...frame, payload: value })) { clientGone = true; break; }
203
+ }
204
+ }
205
+ if (!clientGone) loopEnded = true;
206
+ }
207
+ } catch (error) {
208
+ // 旧实现是 `catch { }`:mux 循环一旦抛错(底层 /api/events.mux 的 HTTP SSE 连接
209
+ // 中断、返回非异步可迭代、signal abort),连接就永远停在 preamble 之后 ——
210
+ // 客户端无限期静默挂起,relay 侧条目也一直挂着,两边都查不出原因。
211
+ logStream(sessionId, 'mux loop threw', error);
212
+ }
213
+ clearInterval(keepAlive);
214
+ try {
215
+ if (!res.writableEnded) {
216
+ // 明确告诉客户端「流到此为止,请带 lastSeq 重连」:它会走已验证可用的 replay
217
+ // 路径补上缺口,而不是把静默断流当成正常状态。
218
+ if (!ac.signal.aborted && !clientGone) {
219
+ writeSse(res, { type: 'session/resync-required', sessionId, oldestSeq: lastDeliveredSeq + 1 });
220
+ }
221
+ res.end();
222
+ logStream(sessionId, `stream closed (loopEnded=${loopEnded}, aborted=${ac.signal.aborted}, clientGone=${clientGone}, deliveredSeq=${lastDeliveredSeq})`, null);
223
+ }
224
+ } catch (error) {
225
+ // 旧实现把 end 失败也吞了 —— 这正是「relay 永远收不到 httpEnd」的直接原因。
226
+ logStream(sessionId, 'stream end failed', error);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * 从会话 history 回放 seq > sinceSeq 的事件(升序),逐个经附件解析后写 SSE。
232
+ * 通过 onDelivered(seq) 回报已交付的最大 seq,供上层去重。
233
+ */
234
+ async function replaySince(ctx, sessionId, sinceSeq, res, signal, onDelivered) {
235
+ try {
236
+ const { value } = unwrap(await callSessions(ctx, 'history', { sessionId, maxMessages: REPLAY_WINDOW }));
237
+ const events = (value && Array.isArray(value.events)) ? value.events : [];
238
+ const seqs = events
239
+ .map((entry) => (entry?.event || entry)?.seq)
240
+ .filter((seq) => Number.isSafeInteger(seq) && seq >= 0);
241
+ const oldestSeq = seqs.length ? Math.min(...seqs) : null;
242
+ // hasMore means the retained history starts before this page. If its first
243
+ // sequence is more than one past the cursor, replay cannot be contiguous.
244
+ const gap = Boolean(value && value.hasMore === true && (oldestSeq === null || oldestSeq - sinceSeq > 1));
245
+ const candidates = events
246
+ .filter((entry) => {
247
+ const e = entry?.event || entry;
248
+ return e && Number.isFinite(e.seq) && e.seq > sinceSeq;
249
+ })
250
+ .sort((a, b) => ((a?.event || a).seq) - ((b?.event || b).seq));
251
+ for (const entry of candidates) {
252
+ if (signal.aborted) break;
253
+ const e = entry?.event || entry;
254
+ const event = await resolveEventAttachments(ctx, sessionId, e);
255
+ // Keep replay data identical to live SSE data: the client reducer consumes
256
+ // the event's own type (turn/start, assistant/message, etc.).
257
+ const payload = { type: 'session/event', sessionId, event };
258
+ if (!writeSse(res, event, { payload })) break;
259
+ if (Number.isFinite(event.seq)) onDelivered(event.seq);
260
+ }
261
+ return { gap, oldestSeq };
262
+ } catch (error) { /* 回放尽力而为;失败不阻塞实时流 */
263
+ logStream(sessionId, 'replay failed', error);
264
+ return { gap: false, oldestSeq: null };
265
+ }
266
+ }
267
+
268
+ module.exports = { handleStream, parseResumeSeq };
@@ -0,0 +1,265 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 移动端「子代理」API:把某个会话派出去的子代理目录转给手机。
5
+ *
6
+ * 桌面端会话标题栏里挂着一个「N 个子代理」下拉(@deepseek-ai/dsh-client-ui-subagent
7
+ * 的 SubagentHeaderLineage):父会话头部按 `origin === "subagent" && parentId ===
8
+ * 当前会话` 把子代理收成一棵树,点进去能读子会话的完整记录。手机端此前把
9
+ * `origin === 'subagent'` 的行在侧栏里直接 continue 掉(app/src/drawerGroups.ts:122),
10
+ * 于是用户既不知道这个会话派过子代理,也没有任何入口能打开它 —— 子代理在手机上
11
+ * 等于不存在。
12
+ *
13
+ * 子会话本身其实早就读得到:/sessions 会带上 origin/parentSessionId,/session 与
14
+ * /session/messages 也能直接读子会话的记录。缺的只是「这个会话有哪些子代理」——
15
+ * 那正是 subagent.list 的返回值(entries + parentAvailable),这里只做形状收敛、
16
+ * 限长与限条数,避免异常宿主把手机端列表撑爆。
17
+ *
18
+ * 宿主 subagent.list 的第二个形参是取消信号(内部 listChildren 会读 signal),
19
+ * 故与 session.search 一样显式传一个 AbortController 的 signal,不裸传 undefined。
20
+ *
21
+ * DSH 0.2.0 没有 subagent.list 这个 RPC 了,目录改由 host-bridge.js 从会话投影的
22
+ * subagentCatalog 重建(形状仍是 entries + parentAvailable),本文件对它的形状收敛、
23
+ * 限长与限条数一字未动。
24
+ */
25
+
26
+ const { sendJson, readJsonBody } = require('../shared/http-util');
27
+ const { unwrap } = require('./sessions.js');
28
+
29
+ const SUBAGENT_MAX_ENTRIES = 100; // 子代理条数上限(正常宿主只有个位数)
30
+ const SUBAGENT_ID_MAX = 200; // 会话 id 长度上限
31
+ const SUBAGENT_LABEL_MAX = 200; // 条目显示名截断长度
32
+ // 0.2.0 的会话投影多了第三种 mode 'unknown':目录说这条子会话存在,但宿主还没读到
33
+ // 它的子代理身份(session/subagent 投影没算出来),所以既不能当成一次性也不能当成
34
+ // 可继续。桌面端对它的处理是当只读 + 显示「模式未知」,这里跟着放行。
35
+ const SUBAGENT_MODES = new Set(['one-shot', 'continuable', 'unknown']);
36
+ const SUBAGENT_ACTIVITIES = new Set(['running', 'inactive']);
37
+ const SUBAGENT_REASONS = new Set(['corrupt', 'unsupported', 'unavailable']);
38
+ const SUBAGENT_PROMPT_MAX_BODY = 256 * 1024; // 续话请求体上限(只有文本,图片已拒)
39
+ const SUBAGENT_PROMPT_MAX_BLOCKS = 32; // content 块数上限
40
+ const SUBAGENT_PROMPT_MAX_TEXT = 64 * 1024; // 单条 text 块字符上限
41
+ const SUBAGENT_INTERRUPT_MAX_BODY = 64 * 1024;
42
+
43
+ /** 会话 id:必须是非空字符串;超长截断(纯防御异常宿主)。 */
44
+ function normalizeId(value) {
45
+ if (typeof value !== 'string') return '';
46
+ const id = value.trim();
47
+ if (!id) return '';
48
+ return id.length > SUBAGENT_ID_MAX ? id.slice(0, SUBAGENT_ID_MAX) : id;
49
+ }
50
+
51
+ /** 显示名:非字符串或空白当「没有」;超长截断补省略号。 */
52
+ function normalizeLabel(value) {
53
+ if (typeof value !== 'string') return undefined;
54
+ const label = value.trim();
55
+ if (!label) return undefined;
56
+ return label.length > SUBAGENT_LABEL_MAX ? `${label.slice(0, SUBAGENT_LABEL_MAX)}…` : label;
57
+ }
58
+
59
+ /**
60
+ * 把 subagent.list 的一条条目压成手机需要的形状;坏条目直接丢掉。
61
+ *
62
+ * 两种 kind 的必填字段不同:child 要 mode/activity/hasChildren 都合法才留,
63
+ * diagnostic 只要有 id + 认识的 reason。child 的 label 保持可选(宿主对
64
+ * continuable 才保证有),App 侧缺省回落到 id —— 与桌面端 `entry.label ?? entry.id`
65
+ * 一致,猜一个假名字反而会误导用户。
66
+ */
67
+ function normalizeSubagentEntry(item) {
68
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
69
+ const id = normalizeId(item.id);
70
+ if (!id) return null;
71
+
72
+ if (item.kind === 'diagnostic') {
73
+ if (!SUBAGENT_REASONS.has(item.reason)) return null;
74
+ return { kind: 'diagnostic', id, reason: item.reason };
75
+ }
76
+
77
+ if (item.kind !== 'child') return null;
78
+ if (!SUBAGENT_MODES.has(item.mode)) return null;
79
+ if (!SUBAGENT_ACTIVITIES.has(item.activity)) return null;
80
+ if (typeof item.hasChildren !== 'boolean') return null;
81
+
82
+ const label = normalizeLabel(item.label);
83
+ return {
84
+ kind: 'child',
85
+ id,
86
+ mode: item.mode,
87
+ activity: item.activity,
88
+ hasChildren: item.hasChildren,
89
+ ...(label === undefined ? {} : { label }),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * 收敛 subagent.list 的 value:非数组当空目录,坏条目丢掉,按 id 去重(保留先出现的),
95
+ * 再截到上限。parentAvailable 用严格比较 —— 宿主漏字段时按「父会话不在线」处理,
96
+ * 与桌面端「父会话不在线 → 只读」的判定方向一致,猜真值会让手机端误以为还能发消息。
97
+ */
98
+ function normalizeSubagentList(value) {
99
+ const raw = value && typeof value === 'object' && Array.isArray(value.entries) ? value.entries : [];
100
+ const entries = [];
101
+ const seen = new Set();
102
+ for (const item of raw) {
103
+ const entry = normalizeSubagentEntry(item);
104
+ if (!entry || seen.has(entry.id)) continue;
105
+ seen.add(entry.id);
106
+ entries.push(entry);
107
+ if (entries.length >= SUBAGENT_MAX_ENTRIES) break;
108
+ }
109
+ return { entries, parentAvailable: Boolean(value && value.parentAvailable === true) };
110
+ }
111
+
112
+ /** GET /api/mobile/v1/subagents?sessionId=… —— 这个会话派出去的子代理目录(只读)。 */
113
+ async function handleSubagents(ctx, req, res) {
114
+ const url = new URL(req.url, 'http://x');
115
+ const sessionId = (url.searchParams.get('sessionId') || '').trim();
116
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 sessionId' } });
117
+
118
+ const controller = new AbortController();
119
+ // 注意命名空间是复数 subagents(apiProxy.subagents.list),而 RPC 方法名是单数
120
+ // subagent.list —— 宿主两处不对称,写错只会得到 "Cannot read properties of
121
+ // undefined (reading 'list')" 这种 500,所以这里别按方法名顺手改成单数。
122
+ const { value, error } = unwrap(await ctx.apiProxy.subagents.list({
123
+ rpcId: 'mobile',
124
+ method: 'subagent.list',
125
+ payload: { parentSessionId: sessionId },
126
+ }, controller.signal));
127
+ if (error) return sendJson(res, 400, { ok: false, error });
128
+
129
+ sendJson(res, 200, { ok: true, ...normalizeSubagentList(value) });
130
+ }
131
+
132
+ /** 从请求体里取一个必填的会话 id(trim 后仍为空就当没给)。 */
133
+ function readId(body, key) {
134
+ const value = body && body[key];
135
+ return typeof value === 'string' ? value.trim() : '';
136
+ }
137
+
138
+ /**
139
+ * 把续话请求的 content 收敛成宿主 subagent.prompt 认的形状。
140
+ *
141
+ * 与 /session/send 同一套规则,但**图片一律拒绝**:桌面端在路由之前就把带 image 的
142
+ * 续话判成 attachment-error / SUBAGENT_IMAGE_UNSUPPORTED(runtime 的 subagent 分支
143
+ * 只把 text 块 flatMap 出去,图片根本到不了宿主)。这里跟着拒,免得静默丢块让用户
144
+ * 以为图发出去了。
145
+ */
146
+ function normalizePromptContent(body) {
147
+ let content;
148
+ if (Array.isArray(body.content)) content = body.content;
149
+ else if (typeof body.text === 'string' && body.text.trim()) content = [{ type: 'text', text: body.text }];
150
+ else return { error: { code: 'bad-request', message: '需要 content 数组或 text' } };
151
+
152
+ if (content.length === 0) return { error: { code: 'bad-request', message: 'content 不能为空' } };
153
+ if (content.length > SUBAGENT_PROMPT_MAX_BLOCKS) {
154
+ return { error: { code: 'bad-request', message: `content 一次最多 ${SUBAGENT_PROMPT_MAX_BLOCKS} 块` } };
155
+ }
156
+
157
+ const blocks = [];
158
+ for (const block of content) {
159
+ if (!block || typeof block.type !== 'string') {
160
+ return { error: { code: 'bad-request', message: 'content 块缺少 type' } };
161
+ }
162
+ if (block.type === 'image') {
163
+ return {
164
+ error: {
165
+ code: 'attachment-error',
166
+ message: '子代理续话不支持图片输入。',
167
+ details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
168
+ },
169
+ };
170
+ }
171
+ if (block.type !== 'text') {
172
+ return { error: { code: 'bad-request', message: `不支持的 content 块类型: ${block.type}` } };
173
+ }
174
+ if (typeof block.text !== 'string' || !block.text.trim()) {
175
+ return { error: { code: 'bad-request', message: 'text 块内容为空' } };
176
+ }
177
+ if (block.text.length > SUBAGENT_PROMPT_MAX_TEXT) {
178
+ return { error: { code: 'bad-request', message: `text 块最长 ${SUBAGENT_PROMPT_MAX_TEXT} 个字符` } };
179
+ }
180
+ blocks.push({ type: 'text', text: block.text });
181
+ }
182
+ return { blocks };
183
+ }
184
+
185
+ /**
186
+ * POST /api/mobile/v1/subagents/prompt —— 给「可继续」子代理续一条消息。
187
+ *
188
+ * 手机端的输入框平时只会发 session.send,而宿主对子会话的 session.prompt /
189
+ * session.models 一律回 agent-busy("use subagent delivery for this child session"),
190
+ * 所以续话必须走 subagent.prompt。mode 固定 continuable:一次性子代理宿主压根
191
+ * 不接受续话(桌面端也是先判 one-shot 再拒绝),这里不给客户端挑的机会。
192
+ *
193
+ * 宿主还要求父会话「在线」(ctx.agents.get(parentSessionId) 有值),否则回
194
+ * subagent-parent-unavailable —— 这一条原样透传给 App,由它决定提示什么。
195
+ */
196
+ async function handleSubagentPrompt(ctx, req, res) {
197
+ const body = await readJsonBody(req, SUBAGENT_PROMPT_MAX_BODY);
198
+ const parentSessionId = readId(body, 'parentSessionId');
199
+ const childSessionId = readId(body, 'childSessionId');
200
+ if (!parentSessionId || !childSessionId) {
201
+ return sendJson(res, 400, {
202
+ ok: false,
203
+ error: { code: 'bad-request', message: '需要 { parentSessionId, childSessionId, content | text }' },
204
+ });
205
+ }
206
+ const normalized = normalizePromptContent(body);
207
+ if (normalized.error) return sendJson(res, 400, { ok: false, error: normalized.error });
208
+
209
+ const clientTimeZone = typeof body.clientTimeZone === 'string' && body.clientTimeZone.trim()
210
+ ? body.clientTimeZone.trim()
211
+ : undefined;
212
+
213
+ const controller = new AbortController();
214
+ const { error } = unwrap(await ctx.apiProxy.subagents.prompt({
215
+ rpcId: 'mobile',
216
+ method: 'subagent.prompt',
217
+ payload: {
218
+ parentSessionId,
219
+ childSessionId,
220
+ mode: 'continuable',
221
+ content: normalized.blocks,
222
+ ...(clientTimeZone === undefined ? {} : { clientTimeZone }),
223
+ },
224
+ }, controller.signal));
225
+ if (error) return sendJson(res, 400, { ok: false, error });
226
+
227
+ // 宿主回的是 { messageId },但手机端自己生成乐观行的 clientMessageId,用不上它。
228
+ sendJson(res, 200, { ok: true, accepted: true });
229
+ }
230
+
231
+ /**
232
+ * POST /api/mobile/v1/subagents/interrupt —— 打断可继续子代理当前这一轮。
233
+ *
234
+ * 宿主的 subagents.interrupt(request) 只声明了一个形参(与 prompt 不同),所以这里
235
+ * 不传 signal —— 多传一个参数不会报错,但会让人误以为它有取消语义。
236
+ */
237
+ async function handleSubagentInterrupt(ctx, req, res) {
238
+ const body = await readJsonBody(req, SUBAGENT_INTERRUPT_MAX_BODY);
239
+ const parentSessionId = readId(body, 'parentSessionId');
240
+ const childSessionId = readId(body, 'childSessionId');
241
+ if (!parentSessionId || !childSessionId) {
242
+ return sendJson(res, 400, {
243
+ ok: false,
244
+ error: { code: 'bad-request', message: '需要 { parentSessionId, childSessionId }' },
245
+ });
246
+ }
247
+
248
+ const { error } = unwrap(await ctx.apiProxy.subagents.interrupt({
249
+ rpcId: 'mobile',
250
+ method: 'subagent.interrupt',
251
+ payload: { parentSessionId, childSessionId, mode: 'continuable' },
252
+ }));
253
+ if (error) return sendJson(res, 400, { ok: false, error });
254
+
255
+ sendJson(res, 200, { ok: true, accepted: true });
256
+ }
257
+
258
+ module.exports = {
259
+ handleSubagents,
260
+ handleSubagentPrompt,
261
+ handleSubagentInterrupt,
262
+ normalizeSubagentEntry,
263
+ normalizeSubagentList,
264
+ normalizePromptContent,
265
+ };