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,462 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 移动端会话 API 的**会话列表**与**新建会话**。
5
+ *
6
+ * 本文件一度是 1964 行的单体,HTTP 路由、宿主 RPC、缓存、事件派生全混在一起;而且
7
+ * 它是 lib/mobile 下被引用最多的模块——stream / credentials / skills / subagents /
8
+ * workspaces / host-settings 全都从它取 unwrap、callSessions、缓存访问器,于是它成了
9
+ * 事实上的公共工具库,谁都不好动。现在按域拆开:
10
+ *
11
+ * envelope.js 信封拆解(unwrap / envelopeValue),唯一的实现
12
+ * rpc.js 对宿主 apiProxy 的窄接口封装(callSessions / callAgentPresets)
13
+ * session-cache.js 按 ctx 隔离的八张状态表,全部 WeakMap 集中在这里
14
+ * attachments.js 图片附件还原
15
+ * completion.js 完成提醒状态机
16
+ * session-models.js 模型目录与切模型
17
+ * session-search.js 正文检索
18
+ * session-send.js 发消息 / slash 命令 / 读 / 停 / 改名 / 分叉
19
+ * session-snapshot.js 消息与统计快照(镜像与分页共用)
20
+ * session-mirror.js 镜像整帧构建
21
+ * session-goal.js 目标条动作
22
+ * session-presets.js Agent 预设目录
23
+ *
24
+ * 剩下的就是列表:GET /sessions(10 秒缓存 + fresh 绕过 + 完成态确认)、GET /session、
25
+ * GET /session/stats、新建会话、排队消息动作。**本文件继续从各子模块转出导出**,
26
+ * 所以 lib/mobile 下所有 import 路径一行都不用改。
27
+ */
28
+
29
+ const { sendJson, readJsonBody } = require('../shared/http-util');
30
+ const { isFreshRequest } = require('../shared/fresh-request');
31
+ const { listRevision } = require('../shared/list-revision.js');
32
+ const { withIdempotency, requestIdOf } = require('./idempotency.js');
33
+ const { listTitle, deriveMessages, lastMessageOf, promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
34
+ const { unwrap } = require('./envelope.js');
35
+ const { callSessions } = require('./rpc.js');
36
+ const {
37
+ canonicalDeviceKey, sessionListResponseState, invalidateSessionList,
38
+ sessionPreviewStateOf, sessionPreviewInFlightStateOf, sessionPreviewClockOf,
39
+ completionGenerationOf, bumpCompletionGeneration,
40
+ completionStateOf, sessionModelsStateOf, invalidateSessionModels, sendIdempotencyStateOf,
41
+ } = require('./session-cache.js');
42
+ const { resolveEventAttachments } = require('./attachments.js');
43
+ const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
44
+ const { buildMessagesSnapshot, buildStatsSnapshot, statsProjections, handleMessages } = require('./session-snapshot.js');
45
+ const { safeModelsValue, buildModelsSnapshot, handleModels, handleSelectModel } = require('./session-models.js');
46
+ const { buildSingleSessionMirror, buildMirrorWarmSnapshots } = require('./session-mirror.js');
47
+ const {
48
+ promptOnce, handleSend, handleCommands, handleSessionRead,
49
+ handleStop, handleRename, handleFork,
50
+ } = require('./session-send.js');
51
+ const { handleSessionSearch } = require('./session-search.js');
52
+ const { handleAgentPresets } = require('./session-presets.js');
53
+ const { handleGoalAction } = require('./session-goal.js');
54
+
55
+ const HISTORY_FOR_ENRICH = 10; // 补标题/预览时拉取的消息条数(低到足够取最后一条预览,避免每次重活)
56
+ const SESSIONS_PREVIEW_LIMIT = 20; // 仅为最近/运行中的会话补最后消息预览
57
+ const SESSION_LIST_CACHE_MS = 10000; // 会话列表增料缓存(降低手机轮询对桌面的冲击)
58
+ /**
59
+ * 运行中会话的预览重读下限。
60
+ *
61
+ * 运行中的会话 updatedAt 几乎每轮都在变,`sessionPreviewFingerprint` 于是每轮都变,
62
+ * 10 秒的列表缓存一到期就要为每个运行中会话重读一次 history(HISTORY_FOR_ENRICH 条)。
63
+ * 一轮生成里手机侧 15s 轮询会反复撞上「指纹变了但预览内容其实没变」的中间态。
64
+ * 这里给运行中会话单独加一个更短的窗口:窗口内复用上一次预览,不重读日志。
65
+ * 非运行中的会话仍走指纹判定(它们的 updatedAt 不变,指纹不变,本来就不会重读)。
66
+ */
67
+ const RUNNING_PREVIEW_MIN_INTERVAL_MS = 3000;
68
+
69
+ /**
70
+ * 宿主的归档会话集合(`workspace.list` 的 `archivedSessionIds`)。
71
+ *
72
+ * 桌面端侧边栏据此把归档会话藏起来,而 `sessions.list` 自身**不过滤**归档,
73
+ * 所以移动端必须自己读这个集合:此前完全没读,App 的「归档会话」写是写成功了
74
+ * (宿主确实把 id 记进了 archivedSessionIds),列表却照旧把那一行发回手机,
75
+ * 用户看到的就是一次毫无反馈的空操作。
76
+ *
77
+ * 取不到时返回空集合(失败放开):宁可多显示一行,也不能因为一次 RPC 抖动
78
+ * 让整个会话列表凭空变空。
79
+ */
80
+ async function archivedSessionIdSet(ctx) {
81
+ try {
82
+ const resp = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
83
+ const value = unwrap(resp).value;
84
+ const ids = value && Array.isArray(value.archivedSessionIds) ? value.archivedSessionIds : [];
85
+ return new Set(ids.filter(id => typeof id === 'string'));
86
+ } catch {
87
+ return new Set();
88
+ }
89
+ }
90
+
91
+ function sessionPreviewFingerprint(summary) {
92
+ return `${summary.updatedAt || 0}:${summary.running ? 'running' : 'idle'}:${summary.blank ? 'blank' : 'content'}`;
93
+ }
94
+
95
+ /** Read one preview at most once per unchanged session revision. */
96
+ async function sessionPreviewFor(ctx, summary) {
97
+ const sessionId = summary.sessionId;
98
+ const fingerprint = sessionPreviewFingerprint(summary);
99
+ const cached = sessionPreviewStateOf(ctx).get(sessionId);
100
+ if (cached?.fingerprint === fingerprint) return { events: [], lastMessage: cached.lastMessage };
101
+
102
+ const inFlight = sessionPreviewInFlightStateOf(ctx);
103
+ const existing = inFlight.get(sessionId);
104
+ if (existing?.fingerprint === fingerprint) return existing.promise;
105
+
106
+ // 运行中会话的节流(见 RUNNING_PREVIEW_MIN_INTERVAL_MS):窗口内复用上一次预览,
107
+ // 不为「updatedAt 又动了一下」再读一遍日志。返回空 events 与 cache-hit 分支一致
108
+ // ——完成态确认只在真正读回历史时进行,本轮不复用它的边沿判定。
109
+ const clock = sessionPreviewClockOf(ctx);
110
+ if (cached && summary.running && Date.now() - (clock.get(sessionId) || 0) < RUNNING_PREVIEW_MIN_INTERVAL_MS) {
111
+ return { events: [], lastMessage: cached.lastMessage };
112
+ }
113
+ clock.set(sessionId, Date.now());
114
+
115
+ const promise = Promise.resolve(callSessions(ctx, 'history', {
116
+ sessionId,
117
+ maxMessages: HISTORY_FOR_ENRICH,
118
+ })).then((response) => {
119
+ const h = unwrap(response);
120
+ if (h.error) return { events: [], lastMessage: null };
121
+ const events = (h.value && h.value.events) || [];
122
+ const messages = deriveMessages(events);
123
+ const last = lastMessageOf(messages);
124
+ const lastMessage = last ? { role: last.role, text: last.text.slice(0, 200), at: last.time } : null;
125
+ sessionPreviewStateOf(ctx).set(sessionId, { fingerprint, lastMessage });
126
+ return { events, lastMessage };
127
+ }).finally(() => {
128
+ if (inFlight.get(sessionId)?.promise === promise) inFlight.delete(sessionId);
129
+ });
130
+ inFlight.set(sessionId, { fingerprint, promise });
131
+ return promise;
132
+ }
133
+
134
+ /** GET /api/mobile/v1/sessions(token) */
135
+ async function handleSessions(ctx, req, res) {
136
+ const deviceKey = canonicalDeviceKey(req);
137
+ let generation = completionGenerationOf(ctx, deviceKey);
138
+ const cache = sessionListResponseState(ctx, deviceKey);
139
+ const now = Date.now();
140
+ // 写操作之后的刷新带 ?fresh=1:跳过这层 10s 缓存、在途构建与宿主 list 的合流,直接按最新状态重建。
141
+ const wantFresh = isFreshRequest(req.url);
142
+ if (wantFresh) {
143
+ // 顺手作废更早发起的构建:它们手里的数据是写入之前的,若在新结果之后才落地会把旧列表又写回缓存。
144
+ cache.value = null;
145
+ cache.updatedAt = 0;
146
+ generation = bumpCompletionGeneration(ctx, deviceKey);
147
+ }
148
+ if (!wantFresh && cache.value && now - cache.updatedAt < SESSION_LIST_CACHE_MS) {
149
+ return sendJson(res, 200, cache.value);
150
+ }
151
+
152
+ if (!wantFresh && cache.promise) {
153
+ const response = await cache.promise;
154
+ return sendJson(res, response.status, response.body);
155
+ }
156
+
157
+ // 变更信号(镜像推送调度器先看见清单变了)会调 dropSessionListCache 丢掉这层缓存,
158
+ // 但「丢掉」挡不住**已经在飞**的那次构建:它手里是变更之前的数据,落地后会在缓存里
159
+ // 再活 10 秒。用缓存自带的朝代号兜住(见 session-cache.js 的 dropSessionListCache)。
160
+ const epoch = cache.droppedAt || 0;
161
+ const promise = buildSessionsResponse(ctx, deviceKey, generation, wantFresh);
162
+ cache.promise = promise;
163
+ try {
164
+ const response = await promise;
165
+ if (response.status === 200 && completionGenerationOf(ctx, deviceKey) === generation
166
+ && (cache.droppedAt || 0) === epoch) {
167
+ // 含「运行中」行的清单不进 10 秒缓存:这些行的 completed / lastTurnError 要等预览
168
+ // 把 turn/end 折出来才定稿,缓存会把「刚失败 / 刚跑完」的那一份多留一整个轮询
169
+ // 周期。15 秒轮询本来就跨过 10 秒窗口,这层缓存的收益只体现在突发刷新上。
170
+ const hasRunning = Array.isArray(response.body && response.body.items)
171
+ && response.body.items.some((item) => item && item.running);
172
+ if (!hasRunning) {
173
+ cache.value = response.body;
174
+ cache.updatedAt = Date.now();
175
+ }
176
+ }
177
+ return sendJson(res, response.status, response.body);
178
+ } finally {
179
+ if (cache.promise === promise) cache.promise = null;
180
+ }
181
+ }
182
+
183
+ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = completionGenerationOf(ctx, deviceKey), fresh = false) {
184
+ const { value, error } = unwrap(await callSessions(ctx, 'list', {}, 'mobile', undefined, { fresh }));
185
+ if (error) return { status: 400, body: { ok: false, error } };
186
+ // 形状不对 = 没取到,不是「你一个会话都没有」。旧实现 `(value.items || [])` 把
187
+ // 任何缺 items 的响应变成 200 空列表:下游(App 提交、镜像推送、中转响应缓存)
188
+ // 都会把它当权威数据,把好的会话清单整个覆盖掉。
189
+ // 用 500 而不是 400:中转对 >=500 的小响应会退回上一份好副本(stale:true),
190
+ // 手机上因此表现为「短暂用旧列表」而不是「列表清空」。
191
+ const summaries = Array.isArray(value) ? value : (value && Array.isArray(value.items) ? value.items : null);
192
+ if (summaries === null) {
193
+ return {
194
+ status: 500,
195
+ body: {
196
+ ok: false,
197
+ error: { code: 'host-session-list-degraded', message: '宿主会话列表形状异常:响应里没有 items 数组' },
198
+ },
199
+ };
200
+ }
201
+ // 归档会话在这里剔除,与桌面端侧边栏行为一致:宿主 sessions.list 自己不过滤归档,
202
+ // 「哪些会话已归档」只存在于 workspaceRegistry.archivedSessionIds(见 archivedSessionIdSet)。
203
+ const archived = await archivedSessionIdSet(ctx);
204
+ const allSummaries = summaries.filter(s => s && !archived.has(s.sessionId));
205
+ // 状态跟踪覆盖完整列表,不能受预览补全上限影响。
206
+ const completionStatesForContext = updateCompletionStates(ctx, allSummaries, deviceKey);
207
+ // 列表完整返回:侧边栏分组依赖全部会话(工作区 sessionIds 排序 + 未分组兜底),
208
+ // 截断会让工作区/项目下的会话凭空消失。标题直接取 list 摘要的 title 投影
209
+ // (与桌面端侧栏同一来源,正式 session/title 事件折叠,绝不用对话正文冒充标题),
210
+ // 该项是廉价字段;history 仅用于补「最后消息预览」,属于重活,见下方预览子集。
211
+ const rows = allSummaries.map((s) => {
212
+ const completionState = completionStatesForContext.get(s.sessionId);
213
+ const base = {
214
+ sessionId: s.sessionId,
215
+ title: listTitle(s),
216
+ running: !!s.running,
217
+ ...(s.pendingInteraction === 'approval' || s.pendingInteraction === 'plan-review' || s.pendingInteraction === 'question'
218
+ ? { pendingInteraction: s.pendingInteraction }
219
+ : {}),
220
+ completed: !!completionState?.completed,
221
+ // 上一轮以错误收尾(由 completion.js 从 history 的 turn/end reason.kind 折出)。
222
+ // 侧栏据此画红点:completed 只说「跑完了、待阅读」,说不了「跑挂了」。字段只在
223
+ // 真有失败时出现,旧 App 忽略未知字段、行为不变。
224
+ ...(completionState?.failure ? { lastTurnError: completionState.failure } : {}),
225
+ blank: !!s.blank,
226
+ cwd: s.cwd || null,
227
+ // 会话定档的 Agent 预设(宿主 session.list 摘要里带,用来显示会话当前预设)。
228
+ ...(typeof s.agentPreset === 'string' && s.agentPreset ? { agentPreset: s.agentPreset } : {}),
229
+ updatedAt: s.updatedAt || 0,
230
+ lastMessage: null,
231
+ ...(s.origin === 'subagent' ? { origin: s.origin } : {}),
232
+ ...(typeof s.parentSessionId === 'string' ? { parentSessionId: s.parentSessionId } : {}),
233
+ };
234
+ return { summary: s, base };
235
+ });
236
+ // 预览子集:运行中优先(完成边沿确认不能缺),其次最近活跃;数量受
237
+ // SESSIONS_PREVIEW_LIMIT 限制,避免会话多时每次列表请求都全量读历史日志。
238
+ const previewCandidates = rows
239
+ .slice()
240
+ .sort((a, b) =>
241
+ (b.summary.running ? 1 : 0) - (a.summary.running ? 1 : 0)
242
+ || (b.summary.updatedAt || 0) - (a.summary.updatedAt || 0))
243
+ .slice(0, SESSIONS_PREVIEW_LIMIT);
244
+ await Promise.all(previewCandidates.map(async ({ summary: s, base }) => {
245
+ try {
246
+ const preview = await sessionPreviewFor(ctx, s);
247
+ const events = preview.events;
248
+ const completionState = completionStatesForContext.get(s.sessionId);
249
+ if (completionGenerationOf(ctx, deviceKey) === generation) {
250
+ confirmCompletionFromHistory(completionState, events, !!s.running);
251
+ }
252
+ // Only expose completion after the matching turn/end and final
253
+ // assistant/message are both present in the same history snapshot.
254
+ base.completed = !!completionState?.completed;
255
+ // 失败标记同样只在这一步之后才知道(上面构造行时它还是上一份快照的值)。
256
+ // 必须在这里同步一次:否则「失败被折出来的那一次响应」里 completed 已经是
257
+ // false、lastTurnError 却还缺着,侧栏会出现一个什么都不显示的间隔(既不是进行中、
258
+ // 也没有红点),要等下一轮轮询才亮 —— 那正是这个补丁要消灭的「静默」。
259
+ if (completionState?.failure) base.lastTurnError = completionState.failure;
260
+ else delete base.lastTurnError;
261
+ base.lastMessage = preview.lastMessage;
262
+ } catch {
263
+ // 预览失败不影响标题与行数据。
264
+ }
265
+ }));
266
+ const items = rows.map((r) => r.base);
267
+ const generatedAt = Date.now();
268
+ return {
269
+ status: 200,
270
+ body: {
271
+ ok: true,
272
+ items,
273
+ // 数据新鲜度:App 把这两个字段连同快照一起存下来,就能把「这份数据是谁在什么
274
+ // 时候给的」显示给用户,而不是只给一个「刚刚同步过」的客户端时间。revision 是
275
+ // 内容指纹(跨插件重启稳定),App 也用它判断一次变更信号是否真的带来了新内容。
276
+ generatedAt,
277
+ revision: listRevision(items),
278
+ // 显式空态:形状降级在上面的 500 分支里已被挡掉,能走到这里说明宿主回了一份
279
+ // 合法清单 —— 「空」在这一点上是可断言的契约,不再需要 App 连续两次空去猜。
280
+ // 下游(空清单守卫 / 中转缓存 / App 的空态判定)据此放行真实的「清空」。
281
+ emptyConfirmed: items.length === 0,
282
+ },
283
+ };
284
+ }
285
+
286
+ /** GET /api/mobile/v1/session?sessionId=…(token) */
287
+ async function handleSession(ctx, req, res) {
288
+ const sessionId = new URL(req.url, 'http://x').searchParams.get('sessionId');
289
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
290
+ const { value, error } = unwrap(await callSessions(ctx, 'history', { sessionId, maxMessages: HISTORY_FOR_ENRICH }));
291
+ if (error) return sendJson(res, 400, { ok: false, error });
292
+ const events = (value && value.events) || [];
293
+ const messages = deriveMessages(events);
294
+ const last = lastMessageOf(messages);
295
+ // 标题与 cwd 从会话列表摘要取(标题用 title 投影,与桌面端一致;
296
+ // history 的 30 条窗口可能截掉标题事件,不能作为标题来源)。
297
+ // blank / agentPreset 同源:App 据此决定 Agent 预设芯片是「可切」还是「已锁死」。
298
+ let cwd = null;
299
+ let title = '';
300
+ let blank = false;
301
+ let agentPreset;
302
+ try {
303
+ const list = unwrap(await callSessions(ctx, 'list', {}));
304
+ const s = (list.value && list.value.items || []).find((x) => x.sessionId === sessionId);
305
+ if (s) {
306
+ if (typeof s.cwd === 'string' && s.cwd) cwd = s.cwd;
307
+ title = listTitle(s);
308
+ blank = !!s.blank;
309
+ if (typeof s.agentPreset === 'string' && s.agentPreset) agentPreset = s.agentPreset;
310
+ }
311
+ } catch { /* cwd/title/blank/agentPreset 可选 */ }
312
+ sendJson(res, 200, {
313
+ ok: true,
314
+ session: {
315
+ sessionId,
316
+ cwd,
317
+ title,
318
+ blank,
319
+ ...(agentPreset === undefined ? {} : { agentPreset }),
320
+ lastSeq: events.length ? (eventSeqOf(events[events.length - 1]) ?? 0) : 0,
321
+ lastMessage: last ? { role: last.role, text: last.text.slice(0, 200), at: last.time } : null,
322
+ },
323
+ });
324
+ }
325
+
326
+ /**
327
+ * GET /api/mobile/v1/session/stats?sessionId=…(token)
328
+ * 透传宿主 session.history 尾页的 projections(tokenUsage / sessionStats /
329
+ * contextPressure / contextBreakdown),供移动端渲染上下文占用圆环与统计行。
330
+ */
331
+ async function handleStats(ctx, req, res) {
332
+ const url = new URL(req.url, 'http://x');
333
+ const sessionId = url.searchParams.get('sessionId');
334
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } });
335
+ const stats = await statsProjections(ctx, sessionId);
336
+ if (!stats.ok) return sendJson(res, 400, { ok: false, error: stats.error });
337
+ sendJson(res, 200, { ok: true, projections: stats.projections });
338
+ }
339
+
340
+ /**
341
+ * POST /api/mobile/v1/session/new(token){ workspaceId? | cwd? } → { ok, sessionId }
342
+ * 新建一个空白会话。优先 workspaceId:宿主把新会话 attach 到该项目(workspace)
343
+ * 的 sessionIds 账上,桌面端侧栏项目下立即可见;无 workspaceId 时回退 cwd
344
+ * (不传则归属其默认/未分组工作区)。
345
+ * 走宿主 apiProxy 的 session.create(接受 workspaceId 或 cwd,二者不能同时给)。
346
+ */
347
+ async function handleNew(ctx, req, res) {
348
+ const body = await readJsonBody(req, 64 * 1024);
349
+ // 新建会话是**非幂等**写:同一发请求被重投一次就会凭空多一个会话。requestId 由
350
+ // App 在构造请求头时生成一次,内核换通道重投复用同一个 init(同一个头),重投于是
351
+ // 命中这里、直接拿回第一次的 sessionId(见 mobile/idempotency.js)。
352
+ const outcome = await withIdempotency(ctx, canonicalDeviceKey(req), requestIdOf(req, body), async () => {
353
+ let payload = {};
354
+ const workspaceId = body && typeof body.workspaceId === 'string' && body.workspaceId.trim()
355
+ ? body.workspaceId.trim()
356
+ : '';
357
+ if (workspaceId) {
358
+ payload.workspaceId = workspaceId;
359
+ } else if (body && typeof body.cwd === 'string' && body.cwd.trim()) {
360
+ payload.cwd = body.cwd.trim();
361
+ }
362
+ // 宿主 session.create 接受 agentPreset:新建时直接定档,省掉「先建再 select」的
363
+ // 两步(select 只在会话还没开跑时生效,新建后立刻 select 同样可行但多一次 RPC)。
364
+ if (body && typeof body.agentPreset === 'string' && body.agentPreset.trim()) {
365
+ payload.agentPreset = body.agentPreset.trim();
366
+ }
367
+ const { value, error } = unwrap(await callSessions(ctx, 'create', payload));
368
+ if (error) return { status: 400, body: { ok: false, error } };
369
+ const sessionId = value && typeof value.sessionId === 'string' ? value.sessionId : null;
370
+ if (!sessionId) {
371
+ return { status: 500, body: { ok: false, error: { code: 'internal', message: '新建会话未返回 sessionId' } } };
372
+ }
373
+ // 创建响应携带宿主当前选择;APP 可立即显示,完整目录仍可后台刷新。
374
+ let models;
375
+ try {
376
+ const result = unwrap(await callSessions(ctx, 'models', { sessionId }));
377
+ if (!result.error) models = safeModelsValue(result.value);
378
+ } catch { /* 模型目录不可用时仍允许创建会话 */ }
379
+ // 新建的会话要立刻出现在列表里,否则手机端最长 10 秒看不到它。
380
+ invalidateSessionList(ctx);
381
+ return {
382
+ status: 200,
383
+ body: {
384
+ ok: true,
385
+ sessionId,
386
+ cwd: payload.cwd || null,
387
+ ...(value && typeof value.agentPreset === 'string' ? { agentPreset: value.agentPreset } : {}),
388
+ ...(models?.current ? { models } : {}),
389
+ },
390
+ };
391
+ });
392
+ sendJson(res, outcome.status, outcome.body);
393
+ }
394
+
395
+ /**
396
+ * POST /api/mobile/v1/session/queue/update(token){ sessionId, itemId, action }
397
+ * 排队消息条动作透传:edit(content) / remove / steer(插话发送,仅运行中)。
398
+ * action: { kind:'edit', content:[{type:'text',text}] } | { kind:'remove' } | { kind:'steer' }。
399
+ */
400
+ async function handleQueueUpdate(ctx, req, res) {
401
+ const body = await readJsonBody(req, 256 * 1024);
402
+ const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId : '';
403
+ const itemId = body && typeof body.itemId === 'string' ? body.itemId : '';
404
+ const action = body && body.action;
405
+ if (!sessionId || !itemId || !action || typeof action.kind !== 'string') {
406
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, itemId, action }' } });
407
+ }
408
+ const { value, error } = unwrap(await callSessions(ctx, 'updateQueue', { sessionId, itemId, action }));
409
+ if (error) return sendJson(res, 400, { ok: false, error });
410
+ sendJson(res, 200, { ok: true, ...(value || {}) });
411
+ }
412
+
413
+ // 下面这一组从 session-cache.js 转出:它们此前是本文件的模块级 WeakMap 访问器,
414
+ // 而 sessions.js 是 mobile/ 下被引用最多的模块(测试与其它 handler 都从这里取)。
415
+ // 换实现不改路径。
416
+
417
+ // 下面这一组从各子模块转出:它们此前全是 sessions.js 的实现,而 sessions.js 是
418
+ // mobile/ 下被引用最多的模块(其它 handler 与测试都从这里取)。换实现不改路径。
419
+ module.exports = {
420
+ // 本文件自己的
421
+ buildSessionsResponse,
422
+ SESSIONS_PREVIEW_LIMIT,
423
+ handleSessions,
424
+ handleSession,
425
+ handleStats,
426
+ handleNew,
427
+ handleQueueUpdate,
428
+ // 子模块转出
429
+ unwrap,
430
+ callSessions,
431
+ promptOnce,
432
+ promptErrorObject,
433
+ MODEL_UNAVAILABLE_ERROR,
434
+ buildMessagesSnapshot,
435
+ buildStatsSnapshot,
436
+ buildSingleSessionMirror,
437
+ buildMirrorWarmSnapshots,
438
+ buildModelsSnapshot,
439
+ resolveEventAttachments,
440
+ // 状态表访问器(实现在 session-cache.js / rpc.js)
441
+ sendIdempotencyStateOf,
442
+ completionStateOf,
443
+ sessionModelsStateOf,
444
+ invalidateSessionModels,
445
+ invalidateSessionList,
446
+ // 纯派生(实现在 history-derive.js)
447
+ listTitle,
448
+ deriveMessages,
449
+ // handler
450
+ handleMessages,
451
+ handleSend,
452
+ handleCommands,
453
+ handleSessionRead,
454
+ handleStop,
455
+ handleRename,
456
+ handleFork,
457
+ handleModels,
458
+ handleSelectModel,
459
+ handleSessionSearch,
460
+ handleAgentPresets,
461
+ handleGoalAction,
462
+ };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 移动端「技能」API:把宿主当前会话可用的技能目录转给手机。
5
+ *
6
+ * 桌面端在输入框里敲「/」时,候选菜单除了宿主命令还会列出技能
7
+ * (@deepseek-ai/dsh-client-ui-skill 注册的 inputTriggers source:trigger "/"、order 2、
8
+ * 按 `startsWith(query)` 前缀过滤、选中后插入 `/<name> `)。手机端此前只有
9
+ * commands.list 的命令目录,技能一个也看不到:用户既不知道这台宿主有哪些技能,
10
+ * 也没法像桌面那样直接选中插入。
11
+ *
12
+ * skill.list 是只读接口,且宿主自己已经过滤掉「非用户可调用」的技能
13
+ * (apiproxy 里 `.filter(isUserInvocable)`),所以可以原样转给手机;
14
+ * 这里只做形状收敛、限长与限条数,避免异常宿主把手机端菜单撑爆。
15
+ *
16
+ * 宿主 skills.list 的签名是 `list(request)`(没有 signal 形参),
17
+ * 与 settings.describe 一样不接受取消信号,故只传信封。
18
+ */
19
+
20
+ const { sendJson } = require('../shared/http-util');
21
+ const { unwrap } = require('./sessions.js');
22
+
23
+ const SKILL_MAX_ITEMS = 200; // 技能条数上限(正常宿主只有个位数)
24
+ const SKILL_NAME_MAX = 120; // 技能名长度上限
25
+ const SKILL_DESCRIPTION_MAX = 400; // 单条说明截断长度
26
+
27
+ /** 技能名:必须是非空字符串;超长截断(斜杠命令名不可能这么长,纯防御异常宿主)。 */
28
+ function normalizeSkillName(value) {
29
+ if (typeof value !== 'string') return '';
30
+ const name = value.trim();
31
+ if (!name) return '';
32
+ return name.length > SKILL_NAME_MAX ? name.slice(0, SKILL_NAME_MAX) : name;
33
+ }
34
+
35
+ /** 说明文本:非字符串当空串;超长截断补省略号,避免菜单行被超长说明撑开。 */
36
+ function normalizeSkillDescription(value) {
37
+ if (typeof value !== 'string') return '';
38
+ const text = value.trim();
39
+ if (!text) return '';
40
+ return text.length > SKILL_DESCRIPTION_MAX ? `${text.slice(0, SKILL_DESCRIPTION_MAX)}…` : text;
41
+ }
42
+
43
+ /**
44
+ * 把 skill.list 的一条技能压成手机需要的形状;缺名字的条目丢掉。
45
+ *
46
+ * modelInvocable 用严格比较:宿主漏字段时按「不可被模型调用」处理(桌面端也只是
47
+ * 拿它决定菜单里要不要加「仅用户」前缀,猜真值反而会误导用户)。
48
+ * whenToUse 不透传:手机端菜单只显示 name + description,不需要它。
49
+ */
50
+ function normalizeSkill(item) {
51
+ if (!item || typeof item !== 'object') return null;
52
+ const name = normalizeSkillName(item.name);
53
+ if (!name) return null;
54
+ return {
55
+ name,
56
+ description: normalizeSkillDescription(item.description),
57
+ modelInvocable: item.modelInvocable === true,
58
+ };
59
+ }
60
+
61
+ /** GET /api/mobile/v1/skills?sessionId=… —— 本会话可用的技能目录(只读)。 */
62
+ async function handleSkills(ctx, req, res) {
63
+ const url = new URL(req.url, 'http://x');
64
+ const sessionId = (url.searchParams.get('sessionId') || '').trim();
65
+ if (!sessionId) return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 sessionId' } });
66
+
67
+ const { value, error } = unwrap(await ctx.apiProxy.skills.list({
68
+ rpcId: 'mobile',
69
+ method: 'skill.list',
70
+ payload: { sessionId },
71
+ }));
72
+ if (error) return sendJson(res, 400, { ok: false, error });
73
+
74
+ const list = Array.isArray(value && value.skills) ? value.skills : [];
75
+ const skills = list.map(normalizeSkill).filter(Boolean).slice(0, SKILL_MAX_ITEMS);
76
+
77
+ sendJson(res, 200, { ok: true, skills });
78
+ }
79
+
80
+ module.exports = { handleSkills, normalizeSkill };