dsh-mini-remote 0.1.0 → 0.1.4

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/lib/store.js CHANGED
@@ -33,6 +33,14 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
33
33
  * 任何一条广播都能把它补上。
34
34
  */
35
35
  liveBySession: {},
36
+ /**
37
+ * 模型此刻的「自言自语」:sessionId -> { text, at }。**同样故意不落盘**。
38
+ *
39
+ * 只给手机上的鲸鱼娘当台词用,和 latest/history 完全分开——它既不是回答,
40
+ * 也不是历史的一部分(历史有 50 条上限,思考会把它挤爆)。一轮结束就清掉,
41
+ * 所以刷新页面看到的是「当前这一句」,而不是上一次的残留。
42
+ */
43
+ thoughtBySession: {},
36
44
  }
37
45
 
38
46
  /** 已知会话的元信息:sessionId -> { id, title, running, lastActivity, seq } */
@@ -84,6 +92,71 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
84
92
  queueSource = typeof fn === 'function' ? fn : () => []
85
93
  }
86
94
 
95
+ /**
96
+ * 「这个会话**完整**的历史」从哪儿问。
97
+ *
98
+ * 和 queueSource 同一个道理、同一套写法:真相在 DSH 自己的会话记录里
99
+ * (`sessionQuery.readSession`),而 store 拿不到 ctx,所以由插件注入一个回调。
100
+ * 回调返回 `{ entries, latest, pending }`;没读过这个会话时返回 null。
101
+ *
102
+ * **为什么不把读出来的历史塞进 `state.historyBySession`**:那一份是要落盘的,
103
+ * 而且是 500 毫秒写一次整个 JSON。几百条历史灌进去,等于每半秒重写一遍几百 KB
104
+ * 的文件。所以完整历史只活在内存里,落盘的仍然是「插件亲眼看过的事件」。
105
+ */
106
+ let replaySource = null
107
+
108
+ function setReplaySource(fn) {
109
+ replaySource = typeof fn === 'function' ? fn : null
110
+ }
111
+
112
+ /**
113
+ * 展示用的聊天记录 = **硬盘那份(完整)+ 还没来得及落进日志的那几条**。
114
+ *
115
+ * 硬盘那份是 DSH 自己写的日志,权威且完整;内存那份只在两种情况下有用:
116
+ * ① 硬盘那份还没读出来(刚点进去的那一下);② 刚发生、日志里还没有的那一条。
117
+ * 所以规则是:以硬盘那份为主,把时间戳比它最后一条更新的内存条目接在后面。
118
+ * 两边的钟是同一台机器的(事件的 `time` 与记录时的 `Date.now()`),不会接重或接丢。
119
+ *
120
+ * **斜杠指令要多一道手续**:同一条指令可能在硬盘那份里还写着「执行中」——读日志的
121
+ * 那一瞬间它**确实还在跑**(`/compact` 要跑十几秒,而一分钟会重读一次日志,撞上的
122
+ * 机会不小)。手里这份后来收到了 `command/done`,可它的时间戳和硬盘那条**一样**
123
+ * (同一条 `command/run` 事件),按上面的时间戳规则接不上去,于是手机上会一直挂着
124
+ * 「执行中」,直到下一次重读——最多一分钟。所以指令行按 `commandId` 认人:
125
+ * 硬盘那条就地改成手里这份的结果,然后**不能**再把它接一遍。
126
+ */
127
+ function mergeHistory(live, disk) {
128
+ if (!disk || !disk.length) return live
129
+ if (!live.length) return disk
130
+ const settled = new Map()
131
+ for (const m of live) {
132
+ if (m?.role === 'command' && m.commandId && m.kind !== 'running') settled.set(m.commandId, m)
133
+ }
134
+ const mergedInto = new Set()
135
+ const merged = settled.size
136
+ ? disk.map((m) => {
137
+ if (m?.role !== 'command' || !m.commandId) return m
138
+ const better = settled.get(m.commandId)
139
+ if (!better) return m
140
+ mergedInto.add(m.commandId)
141
+ return { ...m, kind: better.kind, text: better.text }
142
+ })
143
+ : disk
144
+ const last = merged[merged.length - 1].timestamp ?? 0
145
+ const tail = live.filter((m) => {
146
+ // 刚在上面并进去的那几条,不能再作为「新的一条」接一遍。
147
+ if (m?.role === 'command' && m.commandId && mergedInto.has(m.commandId)) return false
148
+ return (m.timestamp ?? 0) > last
149
+ })
150
+ return tail.length ? merged.concat(tail) : merged
151
+ }
152
+
153
+ /** 单帧模式显示哪一条:两份里取时间更新的那条。 */
154
+ function newestLatest(a, b) {
155
+ if (!a) return b
156
+ if (!b) return a
157
+ return (b.timestamp ?? 0) > (a.timestamp ?? 0) ? b : a
158
+ }
159
+
87
160
  /** 某个会话的最新回复;没有就是 null。 */
