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,153 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
+ const { execFileSync } = require('node:child_process')
6
+
7
+ const DEFAULT_OLDER_THAN_MINUTES = 30
8
+ const MINUTES = 60 * 1000
9
+
10
+ const { envelopeValue } = require('./mobile/envelope.js')
11
+
12
+ function asItems(response) {
13
+ const value = envelopeValue(response)
14
+ return (value && Array.isArray(value.items) ? value.items : [])
15
+ }
16
+
17
+ function asWorkspaceState(response) {
18
+ const value = envelopeValue(response)
19
+ return value && typeof value === 'object' ? value : {}
20
+ }
21
+
22
+ function projectItems(items, cwd) {
23
+ return items.filter(item => item?.cwd === cwd)
24
+ }
25
+
26
+ function candidates(items, archivedSessionIds, args, now = Date.now()) {
27
+ const ids = new Set(Array.isArray(args.sessionIds) ? args.sessionIds.filter(id => typeof id === 'string' && id) : [])
28
+ const cutoff = now - (Number.isFinite(args.olderThanMinutes) ? args.olderThanMinutes : DEFAULT_OLDER_THAN_MINUTES) * MINUTES
29
+ return items.filter(item => {
30
+ if (item?.blank || item?.running || typeof item?.sessionId !== 'string') return false
31
+ if (ids.size > 0) return ids.has(item.sessionId)
32
+ if (typeof args.cwd === 'string' && args.cwd && item.cwd !== args.cwd) return false
33
+ if (!Number.isFinite(item.updatedAt) || item.updatedAt >= cutoff) return false
34
+ if (args.action === 'delete' && !archivedSessionIds.has(item.sessionId)) return false
35
+ return true
36
+ })
37
+ }
38
+
39
+ function projectCount(items, cwd) {
40
+ return projectItems(items, cwd).filter(item => !item?.blank).length
41
+ }
42
+
43
+ const { dshHome } = require('./shared/paths')
44
+
45
+ function readHeader(file) {
46
+ try {
47
+ const firstLine = execFileSync('zstd', ['-dc', file], { encoding: 'utf8', maxBuffer: 16384 }).split('\n', 1)[0]
48
+ return JSON.parse(firstLine)
49
+ } catch {
50
+ return undefined
51
+ }
52
+ }
53
+
54
+ function sessionFiles(root) {
55
+ const result = []
56
+ const visit = directory => {
57
+ let entries
58
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch { return }
59
+ for (const entry of entries) {
60
+ const file = path.join(directory, entry.name)
61
+ if (entry.isDirectory()) visit(file)
62
+ else if (entry.name === 'session.jsonl.zstd') result.push(file)
63
+ }
64
+ }
65
+ visit(root)
66
+ return result
67
+ }
68
+
69
+ function deleteSessionData(sessionIds) {
70
+ const ids = new Set(sessionIds)
71
+ const removed = []
72
+ const root = path.join(dshHome(), 'sessions')
73
+ for (const file of sessionFiles(root)) {
74
+ const header = readHeader(file)
75
+ if (!header || !ids.has(header.id)) continue
76
+ try {
77
+ fs.rmSync(path.dirname(file), { recursive: true, force: true })
78
+ removed.push(header.id)
79
+ } catch (error) {
80
+ throw new Error(`删除会话 ${header.id} 失败:${error.message}`)
81
+ }
82
+ }
83
+ const cacheRoot = path.join(dshHome(), 'storages', 'session_projcache', 'sessions')
84
+ for (const id of ids) {
85
+ for (const name of [`${id}.json`, `session-${id}.json`]) {
86
+ try { fs.rmSync(path.join(cacheRoot, name), { force: true }) } catch { /* cache cleanup is best effort */ }
87
+ }
88
+ }
89
+ return removed
90
+ }
91
+
92
+ async function cleanupSessions(ctx, args) {
93
+ const list = ctx.apiProxy?.sessions?.list
94
+ const workspaceList = ctx.apiProxy?.workspace?.list
95
+ const archive = ctx.apiProxy?.workspace?.archiveSession
96
+ if (typeof list !== 'function' || typeof workspaceList !== 'function' || typeof archive !== 'function') throw new Error('会话清理能力不可用')
97
+ // 兼容桥总是提供这三个方法(它就是 apiProxy 的实现),所以「方法在不在」不再是宿主
98
+ // 能力探针 —— 真正要看的是调用结果:宿主没这个能力时会回 host-unavailable。
99
+ const listResponse = await list({ rpcId: 'dsh-mobile-plugin-session-cleanup', method: 'session.list', payload: {} })
100
+ const workspaceResponse = await workspaceList({ rpcId: 'dsh-mobile-plugin-session-cleanup', method: 'workspace.list', payload: {} })
101
+ for (const [name, response] of [['session.list', listResponse], ['workspace.list', workspaceResponse]]) {
102
+ const failure = response && response.result && response.result.ok === false ? response.result.error : null
103
+ if (failure) throw new Error(`${name} 失败:${failure.message || failure.code || '未知错误'}`)
104
+ }
105
+ const items = asItems(listResponse)
106
+ const workspace = asWorkspaceState(workspaceResponse)
107
+ const archived = new Set(Array.isArray(workspace.archivedSessionIds) ? workspace.archivedSessionIds : [])
108
+ const selected = candidates(items, archived, args)
109
+ const cwd = args.cwd || selected.find(item => item.cwd)?.cwd || ''
110
+ if (args.minProjectSessions && cwd && projectCount(items, cwd) < args.minProjectSessions) return { action: args.action, cwd, selected: 0, archived: 0, deleted: 0, failed: 0, sessionIds: [] }
111
+ if (args.action === 'archive') {
112
+ let count = 0
113
+ const failures = []
114
+ for (const item of selected) {
115
+ try {
116
+ const result = await archive({ rpcId: 'dsh-mobile-plugin-session-cleanup', method: 'workspace.archiveSession', payload: { sessionId: item.sessionId } })
117
+ const failure = result && result.result && result.result.ok === false ? result.result.error : null
118
+ if (failure) throw new Error(failure.message || '归档被拒绝')
119
+ count += 1
120
+ } catch (error) { failures.push({ sessionId: item.sessionId, error: error.message }) }
121
+ }
122
+ return { action: args.action, cwd, selected: selected.length, archived: count, deleted: 0, failed: failures.length, failures, sessionIds: selected.map(item => item.sessionId) }
123
+ }
124
+ if (args.confirm !== true) throw new Error('永久删除需要 confirm: true')
125
+ const ids = selected.map(item => item.sessionId)
126
+ const deletedIds = deleteSessionData(ids)
127
+ return { action: args.action, cwd, selected: ids.length, archived: 0, deleted: deletedIds.length, failed: ids.length - deletedIds.length, sessionIds: deletedIds }
128
+ }
129
+
130
+ function registerSessionCleanupTool(ctx) {
131
+ const { defineTool } = require('@deepseek-ai/dsh-tools')
132
+ ctx.tools.register(defineTool({
133
+ name: 'session_cleanup',
134
+ description: 'Archive or permanently delete old DSH sessions. Delete only targets already archived sessions and requires confirm=true.',
135
+ parameters: {
136
+ action: { type: 'string', required: true, enum: ['archive', 'delete'], description: 'archive hides sessions; delete permanently removes their logs and local projection cache.' },
137
+ cwd: { type: 'string', description: 'Project directory to target. Required when selecting by age across multiple projects.' },
138
+ olderThanMinutes: { type: 'number', description: 'Only target sessions not updated within this many minutes. Defaults to 30.' },
139
+ minProjectSessions: { type: 'integer', description: 'Skip the operation when the selected project has fewer sessions than this number.' },
140
+ sessionIds: { type: 'array', items: { type: 'string' }, description: 'Explicit session IDs. When provided, age and project filters are ignored.' },
141
+ confirm: { type: 'boolean', description: 'Must be true for permanent deletion.' },
142
+ },
143
+ output: {
144
+ schema: { type: 'object', additionalProperties: false, properties: {
145
+ action: { type: 'string' }, cwd: { type: 'string' }, selected: { type: 'integer' }, archived: { type: 'integer' }, deleted: { type: 'integer' }, failed: { type: 'integer' }, sessionIds: { type: 'array', items: { type: 'string' } }, failures: { type: 'array', items: { type: 'object' } },
146
+ } },
147
+ render: (_args, value) => [{ type: 'text', text: `${value.action === 'archive' ? 'Archived' : 'Deleted'} ${value.action === 'archive' ? value.archived : value.deleted} sessions.` }],
148
+ },
149
+ execute: (args) => cleanupSessions(ctx, args),
150
+ }))
151
+ }
152
+
153
+ module.exports = { cleanupSessions, registerSessionCleanupTool }
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 凭据包 `dsh-mobile-credentials` 的入口。
5
+ *
6
+ * 它在盘上有**两代布局**:
7
+ *
8
+ * - 新布局:嵌在插件包内 —— `<插件包>/dsh-mobile-credentials/lib/credentials.js`
9
+ * (一代自包含,回滚时凭据版本跟着一起回滚);
10
+ * - 旧布局:与插件包同级 —— `node_modules/dsh-mobile-credentials/…`,
11
+ * 代际目录下则是 `<代>/dsh-mobile-credentials/…`。
12
+ *
13
+ * 往哪里落由**外壳**(`lib/shell/ota.js` 的资产重映射)决定,而外壳在进程启动时就被 Node
14
+ * 缓存住、换不掉。布局迁移那一次,旧外壳把凭据放进同级目录、新实现却按嵌套路径 require,
15
+ * 结果是「装完激活才炸 Cannot find module」——热更半装成功,留下的一代还是残缺的。
16
+ *
17
+ * 所以这里两条路都试,谁在就用谁:旧外壳配新实现也能跑起来,布局迁移不再是地雷。
18
+ */
19
+
20
+ /** 新布局:`<插件包>/dsh-mobile-credentials/`(本文件在 `<插件包>/lib/shared/`)。 */
21
+ const NESTED = '../../dsh-mobile-credentials/lib/credentials.js';
22
+ /** 旧布局:与插件包同级。 */
23
+ const SIBLING = '../../../dsh-mobile-credentials/lib/credentials.js';
24
+
25
+ let cached = null;
26
+
27
+ /** 找到凭据包入口;两条布局都没有时抛 MODULE_NOT_FOUND,并说清试过哪两条路。 */
28
+ function credentialsEntry() {
29
+ if (cached) return cached;
30
+ const tried = [];
31
+ for (const candidate of [NESTED, SIBLING]) {
32
+ try {
33
+ cached = require(candidate);
34
+ return cached;
35
+ } catch (error) {
36
+ if (!error || error.code !== 'MODULE_NOT_FOUND') throw error;
37
+ tried.push(candidate);
38
+ }
39
+ }
40
+ const error = new Error(
41
+ `找不到凭据包 dsh-mobile-credentials(试过 ${tried.join('、')}):它要么嵌在插件包内,`
42
+ + '要么与插件包同级。请重新完整安装插件。',
43
+ );
44
+ error.code = 'MODULE_NOT_FOUND';
45
+ throw error;
46
+ }
47
+
48
+ module.exports = { credentialsEntry, CREDENTIALS_CANDIDATES: [NESTED, SIBLING] };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 插件侧 LAN/mobile token(HMAC-SHA256 base64url)共享实现。
5
+ *
6
+ * token 格式:<instanceId>.<exp>.<hmac-sha256(secret, "<instanceId>.<exp>") base64url>
7
+ * 其中 exp 为秒级过期时间戳。signLanToken/verifyLanToken 显式收 ttlSec / secret 参数,
8
+ * 由调用方显式传入各自历史缺省值(本函数不隐藏调用点差异)。
9
+ */
10
+
11
+ const crypto = require('node:crypto');
12
+
13
+ /** 常数时间字符串比较(长度不等直接 false,恰好相等时用 timingSafeEqual)。 */
14
+ function safeEqual(a, b) {
15
+ const ba = Buffer.from(String(a));
16
+ const bb = Buffer.from(String(b));
17
+ return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
18
+ }
19
+
20
+ /** HMAC 迭代中安全地把 secret 规整为字符串(与各调用点历史一致,字符串 secret 等价)。 */
21
+ function hmacPayload(secret, payload) {
22
+ return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
23
+ }
24
+
25
+ /**
26
+ * 签发 LAN/mobile token:<instanceId>.<exp>.<sig>。
27
+ * 缺省 ttlSec=30d(与 relay core / browser core 历史缺省一致),但**消费方必须显式传原值**
28
+ * (LAN 主进程 3600、30d 处 30*24*3600),本函数不隐藏调用点的默认值差异。
29
+ * @param {string} secret lanSecret(~/.dsh/relay.json 密钥)
30
+ * @param {string} instanceId 实例 id(~/.dsh/relay.json)
31
+ * @param {number} ttlSec 有效期(秒),缺省 30*24*3600
32
+ * @param {number} [nowSec] 秒级「当前时间」覆盖(测试/主进程签名用);缺省 Date.now()/1000
33
+ * @returns {string}
34
+ */
35
+ function signLanToken(secret, instanceId, ttlSec = 30 * 24 * 3600, nowSec) {
36
+ const sec = Number(ttlSec);
37
+ const ttl = Number.isFinite(sec) && sec > 0 ? sec : 30 * 24 * 3600;
38
+ const now = Number.isFinite(nowSec) && nowSec >= 0 ? nowSec : Math.floor(Date.now() / 1000);
39
+ const exp = now + ttl;
40
+ const payload = `${instanceId}.${exp}`;
41
+ const sig = hmacPayload(secret, payload);
42
+ return `${payload}.${sig}`;
43
+ }
44
+
45
+ /**
46
+ * 校验 token:实例匹配、未过期、签名一致(常数时间)。
47
+ * @param {string} secret lanSecret
48
+ * @param {string} token <instanceId>.<exp>.<sig>
49
+ * @param {string} instanceId 期望实例 id
50
+ * @returns {boolean}
51
+ */
52
+ function verifyLanToken(secret, token, instanceId) {
53
+ if (!token) return false;
54
+ const [id, expStr, sig] = String(token).split('.');
55
+ if (id !== instanceId || !expStr || !sig) return false;
56
+ const exp = Number(expStr);
57
+ if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return false;
58
+ const expect = hmacPayload(secret, `${id}.${expStr}`);
59
+ return safeEqual(expect, sig);
60
+ }
61
+
62
+ module.exports = { signLanToken, verifyLanToken, safeEqual };
@@ -0,0 +1,35 @@
1
+ 'use strict'
2
+
3
+ // 「读己所写」(read-your-writes) 标记:移动端做完一次写操作之后紧接着的那次列表刷新,
4
+ // 必须反映出这次写入,不能被任何「代读副本」顶掉。
5
+ //
6
+ // 背景(真机缺陷,探针实测):移动端的读请求会经过两层代读副本——
7
+ // 1. sidecar 的镜像快照(mirror-snapshot.cjs 判定新鲜度)直接把 /sessions、
8
+ // /workspaces 回给手机,根本不连宿主插件;桌面侧 500ms 推一次,所以正常最多
9
+ // 旧 500ms;
10
+ // 2. 宿主插件自己的 10s 会话列表缓存(lib/mobile/sessions.js 的
11
+ // SESSION_LIST_CACHE_MS)。
12
+ // 两层对「每 15s 一次的轮询」都无所谓,但写操作之后的第一次刷新撞上任何一层,就会把
13
+ // 用户刚做完的事显示成没生效。
14
+ //
15
+ // 实测(Mac 宿主 + MI 9,时间戳相对点击「归档」按钮):
16
+ // + 311ms HOST archive-session session-63acd828-… ← 宿主侧已经改对了
17
+ // + 375ms SNAPSHOT /api/mobile/v1/sessions fresh ← App 的刷新读到 +286ms 推来的旧镜像
18
+ // +17233ms SNAPSHOT /api/mobile/v1/sessions fresh ← 直到 15s 轮询才拿到新列表
19
+ //
20
+ // 于是约定 `?fresh=1`:带这个标记的读跳过所有代读副本,直接回源宿主插件。
21
+ // 轮询、首屏、下拉刷新不带,快路径照旧。
22
+ //
23
+ // 权威实现在本文件;`dsh-mobile-server/lib/fresh-request.js` 是它的手工同步副本,
24
+ // 两侧由 `test/shared-helpers-parity.test.cjs` 钉住。**改一边必须改另一边。**
25
+ // 中转侧同样必须认这个标记:中转自己也持有镜像与响应缓存,它若把带 fresh 的读
26
+ // 当普通读缓存掉,App 的下拉刷新就失去了唯一的自救手段。
27
+
28
+ const FRESH_QUERY_RE = /(?:^|[?&])fresh=1(?:&|$)/
29
+
30
+ /** 判断一次请求(完整 url 或 path,可带 query)是否要求「必须读出最新状态」。 */
31
+ function isFreshRequest(target) {
32
+ return FRESH_QUERY_RE.test(String(target || ''))
33
+ }
34
+
35
+ module.exports = { FRESH_QUERY_RE, isFreshRequest }
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 插件侧 HTTP 小工具共享实现(sendJson / 读取 body)。
5
+ *
6
+ * 中转服务器(dsh-mobile-server)独立部署,保留自身实现,仅作参考。
7
+ */
8
+
9
+ /**
10
+ * 写 JSON 响应(UTF-8)。连接已关闭时静默忽略。
11
+ * @param {object} res node:http ServerResponse
12
+ * @param {number} status HTTP 状态码
13
+ * @param {object} body 响应体(JSON.stringify)
14
+ */
15
+ function sendJson(res, status, body) {
16
+ try {
17
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
18
+ res.end(JSON.stringify(body));
19
+ } catch { /* 连接已关闭 */ }
20
+ }
21
+
22
+ /**
23
+ * 写 HTML 响应(UTF-8)。连接已关闭时静默忽略。
24
+ *
25
+ * 面板页是整页吐给浏览器的,所以必须显式声明 charset:少了它浏览器会按本地编码猜,
26
+ * 中文标题直接变乱码。`no-store` 是刻意的——这份 HTML 就是「改完刷新即生效」的那一份,
27
+ * 缓存住反而会把刚发布的版式藏起来。
28
+ *
29
+ * @param {object} res node:http ServerResponse
30
+ * @param {number} status HTTP 状态码
31
+ * @param {string} html 响应体
32
+ */
33
+ function sendHtml(res, status, html) {
34
+ try {
35
+ res.writeHead(status, {
36
+ 'content-type': 'text/html; charset=utf-8',
37
+ 'cache-control': 'no-store',
38
+ });
39
+ res.end(html);
40
+ } catch { /* 连接已关闭 */ }
41
+ }
42
+
43
+ /**
44
+ * 读取请求体并 JSON.parse;超限/非法 JSON/IO 错误 → null。
45
+ * @param {object} req node:http IncomingMessage
46
+ * @param {number} [maxBytes] 请求体上限(字节),缺省 256KB
47
+ * @returns {Promise<object|null>}
48
+ */
49
+ function readJsonBody(req, maxBytes = 256 * 1024) {
50
+ return new Promise((resolve) => {
51
+ let raw = '';
52
+ req.on('data', (chunk) => {
53
+ raw += chunk;
54
+ if (raw.length > maxBytes) {
55
+ req.destroy();
56
+ resolve(null);
57
+ return;
58
+ }
59
+ });
60
+ req.on('end', () => {
61
+ try {
62
+ resolve(raw.length > 0 ? JSON.parse(raw) : null);
63
+ } catch {
64
+ resolve(null);
65
+ }
66
+ });
67
+ req.on('error', () => resolve(null));
68
+ });
69
+ }
70
+
71
+ module.exports = { sendJson, sendHtml, readJsonBody };
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 清单内容指纹(sessions / workspaces 共用)。
5
+ *
6
+ * 存在的理由:手机端有两件事需要「这份清单和上次是不是同一份」——
7
+ *
8
+ * 1. 数据新鲜度:列表响应带 `revision`,App 把它连同 `generatedAt` 一起存进快照,
9
+ * 就能把「数据是谁在什么时候给的」显示给用户,而不是让用户自己猜;
10
+ * 2. 镜像推送判重(见 lib/mirror/pusher.cjs):后台 500ms tick 每轮都会完整构建
11
+ * sessions + workspaces,内容没变时推送一份字节完全相同的 payload 纯属浪费,
12
+ * 比对 revision 就能安全跳过。
13
+ *
14
+ * 为什么用内容哈希而不是自增序号:插件随桌面 harness 频繁重启,内存里的序号每次
15
+ * 都从 0 开始,重启后第一份快照会因为「序号变了」而被当成新内容重推一遍;内容哈希
16
+ * 跨重启稳定(同样的清单 → 同样的 revision),这才是判重想要的性质。
17
+ *
18
+ * 为什么用 sha1 而不是更省的 32 位哈希:这个值会被用来**跳过推送**,一次碰撞就等于
19
+ * 一份该推的新数据被静默丢掉。sha1 足够便宜(清单量级下每 tick 亚毫秒),不值得为
20
+ * 几个字节冒这个风险。截断到 16 个十六进制字符只是为了让日志短一点。
21
+ */
22
+
23
+ const crypto = require('node:crypto');
24
+
25
+ /**
26
+ * 语义内容的稳定指纹。
27
+ *
28
+ * `JSON.stringify` 保留键的插入顺序,而同一份清单在不同构建路径上键序是稳定的
29
+ * (都由同一段投影代码生成),所以这里不做键排序;调用方换投影字段时自然得到新
30
+ * revision,这正是我们要的(字段变了就是内容变了)。
31
+ *
32
+ * @param {unknown} value 任意可序列化值
33
+ * @returns {string} 16 个十六进制字符;不可序列化时返回空串(调用方按「未知」处理)
34
+ */
35
+ function stableRevision(value) {
36
+ let text;
37
+ try {
38
+ text = JSON.stringify(value);
39
+ } catch {
40
+ return '';
41
+ }
42
+ if (typeof text !== 'string') return '';
43
+ return crypto.createHash('sha1').update(text).digest('hex').slice(0, 16);
44
+ }
45
+
46
+ /** 清单数组的 revision;空清单也有一个稳定的值(不是空串)。 */
47
+ function listRevision(items) {
48
+ return stableRevision(Array.isArray(items) ? items : []);
49
+ }
50
+
51
+ module.exports = { stableRevision, listRevision };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 设备级「清单变了」信号总线:插件内部唯一一条「有内容值得让手机立刻刷新」的通道。
5
+ *
6
+ * 存在的理由(真机现象):侧边栏的实时性此前完全押在设备级 SSE 上,而那条流在宿主的
7
+ * 0.2.0 兼容桥里只订阅了 workspace/follow 与 session/control —— 会话状态变化表现为
8
+ * session/projection 帧,被手机侧白名单(lib/mobile/events.js 的 listRelevantPayload)
9
+ * 丢掉。于是流「连着但一句话都不说」,App 却因为 open 就把兜底轮询从 15s 降到 60s,
10
+ * 侧栏变成一分钟才动一次。
11
+ *
12
+ * 而插件这边**本来就知道列表何时变了**:镜像推送调度器(lib/mirror/pusher.cjs)每
13
+ * 500ms 完整构建一次会话清单,清单自带内容指纹 revision。缺的只是「把这件事说给手机
14
+ * 听」的出口,本模块就是那个出口。
15
+ *
16
+ * 为什么是模块级单例而不是 ctx 上的服务:生产端(pusher.cjs,CJS)与消费端
17
+ * (mobile/events.js,CJS)都通过 require 拿到同一个实例,两边都不需要认识对方;
18
+ * 测试可以 createListSignal() 造一个独立实例注入。
19
+ *
20
+ * 为什么订阅者抛错要被吞掉:bus 的调用方是推送调度器的 500ms tick,一个写坏的 SSE
21
+ * 连接不能把镜像推送整条链拖垮(真故障会从连接自身的 close 反映出来)。
22
+ */
23
+
24
+ const NOOP_RELEASE = () => {};
25
+
26
+ function createListSignal() {
27
+ const listeners = new Set();
28
+ let watchers = 0;
29
+ let revision = '';
30
+ let at = 0;
31
+
32
+ return {
33
+ /**
34
+ * 广播一次清单变更。同一个 revision 连着广播两次只算一次(幂等)。
35
+ *
36
+ * @param {string} nextRevision 清单内容指纹(lib/shared/list-revision.js 的 listRevision)
37
+ * @returns {boolean} 是否真的广播了
38
+ */
39
+ bump(nextRevision) {
40
+ const value = typeof nextRevision === 'string' ? nextRevision : '';
41
+ if (value && value === revision) return false;
42
+ revision = value;
43
+ at = Date.now();
44
+ for (const listener of [...listeners]) {
45
+ try { listener({ revision, at }); } catch { /* 订阅者自己的异常不该影响推送方 */ }
46
+ }
47
+ return true;
48
+ },
49
+
50
+ /** 订阅变更;返回退订函数(可重复调用)。 */
51
+ onBump(listener) {
52
+ if (typeof listener !== 'function') return NOOP_RELEASE;
53
+ listeners.add(listener);
54
+ let released = false;
55
+ return () => {
56
+ if (released) return;
57
+ released = true;
58
+ listeners.delete(listener);
59
+ };
60
+ },
61
+
62
+ /**
63
+ * 登记一个「有人在听」的观众(每条设备级 SSE 连接一个),返回注销函数。
64
+ *
65
+ * watching() 决定推送调度器在**隧道断开**时还要不要空转构建:没有观众时那份快照
66
+ * 没有任何去处,构建纯属浪费;有观众时它是手机唯一的变更来源,必须继续构建。
67
+ */
68
+ watch() {
69
+ watchers += 1;
70
+ let released = false;
71
+ return () => {
72
+ if (released) return;
73
+ released = true;
74
+ watchers = Math.max(0, watchers - 1);
75
+ };
76
+ },
77
+
78
+ watching() { return watchers > 0; },
79
+
80
+ /** 最近一次变更(供诊断面板观测)。 */
81
+ snapshot() { return { revision, at }; },
82
+ };
83
+ }
84
+
85
+ const listSignal = createListSignal();
86
+
87
+ module.exports = { createListSignal, listSignal };
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 插件与中转共用的 DSH 数据目录路径。
5
+ *
6
+ * 原先这里还包含内置浏览器、桌面更新器、配对授权弹窗握手的落盘路径,
7
+ * 那些都属于已删除的桌面端外壳,本模块已收窄为插件真正使用的两处。
8
+ * 均为纯函数(按调用时 env 解析,测试可注入 DSH_HOME)。
9
+ */
10
+
11
+ const os = require('node:os');
12
+ const path = require('node:path');
13
+
14
+ /** DSH 数据目录:DSH_HOME 可覆盖,缺省 ~/.dsh。 */
15
+ function dshHome() {
16
+ return process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
17
+ }
18
+
19
+ /** 远程连接(relay)插件状态文件:~/.dsh/relay.json */
20
+ function relayStateFile() {
21
+ return path.join(dshHome(), 'relay.json');
22
+ }
23
+
24
+ module.exports = {
25
+ dshHome,
26
+ relayStateFile,
27
+ };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 设备级「某个会话的正文水位涨了」信号总线。
5
+ *
6
+ * 与 list-signal.js(清单变了)是同一形状的第二条通道,存在的理由是对话页的第二种场景:
7
+ * 手机上**没有打开**某个会话时,那条会话的正文此前完全没人订阅 —— 只有打开的会话才建
8
+ * `session/stream`(app/src/hooks/useChatStream.ts 的唯一调用点),所以「在别的地方盯着
9
+ * 列表」时,运行中的助手回复只能等下一次点开该会话才被拉下来。
10
+ *
11
+ * 而插件这边本来就把正文推到了中转镜像:镜像调度器(lib/mirror/pusher.cjs)每轮为高优先
12
+ * 会话(运行中 / 5 分钟内动过)构建 200 条消息快照,冷会话每 500ms 轮转补一个。缺的只是
13
+ * 「这个会话的正文又长了,你(手机)可以来取」这一句通知,本模块就是那个出口。
14
+ *
15
+ * 与 list-signal 的关键差别:清单信号是**全局**的(一个 revision),这条是**按会话**的
16
+ * (sessionId → 已广播到的消息水位 seq)。水位取的是快照里**真正装上的消息的最大 seq**
17
+ * (见 pusher 的 messageWatermarkOf),也就是 App 侧会话缓存的水位口径(同一个 seq 空间),
18
+ * 手机拿它和自己的缓存水位一比就知道该不该增量拉一次。
19
+ *
20
+ * 为什么是模块级单例:生产端(mirror/pusher.cjs,CJS)与消费端(mobile/events.js,CJS)
21
+ * 都 require 到同一个实例,两边不需要认识对方;测试可以 createSessionSignal() 造独立实例。
22
+ * 为什么订阅者抛错要吞掉:bus 的调用方是镜像推送链,一条写坏的 SSE 连接不能把推送拖垮
23
+ * (真故障会从连接自身的 close 反映出来)。
24
+ */
25
+
26
+ const NOOP_RELEASE = () => {};
27
+
28
+ /**
29
+ * 最多记多少会话的水位。
30
+ *
31
+ * 键是 sessionId、只增不减:一个长期运行的插件会见过成千上万个会话(归档的、一次性的),
32
+ * 而水位只用来判「这次广播是不是新的」,旧会话的水位没有任何价值。超限时丢最早活动的那条。
33
+ */
34
+ const MAX_TRACKED = 512;
35
+
36
+ function createSessionSignal() {
37
+ const listeners = new Set();
38
+ /** sessionId -> { seq, at }:最近一次广播出去的水位。 */
39
+ const watermarks = new Map();
40
+
41
+ const pruneOldest = () => {
42
+ let oldestKey = null;
43
+ let oldestAt = Infinity;
44
+ for (const [key, value] of watermarks) {
45
+ if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key; }
46
+ }
47
+ if (oldestKey !== null) watermarks.delete(oldestKey);
48
+ };
49
+
50
+ return {
51
+ /**
52
+ * 广播一个会话的正文水位。
53
+ *
54
+ * 同一个会话的水位不变(或更低)时什么都不做(幂等):镜像推送每轮都带着所有高优先
55
+ * 会话的快照,没有这一层判重就会变成每 500ms 给手机发一遍同样的帧。
56
+ *
57
+ * @param {string} sessionId
58
+ * @param {number} seq 快照里真正装上的消息的最大 seq
59
+ * @returns {boolean} 是否真的广播了
60
+ */
61
+ bump(sessionId, seq) {
62
+ const id = typeof sessionId === 'string' ? sessionId : '';
63
+ const value = Number(seq);
64
+ if (!id || !Number.isFinite(value) || value <= 0) return false;
65
+ const previous = watermarks.get(id);
66
+ if (previous && value <= previous.seq) return false;
67
+ const at = Date.now();
68
+ watermarks.set(id, { seq: value, at });
69
+ if (watermarks.size > MAX_TRACKED) pruneOldest();
70
+ for (const listener of [...listeners]) {
71
+ try { listener({ sessionId: id, seq: value, at }); } catch { /* 订阅者自己的异常不该影响推送方 */ }
72
+ }
73
+ return true;
74
+ },
75
+
76
+ /** 订阅水位变化;返回退订函数(可重复调用)。 */
77
+ onBump(listener) {
78
+ if (typeof listener !== 'function') return NOOP_RELEASE;
79
+ listeners.add(listener);
80
+ let released = false;
81
+ return () => {
82
+ if (released) return;
83
+ released = true;
84
+ listeners.delete(listener);
85
+ };
86
+ },
87
+
88
+ /** 某个会话当前已广播到的水位(0 = 还没广播过)。 */
89
+ peek(sessionId) {
90
+ const entry = watermarks.get(sessionId);
91
+ return entry ? entry.seq : 0;
92
+ },
93
+
94
+ /** 全部水位(供诊断面板观测);键为 sessionId,值为 seq。 */
95
+ snapshot() {
96
+ const out = {};
97
+ for (const [key, value] of watermarks) out[key] = value.seq;
98
+ return out;
99
+ },
100
+
101
+ /** 测试用:清空状态。 */
102
+ reset() {
103
+ watermarks.clear();
104
+ },
105
+ };
106
+ }
107
+
108
+ const sessionSignal = createSessionSignal();
109
+
110
+ module.exports = { createSessionSignal, sessionSignal, MAX_TRACKED };