dsh-mini-remote 0.1.0 → 0.1.3
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/CHANGELOG.md +65 -0
- package/README.en.md +154 -0
- package/README.md +75 -59
- package/client/client.js +144 -1
- package/lib/browse.js +216 -0
- package/lib/commands.js +166 -0
- package/lib/history.js +72 -0
- package/lib/index.js +644 -5
- package/lib/log-tail.js +235 -0
- package/lib/page.html +1488 -24
- package/lib/page.js +21 -0
- package/lib/pairing.js +103 -14
- package/lib/permissions.js +165 -0
- package/lib/serve.js +358 -0
- package/lib/server.js +429 -6
- package/lib/store.js +134 -2
- package/lib/sw.js +39 -0
- package/lib/tree.js +145 -3
- package/package.json +6 -5
- package/README.zh.md +0 -138
- package/client/verify.mjs +0 -514
package/lib/store.js
CHANGED
|
@@ -84,6 +84,71 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
84
84
|
queueSource = typeof fn === 'function' ? fn : () => []
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* 「这个会话**完整**的历史」从哪儿问。
|
|
89
|
+
*
|
|
90
|
+
* 和 queueSource 同一个道理、同一套写法:真相在 DSH 自己的会话记录里
|
|
91
|
+
* (`sessionQuery.readSession`),而 store 拿不到 ctx,所以由插件注入一个回调。
|
|
92
|
+
* 回调返回 `{ entries, latest, pending }`;没读过这个会话时返回 null。
|
|
93
|
+
*
|
|
94
|
+
* **为什么不把读出来的历史塞进 `state.historyBySession`**:那一份是要落盘的,
|
|
95
|
+
* 而且是 500 毫秒写一次整个 JSON。几百条历史灌进去,等于每半秒重写一遍几百 KB
|
|
96
|
+
* 的文件。所以完整历史只活在内存里,落盘的仍然是「插件亲眼看过的事件」。
|
|
97
|
+
*/
|
|
98
|
+
let replaySource = null
|
|
99
|
+
|
|
100
|
+
function setReplaySource(fn) {
|
|
101
|
+
replaySource = typeof fn === 'function' ? fn : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 展示用的聊天记录 = **硬盘那份(完整)+ 还没来得及落进日志的那几条**。
|
|
106
|
+
*
|
|
107
|
+
* 硬盘那份是 DSH 自己写的日志,权威且完整;内存那份只在两种情况下有用:
|
|
108
|
+
* ① 硬盘那份还没读出来(刚点进去的那一下);② 刚发生、日志里还没有的那一条。
|
|
109
|
+
* 所以规则是:以硬盘那份为主,把时间戳比它最后一条更新的内存条目接在后面。
|
|
110
|
+
* 两边的钟是同一台机器的(事件的 `time` 与记录时的 `Date.now()`),不会接重或接丢。
|
|
111
|
+
*
|
|
112
|
+
* **斜杠指令要多一道手续**:同一条指令可能在硬盘那份里还写着「执行中」——读日志的
|
|
113
|
+
* 那一瞬间它**确实还在跑**(`/compact` 要跑十几秒,而一分钟会重读一次日志,撞上的
|
|
114
|
+
* 机会不小)。手里这份后来收到了 `command/done`,可它的时间戳和硬盘那条**一样**
|
|
115
|
+
* (同一条 `command/run` 事件),按上面的时间戳规则接不上去,于是手机上会一直挂着
|
|
116
|
+
* 「执行中」,直到下一次重读——最多一分钟。所以指令行按 `commandId` 认人:
|
|
117
|
+
* 硬盘那条就地改成手里这份的结果,然后**不能**再把它接一遍。
|
|
118
|
+
*/
|
|
119
|
+
function mergeHistory(live, disk) {
|
|
120
|
+
if (!disk || !disk.length) return live
|
|
121
|
+
if (!live.length) return disk
|
|
122
|
+
const settled = new Map()
|
|
123
|
+
for (const m of live) {
|
|
124
|
+
if (m?.role === 'command' && m.commandId && m.kind !== 'running') settled.set(m.commandId, m)
|
|
125
|
+
}
|
|
126
|
+
const mergedInto = new Set()
|
|
127
|
+
const merged = settled.size
|
|
128
|
+
? disk.map((m) => {
|
|
129
|
+
if (m?.role !== 'command' || !m.commandId) return m
|
|
130
|
+
const better = settled.get(m.commandId)
|
|
131
|
+
if (!better) return m
|
|
132
|
+
mergedInto.add(m.commandId)
|
|
133
|
+
return { ...m, kind: better.kind, text: better.text }
|
|
134
|
+
})
|
|
135
|
+
: disk
|
|
136
|
+
const last = merged[merged.length - 1].timestamp ?? 0
|
|
137
|
+
const tail = live.filter((m) => {
|
|
138
|
+
// 刚在上面并进去的那几条,不能再作为「新的一条」接一遍。
|
|
139
|
+
if (m?.role === 'command' && m.commandId && mergedInto.has(m.commandId)) return false
|
|
140
|
+
return (m.timestamp ?? 0) > last
|
|
141
|
+
})
|
|
142
|
+
return tail.length ? merged.concat(tail) : merged
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 单帧模式显示哪一条:两份里取时间更新的那条。 */
|
|
146
|
+
function newestLatest(a, b) {
|
|
147
|
+
if (!a) return b
|
|
148
|
+
if (!b) return a
|
|
149
|
+
return (b.timestamp ?? 0) > (a.timestamp ?? 0) ? b : a
|
|
150
|
+
}
|
|
151
|
+
|
|
87
152
|
/** 某个会话的最新回复;没有就是 null。 */
|
|
88
153
|
function latestOf(sessionId) {
|
|
89
154
|
return sessionId ? (state.latestBySession[sessionId] ?? null) : null
|
|
@@ -214,6 +279,9 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
214
279
|
/** 告诉 store「队列里有什么」该去哪儿问(见 setQueueSource)。 */
|
|
215
280
|
setQueueSource,
|
|
216
281
|
|
|
282
|
+
/** 告诉 store「这个会话完整的历史」该去哪儿问(见 setReplaySource)。 */
|
|
283
|
+
setReplaySource,
|
|
284
|
+
|
|
217
285
|
/** 某个会话的最新回复 / 聊天记录。 */
|
|
218
286
|
latestOf,
|
|
219
287
|
historyOf,
|
|
@@ -250,6 +318,46 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
250
318
|
persist()
|
|
251
319
|
},
|
|
252
320
|
|
|
321
|
+
/**
|
|
322
|
+
* 一条斜杠指令开跑了(`command/run`)。
|
|
323
|
+
*
|
|
324
|
+
* 指令是**第三种东西**:不是用户说的话,也不是模型的回答。所以在聊天记录里
|
|
325
|
+
* 单独一个 role,界面上按「系统动作」画。混进用户气泡会让「这句到底是谁说的」
|
|
326
|
+
* 变糊——手机上看聊天记录时这个判断最要紧。
|
|
327
|
+
*/
|
|
328
|
+
pushCommand({ sessionId, commandId, name, args, timestamp }) {
|
|
329
|
+
pushHistory(sessionId, {
|
|
330
|
+
role: 'command', commandId, name, args: args ?? '', kind: 'running', text: null,
|
|
331
|
+
// 时间戳用事件自己的 `time`(事件里带过来的),不是 Date.now():
|
|
332
|
+
// 这一条要和硬盘重放出来的那份按时间戳合成,两边得用同一个钟。
|
|
333
|
+
timestamp: timestamp ?? Date.now(), sessionId,
|
|
334
|
+
})
|
|
335
|
+
persist()
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* 那条指令收尾了(`command/done`):**就地改那一条,不新加一条**。
|
|
340
|
+
*
|
|
341
|
+
* 认 commandId 从后往前找——同一条会话里同时跑两条同名指令是可能的,
|
|
342
|
+
* 按名字找会改错行。
|
|
343
|
+
*
|
|
344
|
+
* 找不到就什么都不做:这只可能是「插件是中途起来的、没看见 run」。
|
|
345
|
+
* 下一次读日志(点开会话时、或六十秒内那一次)会把这一对完整地重放出来,
|
|
346
|
+
* 那时候它自然就在了。**不能**在这儿补一条没有名字的记录——那比晚几十秒更糟。
|
|
347
|
+
*/
|
|
348
|
+
settleCommand({ sessionId, commandId, kind, text }) {
|
|
349
|
+
const list = historyOf(sessionId)
|
|
350
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
351
|
+
const m = list[i]
|
|
352
|
+
if (m.role === 'command' && m.commandId === commandId) {
|
|
353
|
+
m.kind = kind
|
|
354
|
+
m.text = text ?? null
|
|
355
|
+
persist()
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
|
|
253
361
|
/**
|
|
254
362
|
* 清空**当前会话**在单帧模式里的内容。
|
|
255
363
|
*
|
|
@@ -288,15 +396,39 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
288
396
|
|
|
289
397
|
snapshot() {
|
|
290
398
|
const target = targetSessionId()
|
|
399
|
+
// 内存里记着的那一份(插件在场时看见的)
|
|
400
|
+
const live = historyOf(target)
|
|
401
|
+
// DSH 记录里那一份(完整),没读过就是 null;读取中 pending 为 true
|
|
402
|
+
const replay = target && replaySource ? replaySource(target) : null
|
|
291
403
|
return {
|
|
292
404
|
/**
|
|
293
405
|
* latest 和 history 都是**当前这个会话**的那一份。
|
|
294
406
|
*
|
|
295
407
|
* 原来是全局一份,于是切到别的会话时,单帧模式里还显示着上一个会话的答案
|
|
296
408
|
* (2026-09-21 用户实机报的)。和 running 一样,显示状态不能跨会话串。
|
|
409
|
+
*
|
|
410
|
+
* 现在两份来源合成一份:内存里那份(插件亲眼看见的)和 DSH 记录里那份(完整)。
|
|
411
|
+
* 合成规则见 mergeHistory / newestLatest——**只看时间戳,不猜**。
|
|
412
|
+
*/
|
|
413
|
+
latest: newestLatest(latestOf(target), replay?.latest ?? null),
|
|
414
|
+
history: mergeHistory(live, replay?.entries ?? null),
|
|
415
|
+
/**
|
|
416
|
+
* 「正在去读这个会话的记录」。
|
|
417
|
+
*
|
|
418
|
+
* 手机点开一个从没看过的会话时,读它的日志要一会儿(实测最大的那条 20 MB、
|
|
419
|
+
* 约半秒)。这期间内存里空空如也,界面该说「正在读」而不是「还没有对话记录」——
|
|
420
|
+
* 后者会让用户以为这个会话是空的。
|
|
421
|
+
*/
|
|
422
|
+
historyLoading: replay?.pending === true && live.length === 0,
|
|
423
|
+
/**
|
|
424
|
+
* 「这份历史不是全部,只到最近一段」。
|
|
425
|
+
*
|
|
426
|
+
* 读历史有上限(见 index.js 的 HISTORY_MAX_ENTRIES / 尾部窗口):会话太大时
|
|
427
|
+
* 只读日志尾部。**必须跟着内容一起说出去**——否则用户会以为这个会话就这么点。
|
|
297
428
|
*/
|
|
298
|
-
|
|
299
|
-
|
|
429
|
+
historyTruncated: replay?.truncated === true,
|
|
430
|
+
/** 给手机的一句人话(为什么不是全部);null = 不用解释。 */
|
|
431
|
+
historyNote: replay?.note ?? null,
|
|
300
432
|
/**
|
|
301
433
|
* 正在流出来的那一段(可能为空)。
|
|
302
434
|
*
|
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.
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
|
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": {
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
],
|
|
53
54
|
"license": "MIT",
|
|
54
55
|
"disclosure": {
|
|
55
|
-
"transport": "
|
|
56
|
+
"transport": "手机与电脑之间走 http 还是 https,取决于你选哪条路:内网是明文 http;Tailscale 默认也是明文 http,可以另开一个加密的 https 地址;Cloudflare 公网隧道是 https。访问口令随 URL 传递,所以公网隧道那条路请不要在不可信网络里用。",
|
|
56
57
|
"storage": "不保存对话内容之外的任何凭据。cloudflared 二进制按需下载到 DSH 数据目录,不上传。",
|
|
57
58
|
"telemetry": "不收集、不上传任何数据。",
|
|
58
59
|
"network": "启用公网隧道时,流量经 Cloudflare 中转;内网和 Tailscale 两条路不经过任何第三方。"
|
package/README.zh.md
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
# dsh-mini-remote
|
|
2
|
-
|
|
3
|
-
> 一个 [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness)(DSH)插件,把一部手机变成**极简遥控器**——手机端只收到你发出去的指令和 AI 最后的结论,工具调用、文件读写、子 Agent 调度、思考过程全部留在电脑上。
|
|
4
|
-
|
|
5
|
-

|
|
6
|
-

|
|
7
|
-
|
|
8
|
-
[English →](README.md)
|
|
9
|
-
|
|
10
|
-
**要在手机端使用该插件,请将 PC 端 DSH 会话页面切换到【完全权限】**——手机上点不了审批弹窗,会话会一直卡在那里等。
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## 为什么会有这个东西
|
|
15
|
-
|
|
16
|
-
做这个插件的初衷是因为现在AGENT任务执行一轮往往需要很久,而如果出门,则需要手机端来远程操作。
|
|
17
|
-
|
|
18
|
-
但现有的手机端,会把PC的执行步骤非常还原地展现出来。你发一条指令,它可能要想上几分钟、读几十个文件、调好几次工具,最后才给你一个结论。
|
|
19
|
-
|
|
20
|
-
我并不认为这种方式不好。信息完整、过程可控,如果你要认真处理任务,它是对的。
|
|
21
|
-
|
|
22
|
-
但出门在外的时候,就我个人而言,我需要的是另一种东西——**更轻便、更少占用注意力的交互方式**。手机屏幕就那么大,出门时的精力也就那么多。而大部分时候我真正需要的只有一件事:**这一轮跑完了,该发下一条指令了**。而且大部分时间,即使在电脑上,我也很少会去仔细阅读AI做执行过程中的“自言自语”。
|
|
23
|
-
|
|
24
|
-
所以这个插件的界面做得相当「原始」:它完全省略了电脑网页端的执行步骤,只把 AI 最后的结论送到你面前。
|
|
25
|
-
|
|
26
|
-
**某种意义上,它的存在,是为了让你更少地去看它。**
|
|
27
|
-
|
|
28
|
-
你可以更加自由地支配自己的时间,而不是当你和女儿玩耍时,疑惑出游时,被困在那个小小的屏幕里。
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
## 安装
|
|
33
|
-
|
|
34
|
-
```powershell
|
|
35
|
-
dsh plugin --profile web add github:xingzhen199186/dsh-mini-remote
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
包发布到 npm 之后,直接写包名也行:
|
|
39
|
-
|
|
40
|
-
```powershell
|
|
41
|
-
dsh plugin --profile web add dsh-mini-remote
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
手上有源码目录的话,把目录指给它也一样:
|
|
45
|
-
|
|
46
|
-
```powershell
|
|
47
|
-
dsh plugin --profile web add <你放源码的目录>
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
装完**必须重启一次 DSH**,设置页里才会出现新东西。重启后左栏会多出「手机遥控」,启动日志里也会打印手机访问地址和密码。
|
|
51
|
-
|
|
52
|
-
---
|
|
53
|
-
|
|
54
|
-
## 配置:三条路,按需要选
|
|
55
|
-
|
|
56
|
-
三条连接方式**同时存在**,选一条就行,也可以都开着。
|
|
57
|
-
|
|
58
|
-
打开 DSH 设置(侧边栏最下面)→ 左栏「手机遥控」。这一页上每条路都有一个二维码和一条链接,二维码底下那行小字写着**什么时候用这条**,看它就够了。
|
|
59
|
-
|
|
60
|
-
### 一、内网:只在家里用
|
|
61
|
-
|
|
62
|
-
手机和电脑连同一个 Wi-Fi 时,扫「内网」那个码就能用。**不需要任何额外安装**,最省事的一条。
|
|
63
|
-
|
|
64
|
-
代价是出了门就连不上——手机一切到流量,这条就断了。
|
|
65
|
-
|
|
66
|
-
### 二、Tailscale:出门也能连,地址还固定
|
|
67
|
-
|
|
68
|
-
电脑和手机都装上 [Tailscale](https://tailscale.com)、登录同一个账号,配对页上就会自动多出这一条。地址固定,配一次以后不用再管。
|
|
69
|
-
|
|
70
|
-
代价是两边都要装一个 App。如果你不介意装,**出门在外这条最稳**。
|
|
71
|
-
|
|
72
|
-
### 三、公网访问:什么都不用装
|
|
73
|
-
|
|
74
|
-
配对页最底下有个开关,打开它,Cloudflare 会给你一个公网网址。手机在任何网络下都能连,**不用装任何东西**。
|
|
75
|
-
|
|
76
|
-
两个代价,先知道再决定:
|
|
77
|
-
|
|
78
|
-
- **地址每次重启 DSH 都会换**,换了得回来重新扫一次码。
|
|
79
|
-
- **第一次打开会慢**——它在下载一个五十兆左右的组件,只下这一次。这期间状态那行会显示「正在打开…」,那是正常的,不是卡死。注册偶尔会失败,插件会自动重试 3 次。
|
|
80
|
-
|
|
81
|
-
打开之后**等半分钟再扫**。Cloudflare 需要一点时间把这个地址公布出去,太早扫会提示打不开——那不是坏了,是还没到时候。
|
|
82
|
-
|
|
83
|
-
---
|
|
84
|
-
|
|
85
|
-
<img width="1314" height="2186" alt="screenshot_20260922_233535_com huawei hmos brows" src="https://github.com/user-attachments/assets/8befa186-3e20-4c0e-8efc-8ea523f77648" />
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
## 手机端能做什么
|
|
89
|
-
|
|
90
|
-
打开链接就是全部界面:一个输入框,和最新的一条回复。
|
|
91
|
-
|
|
92
|
-
**发指令。** 打字发出去,AI 就在电脑上开始干活。
|
|
93
|
-
|
|
94
|
-
**等结论。** 跑的时候上面显示「运行中…」。这期间你可以接着发,指令会排队。任务结束,结论直接出现在页面上,同时响一声、震一下。
|
|
95
|
-
|
|
96
|
-
**两种显示模式。** 点右上角的 ⚙ 切换。「单帧」(默认)整屏只留最新一条回复,适合「我就想看这次的结果」;「聊天」是一来一回的气泡列表,适合连着追问好几轮。用一阵子你会知道自己偏哪个。
|
|
97
|
-
|
|
98
|
-
**看的是结论,不是过程。** 页面上没有工具调用链、没有文件 diff、没有审批弹窗——**需要你点确认的操作,手机上点不了**,得回电脑上处理。它也不替你在电脑上开新会话、切模型、改配置。它的定位是一根遥控器:电视还得在那儿开着,遥控器才有用。
|
|
99
|
-
|
|
100
|
-
---
|
|
101
|
-
|
|
102
|
-
## 常见问题
|
|
103
|
-
|
|
104
|
-
**扫码之后打不开。**
|
|
105
|
-
先看手机和电脑是不是同一个 Wi-Fi。
|
|
106
|
-
|
|
107
|
-
**出门扫「公网」那条打不开。**
|
|
108
|
-
等半分钟再试。还不行的话,配对页上会直接显示原因。最常见的一种是**电脑上开着代理或 VPN**——Clash 这类软件的「TUN 模式」会把隧道的连接掐断,进程看着好好的,其实一条连接都没建立起来,先把它关掉再试。还有一种:这条网络就是走不通 Cloudflare(国内时好时坏,不是你哪里配错了),那就别在公网上耗着,**改用 Tailscale 那条**。
|
|
109
|
-
|
|
110
|
-
**显示「未连接」。**
|
|
111
|
-
手机切后台再回来经常会这样,页面会自己重连,等几秒;还不行就进设置点「重连」。
|
|
112
|
-
|
|
113
|
-
**回复到了,但没声音。**
|
|
114
|
-
声音要你先碰过页面才会响,点一下就行。
|
|
115
|
-
|
|
116
|
-
**手机上一直让我输密码。**
|
|
117
|
-
说明你打开的链接里没带密码(比如自己手打的地址)。回电脑上重新扫一次二维码就好。
|
|
118
|
-
|
|
119
|
-
**想换一个自己记得住的密码。**
|
|
120
|
-
在「设置 → 手机遥控」的密码那一块直接改,**至少 12 位**。改完要重启一次 DSH,重启之后手机上要重新扫一次码。
|
|
121
|
-
|
|
122
|
-
---
|
|
123
|
-
|
|
124
|
-
## 安全
|
|
125
|
-
|
|
126
|
-
**配对页上带着密码,别截图发出去。** 密码直接编在二维码里,扫完不用手敲——方便是方便,代价是谁拿到这张图谁就能进。
|
|
127
|
-
|
|
128
|
-
除此之外:所有接口都要密码,密码比对用定长比较;同一来源一分钟错 5 次就挡一分钟;配对信息**只允许本机读取**,同一个 Wi-Fi 下的其他设备拿不到;公网隧道默认关着,要你自己在设置页里打开;手机拿不到你的文件系统。
|
|
129
|
-
|
|
130
|
-
---
|
|
131
|
-
|
|
132
|
-
## 致谢
|
|
133
|
-
|
|
134
|
-
这个插件在设计思路上参考过 [dsh-pocket](https://www.npmjs.com/package/dsh-pocket)(作者 shaobeichen)——尤其是「每条连接路径各配一条链接、一个二维码、一个密码」这种配置形态。
|
|
135
|
-
|
|
136
|
-
## 许可
|
|
137
|
-
|
|
138
|
-
MIT
|