88
161
  function latestOf(sessionId) {
89
162
  return sessionId ? (state.latestBySession[sessionId] ?? null) : null
@@ -105,6 +178,18 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
105
178
  return sessionId ? (state.liveBySession[sessionId] ?? '') : ''
106
179
  }
107
180
 
181
+ /**
182
+ * 模型此刻的自言自语(鲸鱼娘的台词)。
183
+ *
184
+ * 和 live 分开存,**不是为了整齐**:页面上的鲸鱼娘只要发现 live 非空就会整块让位
185
+ * (那是「正在流出来的回答」的规矩),把思考塞进 live 会让该出现台词的时候鲸鱼娘
186
+ * 直接消失。
187
+ */
188
+ function thoughtOf(sessionId) {
189
+ const t = sessionId ? state.thoughtBySession[sessionId] : null
190
+ return t ? { text: t.text, at: t.at } : null
191
+ }
192
+
108
193
  /**
109
194
  * 只保留最近碰过的若干会话的数据,免得这个 JSON 文件无限长大。
110
195
  * 被丢掉的会话切过去会显示「还没有回复」——**这比显示别人的回答强**。
@@ -117,6 +202,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
117
202
  delete state.historyBySession[id]
118
203
  delete state.latestBySession[id]
119
204
  delete state.liveBySession[id]
205
+ delete state.thoughtBySession[id]
120
206
  }
121
207
  }
122
208
 
@@ -214,6 +300,9 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
214
300
  /** 告诉 store「队列里有什么」该去哪儿问(见 setQueueSource)。 */
215
301
  setQueueSource,
216
302
 
303
+ /** 告诉 store「这个会话完整的历史」该去哪儿问(见 setReplaySource)。 */
304
+ setReplaySource,
305
+
217
306
  /** 某个会话的最新回复 / 聊天记录。 */
218
307
  latestOf,
219
308
  historyOf,
@@ -229,27 +318,76 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
229
318
  * interrupted 是「这一轮被中止了,下面只是已经吐出来的那半句」。
230
319
  * 手机要把它标出来——不标的话,按了停止之后冒出来的半句话看着像模型答崩了。
231
320
  */
232
- pushReply({ text, sessionId, reason, interrupted }) {
233
- const timestamp = Date.now()
321
+ pushReply({ text, sessionId, reason, interrupted, timestamp }) {
322
+ // 同上:事件带过来的 `time` 优先。合成时两边的钟不一样,同一条回复就会
323
+ // 既从硬盘来一遍、又从手里这份来一遍,手机上显示两遍。
324
+ const at = timestamp ?? Date.now()
234
325
  const session = sessions.get(sessionId)
235
326
  state.latestBySession[sessionId] = {
236
- text, sessionId, title: session?.title ?? '', timestamp, reason,
327
+ text, sessionId, title: session?.title ?? '', timestamp: at, reason,
237
328
  interrupted: interrupted === true,
238
329
  }
239
330
  pushHistory(sessionId, {
240
- role: 'assistant', text, timestamp, sessionId, interrupted: interrupted === true,
331
+ role: 'assistant', text, timestamp: at, sessionId, interrupted: interrupted === true,
241
332
  })
242
333
  persist()
243
334
  },
244
335
 
245
336
  /** 用户(手机上或电脑上)发出了一条指令。 */
246
- pushUser({ text, sessionId, id }) {
337
+ pushUser({ text, sessionId, id, timestamp }) {
247
338
  // id 要留着:排队中的指令靠它和 agent 收件箱里的那一条对上号,
248
339
  // 界面上才能标出「这条还在排队」、也才能撤掉它。
249
- pushHistory(sessionId, { role: 'user', text, timestamp: Date.now(), sessionId, id })
340
+ //
341
+ // 时间戳要是有事件带过来的那个 `time`,就用它,别用 Date.now()——理由和
342
+ // pushCommand 那段一模一样:这一条要和硬盘重放出来的那份按时间戳合成,
343
+ // **两边得是同一个钟**。差一毫秒就够它被当成「新的一条」再接一遍,手机上
344
+ // 同一条指令显示两遍(2026-09-27 用户实机报的,实测两份差 1 毫秒)。
345
+ pushHistory(sessionId, {
346
+ role: 'user', text, timestamp: timestamp ?? Date.now(), sessionId, id,
347
+ })
250
348
  persist()
251
349
  },
252
350
 
351
+ /**
352
+ * 一条斜杠指令开跑了(`command/run`)。
353
+ *
354
+ * 指令是**第三种东西**:不是用户说的话,也不是模型的回答。所以在聊天记录里
355
+ * 单独一个 role,界面上按「系统动作」画。混进用户气泡会让「这句到底是谁说的」
356
+ * 变糊——手机上看聊天记录时这个判断最要紧。
357
+ */
358
+ pushCommand({ sessionId, commandId, name, args, timestamp }) {
359
+ pushHistory(sessionId, {
360
+ role: 'command', commandId, name, args: args ?? '', kind: 'running', text: null,
361
+ // 时间戳用事件自己的 `time`(事件里带过来的),不是 Date.now():
362
+ // 这一条要和硬盘重放出来的那份按时间戳合成,两边得用同一个钟。
363
+ timestamp: timestamp ?? Date.now(), sessionId,
364
+ })
365
+ persist()
366
+ },
367
+
368
+ /**
369
+ * 那条指令收尾了(`command/done`):**就地改那一条,不新加一条**。
370
+ *
371
+ * 认 commandId 从后往前找——同一条会话里同时跑两条同名指令是可能的,
372
+ * 按名字找会改错行。
373
+ *
374
+ * 找不到就什么都不做:这只可能是「插件是中途起来的、没看见 run」。
375
+ * 下一次读日志(点开会话时、或六十秒内那一次)会把这一对完整地重放出来,
376
+ * 那时候它自然就在了。**不能**在这儿补一条没有名字的记录——那比晚几十秒更糟。
377
+ */
378
+ settleCommand({ sessionId, commandId, kind, text }) {
379
+ const list = historyOf(sessionId)
380
+ for (let i = list.length - 1; i >= 0; i--) {
381
+ const m = list[i]
382
+ if (m.role === 'command' && m.commandId === commandId) {
383
+ m.kind = kind
384
+ m.text = text ?? null
385
+ persist()
386
+ return
387
+ }
388
+ }
389
+ },
390
+
253
391
  /**
254
392
  * 清空**当前会话**在单帧模式里的内容。
255
393
  *
@@ -281,6 +419,20 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
281
419
  else delete state.liveBySession[sessionId]
282
420
  },
283
421
 
422
+ /**
423
+ * 记下模型此刻的自言自语,给鲸鱼娘当台词。传 null 就是「没话说了」,清掉。
424
+ *
425
+ * 同样不 persist():它活不过一轮,写盘只会留下一句没头没尾的旧台词。
426
+ */
427
+ setThought(sessionId, thought) {
428
+ if (!sessionId) return
429
+ if (thought && thought.text) {
430
+ state.thoughtBySession[sessionId] = { text: thought.text, at: thought.at ?? Date.now() }
431
+ } else {
432
+ delete state.thoughtBySession[sessionId]
433
+ }
434
+ },
435
+
284
436
  /** 手机是否真的在看着——决定要不要额外发系统推送。 */
285
437
  isForeground() {
286
438
  return state.presence === 'foreground' && Date.now() - state.lastSeen < 60_000
@@ -288,15 +440,39 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
288
440
 
289
441
  snapshot() {
290
442
  const target = targetSessionId()
443
+ // 内存里记着的那一份(插件在场时看见的)
444
+ const live = historyOf(target)
445
+ // DSH 记录里那一份(完整),没读过就是 null;读取中 pending 为 true
446
+ const replay = target && replaySource ? replaySource(target) : null
291
447
  return {
292
448
  /**
293
449
  * latest 和 history 都是**当前这个会话**的那一份。
294
450
  *
295
451
  * 原来是全局一份,于是切到别的会话时,单帧模式里还显示着上一个会话的答案
296
452
  * (2026-09-21 用户实机报的)。和 running 一样,显示状态不能跨会话串。
453
+ *
454
+ * 现在两份来源合成一份:内存里那份(插件亲眼看见的)和 DSH 记录里那份(完整)。
455
+ * 合成规则见 mergeHistory / newestLatest——**只看时间戳,不猜**。
297
456
  */
298
- latest: latestOf(target),
299
- history: historyOf(target),
457
+ latest: newestLatest(latestOf(target), replay?.latest ?? null),
458
+ history: mergeHistory(live, replay?.entries ?? null),
459
+ /**
460
+ * 「正在去读这个会话的记录」。
461
+ *
462
+ * 手机点开一个从没看过的会话时,读它的日志要一会儿(实测最大的那条 20 MB、
463
+ * 约半秒)。这期间内存里空空如也,界面该说「正在读」而不是「还没有对话记录」——
464
+ * 后者会让用户以为这个会话是空的。
465
+ */
466
+ historyLoading: replay?.pending === true && live.length === 0,
467
+ /**
468
+ * 「这份历史不是全部,只到最近一段」。
469
+ *
470
+ * 读历史有上限(见 index.js 的 HISTORY_MAX_ENTRIES / 尾部窗口):会话太大时
471
+ * 只读日志尾部。**必须跟着内容一起说出去**——否则用户会以为这个会话就这么点。
472
+ */
473
+ historyTruncated: replay?.truncated === true,
474
+ /** 给手机的一句人话(为什么不是全部);null = 不用解释。 */
475
+ historyNote: replay?.note ?? null,
300
476
  /**
301
477
  * 正在流出来的那一段(可能为空)。
302
478
  *
@@ -304,6 +480,13 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
304
480
  * 当成答案——真正的答案还是要等不含工具调用的那一步落定(见 events.js)。
305
481
  */
306
482
  live: liveOf(target),
483
+ /**
484
+ * 模型此刻的自言自语(鲸鱼娘的气泡台词),没有就是 null。
485
+ *
486
+ * **第三次重申它的边界**:这不是回答、不进历史、不参与「有没有新回复」的
487
+ * 判断。哪怕它错乱到显示了上一句,代价也只是气泡里一句话旧了。
488
+ */
489
+ thought: thoughtOf(target),
307
490
  boundSessionId: state.boundSessionId,
308
491
  /**
309
492
  * 「正在执行」必须跟着会话走。
package/lib/sw.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Service worker —— 它只为一件存在:让手机能发**系统通知**。
3
+ *
4
+ * 为什么非要有它:手机浏览器上 `new Notification()` 会直接抛 TypeError,
5
+ * 唯一能发系统通知的入口是 `ServiceWorkerRegistration.showNotification()`。
6
+ * 换句话说,「锁屏也能收到提醒」这件事的前提,是先有一个 service worker。
7
+ *
8
+ * 它自己不做任何后台工作:不缓存、不拦截请求、不在后台跑逻辑。收到页面递来的
9
+ * 一句话就发一条通知,用户点了就回到页面。就这样。
10
+ *
11
+ * 2026-09-25 才加。在此之前这条路是关着的——service worker 要求安全上下文,
12
+ * 而当时三条连接路径全是明文 http。Tailscale 那条路接上 HTTPS 之后才成立。
13
+ *
14
+ * **注意作用域**:页面在 `/mini`,不在 `/mini/` 底下。这个文件从 `/mini/sw.js`
15
+ * 发出去,默认作用域只到 `/mini/`,够不着那个页面。所以服务器发它的时候必须带上
16
+ * `Service-Worker-Allowed: /`(见 lib/server.js),把作用域放开到根。
17
+ */
18
+
19
+ // 新的立刻接管。它没有任何状态要迁移,不该让用户「刷新两次才生效」。
20
+ self.addEventListener('install', () => self.skipWaiting())
21
+ self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
22
+
23
+ // 通知由页面直接调 `registration.showNotification()` 发出,不经过这里——
24
+ // 页面本来就能调那个方法,绕一道消息传递只是多一层可能出错的地方。
25
+ // 这个文件在这儿只负责两件页面做不到的事:**让注册成立**,以及**接住点击**。
26
+ self.addEventListener('notificationclick', (event) => {
27
+ event.notification.close()
28
+ const url = event.notification.data && event.notification.data.url
29
+ event.waitUntil(
30
+ (async () => {
31
+ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
32
+ // 页面已经开着就切过去,别再开一个——用户要的是「回到刚才那页」。
33
+ for (const c of all) {
34
+ if (typeof c.focus === 'function') return c.focus()
35
+ }
36
+ if (url && self.clients.openWindow) return self.clients.openWindow(url)
37
+ })(),
38
+ )
39
+ })
package/lib/tree.js CHANGED
@@ -15,6 +15,8 @@
15
15
  * 它的会话(要读日志折标题,贵)。
16
16
  */
17
17
  import { basename } from 'node:path'
18
+ import { statSync } from 'node:fs'
19
+ import { findSessionLog } from './log-tail.js'
18
20
 
19
21
  /** 一个工作区默认最多列多少会话。手机上再多也划不动。 */
20
22
  export const SESSIONS_PER_WORKSPACE = 20
@@ -107,15 +109,21 @@ function titleText(value) {
107
109
  }
108
110
 
109
111
  /**
110
- * 工作区列表。只给「有会话可切」的那些,空工作区不占地方。
112
+ * 工作区列表。默认只给「有会话可切」的那些,空工作区不占地方。
111
113
  * 返回顺序就是桌面端的显示顺序。
114
+ *
115
+ * `includeEmpty`:把一条会话都没有的工作区也列出来。
116
+ * **手机端必须开着这个**——手机现在能自己新建工作区了,一个刚建好的工作区
117
+ * 本来就是空的;要是列表把它滤掉,用户建完它就当场消失,只会以为没建成。
118
+ * (闸门脚本就是这么逮到的:接口回 created=true,列表里却找不到。)
119
+ * 默认仍然是「滤掉」,桌面端那些老调用方的行为一点不变。
112
120
  */
113
- export async function listWorkspaces({ registry, query, runningIds, force }) {
121
+ export async function listWorkspaces({ registry, query, runningIds, force, includeEmpty }) {
114
122
  const byId = toRecordMap(await safeRecords(query, { force }))
115
123
  const out = []
116
124
  for (const ws of safeList(registry)) {
117
125
  const ids = (ws.sessionIds ?? []).filter((id) => visible(byId.get(id)))
118
- if (!ids.length) continue
126
+ if (!ids.length && !includeEmpty) continue
119
127
  let running = 0
120
128
  for (const id of ids) if (runningIds?.has(id)) running += 1
121
129
  out.push({
@@ -125,11 +133,109 @@ export async function listWorkspaces({ registry, query, runningIds, force }) {
125
133
  path: ws.path || '',
126
134
  count: ids.length,
127
135
  running,
136
+ // 手机据此决定是「展开看会话」还是直接提示「这里还没有会话,可以建一个」。
137
+ empty: ids.length === 0,
128
138
  })
129
139
  }
130
140
  return out
131
141
  }
132
142
 
143
+ /**
144
+ * 在一个已有的工作区里新建一个会话。
145
+ *
146
+ * **走 DSH 自己那条路**:`sessionController.create({ workspaceId })`——就是网页端
147
+ * 点「新建会话」时走的那条。不自己拼会话,理由是装配一个能用的会话远不止「起个 id」:
148
+ * 要先装模型选择(`agentDefaultModel`),再把 agent preset 挂到它的作用域上
149
+ * (工具、指令、技能全在那里面),最后才注册进 sessions 和 agents 两张表。
150
+ * 这些都在 `dsh-api-session-controller` 内部,抄一遍等于把 DSH 的内部结构复制到
151
+ * 我们这儿,它一改我们就散架。它自己也是拿 `ctx.agents.create(...)` 做的,
152
+ * 而 `ctx.agents` 就是注入给我们的那个 AgentRegistry。
153
+ *
154
+ * 三种失败分得开,因为手机上的说法不一样:
155
+ * - `no-controller` 这台电脑上的 DSH 没提供会话服务(headless 组合里没有)
156
+ * - `no-workspace` 工作区不在了(桌面端刚把它删掉)
157
+ * - `failed` DSH 拒绝了,原因原样带回
158
+ */
159
+ export async function createSessionIn({ controller, registry, workspaceId }) {
160
+ if (!controller || typeof controller.create !== 'function') {
161
+ return { ok: false, reason: 'no-controller' }
162
+ }
163
+ const ws = safeList(registry).find((w) => w.id === workspaceId)
164
+ if (!ws) return { ok: false, reason: 'no-workspace' }
165
+
166
+ let created
167
+ try {
168
+ created = await controller.create({ workspaceId })
169
+ } catch (err) {
170
+ return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
171
+ }
172
+ // 建成了就必须有 id。没有 id 的话手机没法绑过去,等于白建——宁可报错也不装作成功。
173
+ const sessionId = created?.sessionId
174
+ if (typeof sessionId !== 'string' || sessionId === '') {
175
+ return { ok: false, reason: 'failed', error: 'DSH 建了会话但没返回 id' }
176
+ }
177
+ return {
178
+ ok: true,
179
+ sessionId,
180
+ // 手机建完要显示一句「已在《xxx》里建好」,所以顺手把标题带回去。
181
+ workspaceTitle: (ws.title || '').trim() || basename(ws.path || '') || '未命名工作区',
182
+ }
183
+ }
184
+
185
+ /**
186
+ * 把一个目录登记成工作区。
187
+ *
188
+ * 先 `resolveByPath` 再 `create`,虽然 `create` 自己也会复用同路径的已有记录
189
+ * (它的文档写着 "Repeated calls for the same canonical path return the existing
190
+ * entity without changing its title")。多问一次是为了**能如实告诉手机
191
+ * 「这是新建的,还是本来就有」**——用户点了「新建工作区」,结果只是切到一个
192
+ * 早就登记过的目录,界面得说清楚,不能让他以为建了个新的。
193
+ *
194
+ * 两个方法的失败形状不一样,都要接:
195
+ * - `resolveByPath` 在路径**不存在**时靠 realpath 抛错(文档原话:
196
+ * "A missing path rejects during realpath")
197
+ * - `create` 在路径是相对路径、不存在、或者不是目录时拒绝
198
+ *
199
+ * 注意 `create` 会把路径用 `fs.realpath` 规范化——所以我们报回去的 `path`
200
+ * 用它给的那份,而不是手机传进来的那份。两者可能不一样(短路径、大小写、
201
+ * 符号链接),界面上要显示的是**真实的那条**。
202
+ */
203
+ export async function createWorkspaceAt({ registry, path }) {
204
+ if (!registry || typeof registry.create !== 'function') {
205
+ return { ok: false, reason: 'no-registry' }
206
+ }
207
+ if (typeof path !== 'string' || path.trim() === '') {
208
+ return { ok: false, reason: 'bad-path' }
209
+ }
210
+
211
+ try {
212
+ const existing = await registry.resolveByPath(path)
213
+ if (existing) {
214
+ return { ok: true, created: false, workspace: shapeWorkspace(existing) }
215
+ }
216
+ } catch (err) {
217
+ // 路径不存在时这里就会抛。**不当作失败**:让它落到下面 create 那一步,
218
+ // 由 create 给出「不存在 / 不是目录」的准确说法。两处各报一次错的话,
219
+ // 手机上会看到两条不一样的话,反而不知道信哪条。
220
+ }
221
+
222
+ try {
223
+ const created = await registry.create(path)
224
+ return { ok: true, created: true, workspace: shapeWorkspace(created) }
225
+ } catch (err) {
226
+ return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
227
+ }
228
+ }
229
+
230
+ /** 工作区实体 → 手机要的那几项。别把整个实体丢过去:它带一堆内部字段。 */
231
+ function shapeWorkspace(ws) {
232
+ return {
233
+ id: ws?.id ?? '',
234
+ path: ws?.path ?? '',
235
+ title: (ws?.title || '').trim() || basename(ws?.path || '') || '未命名工作区',
236
+ }
237
+ }
238
+
133
239
  /**
134
240
  * 某个工作区下的会话,新的在前,最多 limit 条。
135
241
  *
@@ -183,3 +289,39 @@ export async function listSessionsOf({ registry, query, agents, knownTitles, wor
183
289
  }),
184
290
  }
185
291
  }
292
+
293
+ /**
294
+ * 会话日志的压缩体积(字节)。拿不到就返回 null——**不猜**。
295
+ *
296
+ * 为什么要它:`sessionQuery.readSession()` 会把整份事件解出来(还要做两次全量克隆),
297
+ * 而且 0.1.7-rc.2 起 v3 日志要走 v3→v4 迁移,迁移过程会把这个父会话的
298
+ * **每个直接子会话整份解码**。实测:26MB / 2.2 万事件的父会话带 310 个子会话时,
299
+ * 默认约 4GB 堆 20 秒就 OOM(见 2026-09-25 事故)。所以读之前先量体积。
300
+ */
301
+ function logBytes(sessionId) {
302
+ const file = findSessionLog(sessionId)
303
+ if (!file) return null
304
+ try {
305
+ return statSync(file).size
306
+ } catch {
307
+ return null
308
+ }
309
+ }
310
+
311
+ /**
312
+ * 一次「整份读」值不值得。子会话数用已缓存的 `listSessions()` 记录算(**无额外扫盘**)。
313
+ *
314
+ * 读不动有两类原因:① **子会话太多**(迁移要把它们全解出来);② **自己太大**
315
+ * (解 + 两次克隆 ≈ 体积的 5–8 倍)。两个信号都给出去,阈值由调用方定。
316
+ *
317
+ * @param {{query:object, sessionId:string}} args
318
+ * @returns {Promise<{children:number, bytes:number|null}>} children = 以它为 parentSession 的会话数
319
+ */
320
+ export async function sessionScale({ query, sessionId }) {
321
+ const records = await safeRecords(query)
322
+ let children = 0
323
+ for (const r of records) {
324
+ if (r?.header?.parentSession === sessionId) children += 1
325
+ }
326
+ return { children, bytes: logBytes(sessionId) }
327
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mini-remote",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "description": "DSH 极简移动端遥控器:只把「你的指令」和「AI 的最终回复」送到手机,屏蔽全部中间过程。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -13,17 +13,18 @@
13
13
  },
14
14
  "files": [
15
15
  "lib",
16
- "client",
16
+ "client/client.js",
17
17
  "cordis.patch.yml",
18
18
  "README.md",
19
19
  "CHANGELOG.md",
20
20
  "LICENSE"
21
21
  ],
22
22
  "scripts": {
23
- "test": "node --test test/smoke.test.mjs test/plugin.test.mjs test/net.test.mjs test/tunnel.test.mjs test/page.test.mjs test/tree.test.mjs"
23
+ "test": "node --test \"test/*.test.mjs\""
24
24
  },
25
25
  "engines": {
26
- "node": ">=22"
26
+ "node": ">=22",
27
+ "dsh": ">=0.1.5-rc.2"
27
28
  },
28
29
  "author": "xingzhen199186",
29
30
  "repository": {
@@ -35,13 +36,21 @@
35
36
  "url": "https://github.com/xingzhen199186/dsh-mini-remote/issues"
36
37
  },
37
38
  "peerDependencies": {
38
- "@deepseek-ai/cordis": "^4.0.2"
39
+ "@deepseek-ai/cordis": "^4.0.2",
40
+ "@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0",
41
+ "@deepseek-ai/schemastery": "^3.18.3"
39
42
  },
40
43
  "peerDependenciesMeta": {
41
44
  "@deepseek-ai/cordis": {
42
45
  "optional": true
46
+ },
47
+ "@deepseek-ai/dsh": {
48
+ "optional": true
43
49
  }
44
50
  },
51
+ "devDependencies": {
52
+ "@deepseek-ai/schemastery": "^3.18.3"
53
+ },
45
54
  "keywords": [
46
55
  "deepseek-harness",
47
56
  "dsh",
@@ -52,7 +61,7 @@
52
61
  ],
53
62
  "license": "MIT",
54
63
  "disclosure": {
55
- "transport": "手机与电脑之间是明文 http(内网 / Tailscale / Cloudflare 隧道三种任选其一)。访问口令随 URL 传递,所以公网隧道那条路请不要在不可信网络里用。",
64
+ "transport": "手机与电脑之间走 http 还是 https,取决于你选哪条路:内网是明文 http;Tailscale 默认也是明文 http,可以另开一个加密的 https 地址;Cloudflare 公网隧道是 https。访问口令随 URL 传递,所以公网隧道那条路请不要在不可信网络里用。",
56
65
  "storage": "不保存对话内容之外的任何凭据。cloudflared 二进制按需下载到 DSH 数据目录,不上传。",
57
66
  "telemetry": "不收集、不上传任何数据。",
58
67
  "network": "启用公网隧道时,流量经 Cloudflare 中转;内网和 Tailscale 两条路不经过任何第三方。"