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,246 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * sidecar 子进程的完整生命周期:起停、配置下发、自重启、健康轮询。
5
+ *
6
+ * 这些代码原本散在 apply() 里三段(状态变量、配置下发的串行化、自重启),中间还隔着
7
+ * 一个 200 行的镜像推送调度器。它们共享同一批可变量(sidecarState / sidecarSnapshot /
8
+ * restartInFlight / syncInFlight),拆到一处才看得清「谁改了什么」。
9
+ *
10
+ * 注意扩展名是 .cjs:包根声明 type=module,lib/ 下没有就近的 package.json,所以
11
+ * lib/*.js 全是 ESM,只有 .cjs 是 CommonJS(同 sidecar-facade.cjs)。
12
+ *
13
+ * ⚠️ `sidecar` 是**会被整体替换**的 let(自重启换掉 facade 实例),所以对外一律给
14
+ * 取值器或 accessor,绝不能把实例本身传出去——传出去就是一份永远指向旧实例的快照。
15
+ */
16
+
17
+ const path = require('node:path');
18
+ const { randomBytes } = require('node:crypto');
19
+
20
+ const { SidecarFacade } = require('./sidecar-facade.cjs');
21
+
22
+ const HEALTH_POLL_INTERVAL_MS = 10000;
23
+
24
+ /**
25
+ * @param {object} deps
26
+ * @param {import('cordis').Context} deps.ctx
27
+ * @param {string} [deps.pluginLibDir] 本代实现所在的 `lib/` 目录。默认按本文件位置推;
28
+ * 之所以要能注入,是因为实现现在住在代际目录(`lib/.live/<版本>/lib/`)里,
29
+ * `require.resolve` 会解析到代自己那一份,而调用方手上的目录才是最准的。
30
+ * @param {() => string} deps.lanPortInUse 取值器:lanPortAdjusted/effectiveLanPort 会被重新赋值
31
+ * @param {() => Promise<number>} deps.resolveLanPort
32
+ * @param {(cfg: object) => Promise<object>} deps.buildConfig 把它组装的配置交给谁下发(index.js 组)
33
+ */
34
+ function createSidecarLifecycle(deps) {
35
+ const { ctx, lanPortInUse, resolveLanPort, buildConfig } = deps;
36
+
37
+ const sidecarToken = randomBytes(32).toString('hex')
38
+ // `let` 而非 `const`:sidecar 自重启要换掉整个实例(见 restartSidecar)。
39
+ // 下面所有 `sidecar.xxx` 都是调用时读这个变量,换实例后自然跟着走——这也是
40
+ // 刻意的:宁可让每次调用多一次变量查找,也不要把 facade 引用散进各个闭包,
41
+ // 那样重启就一定有一半调用点还握着旧实例。
42
+ // The child-process bridge is used by the sidecar transport; Cordis services remain injected read-only.
43
+ let sidecarState = 'starting'
44
+ let sidecarSnapshot = { tunnel: { state: 'off', error: null }, lan: { state: 'off', error: null } }
45
+ // worker 意外退出时的唯一出口:把状态打回「不健康」,而不是让它冻在最后一帧。
46
+ // 冻住的后果是 /api/relay/status 继续报 tunnel: connected / lan: listening,
47
+ // 宿主于是照着「隧道是通的」去推镜像,一路 503;局域网端口上却早就没人监听。
48
+ // status() 如实报「off + 原因」,至少人一眼能看出是没起来还是掉线了。
49
+ //
50
+ // 声明顺序是刻意的:这三个 const/let 必须排在 new SidecarFacade() 前面,因为构造
51
+ // 时要把 markSidecarDown 交进去,箭头函数还没初始化就被引用会直接 TDZ 报错。
52
+ const markSidecarDown = (error) => {
53
+ const message = error instanceof Error ? error.message : String(error)
54
+ sidecarState = `error: ${message}`
55
+ sidecarSnapshot = { tunnel: { state: 'off', error: message }, lan: { state: 'off', error: message } }
56
+ }
57
+ // 「隧道刚连上」的订阅者。声明必须排在 new SidecarFacade() 前面:构造时交进去的
58
+ // applyTunnelState 会在 worker 报状态跳变时立刻用到它(和 markSidecarDown 同理)。
59
+ const tunnelUpSubscribers = new Set()
60
+ // 「隧道刚连上」的边沿判定只有这一处实现,两个来源共用:worker 的即时状态上报
61
+ // (applyTunnelState)与 10s 健康轮询(权威但滞后)。共用是必须的——一次重连只能补推
62
+ // 一次全量镜像,两个来源各自记边沿就会重复补推;而隧道「断又连」若整段落在两次轮询
63
+ // 之间,轮询根本看不到跳变,只有即时上报能抓住它。
64
+ let lastTunnelConnected = false
65
+ const noteTunnelConnected = (connected) => {
66
+ if (connected && !lastTunnelConnected) { for (const fn of tunnelUpSubscribers) fn() }
67
+ lastTunnelConnected = connected
68
+ }
69
+ /**
70
+ * worker 报来的状态跳变(lib/sidecar-facade-worker.cjs 的 publishState()):立刻回写
71
+ * sidecarSnapshot,不等 10s 健康轮询。
72
+ *
73
+ * 轮询仍然是权威(只有它能发现「worker 整个没了」——那时 IPC 也断了,没人会推),但它是
74
+ * 滞后的:隧道真断之后宿主最多 10s 仍认为已连接,期间照推镜像、每次撞 sidecar-facade 的
75
+ * 5s 超时,该会话的单飞门被占住,事件只能合并成一次补推。
76
+ */
77
+ const applyTunnelState = (message) => {
78
+ if (!message || !message.tunnel) return
79
+ sidecarSnapshot = { ...sidecarSnapshot, tunnel: message.tunnel, ...(message.lan ? { lan: message.lan } : {}) }
80
+ noteTunnelConnected(message.tunnel.state === 'connected')
81
+ }
82
+ let sidecar = new SidecarFacade({ parentPort: ctx.webServer.port, token: sidecarToken, onDown: markSidecarDown, onState: applyTunnelState })
83
+ // sidecar 自重启的账:几次、什么时候、上次成没成。自重启是运维动作(改完代码不想
84
+ // 逼用户重启整个 DSH),出问题时「刚才那次重启到底把哪一段换掉了」必须查得到。
85
+ let sidecarRestarts = 0
86
+ let sidecarRestartedAt = 0
87
+ let sidecarRestartError = null
88
+ let restartInFlight = null
89
+
90
+ // 隧道是否真的连上了中转。`sidecarSnapshot` 由 10s 一次的健康轮询回写,是权威状态;
91
+ // `configure()` 返回的瞬时快照不算(那时 startTunnel 的 start() 还没 resolve)。
92
+ const tunnelConnected = () => sidecarSnapshot?.tunnel?.state === 'connected'
93
+
94
+ // 配置下发的串行化 + 合并。
95
+ //
96
+ // 每一次同步都会让 worker 完整 stopServices()+重启隧道与局域网代理,而
97
+ // 控制面路由(toggle/regenerate/config/revoke-all)与 `settings.watch` 触发的
98
+ // `applyServices()` 会为同一次修改各请求一次同步:旧实现于是每次都重启两轮,
99
+ // 并且处理函数读到的还是重启**前**的 sidecarSnapshot。这里让并发/短时间内重复的
100
+ // 请求复用同一个在飞 Promise,并记录一次待重跑,确保最终下发的是最新配置。
101
+ let syncInFlight = null
102
+ let syncQueued = false
103
+ const applySidecarConfig = async () => {
104
+ await resolveLanPort()
105
+ sidecarSnapshot = await sidecar.configure(buildConfig(lanPortInUse()))
106
+ sidecarState = 'healthy'
107
+ return sidecarSnapshot
108
+ }
109
+ const syncSidecar = () => {
110
+ if (syncInFlight) { syncQueued = true; return syncInFlight }
111
+ syncInFlight = applySidecarConfig()
112
+ .catch(error => {
113
+ sidecarState = `error: ${error instanceof Error ? error.message : String(error)}`
114
+ throw error
115
+ })
116
+ .finally(() => {
117
+ syncInFlight = null
118
+ if (syncQueued) { syncQueued = false; void syncSidecar().catch(() => undefined) }
119
+ })
120
+ return syncInFlight
121
+ }
122
+
123
+ // ── sidecar 自重启 ──────────────────────────────────────────────────
124
+ //
125
+ // 为什么要它:sidecar worker 是本插件 fork 出去的**子进程**,它的代码(worker 本体、
126
+ // lan-proxy、relay/tunnel、relay/protocol)完全由磁盘决定,re-fork 一次就是新的。
127
+ // 也就是说这一整层从来不需要重启 DSH 宿主——之前每次改完都要用户手动重启宿主,
128
+ // 是白等的。facade 自己(sidecar-facade.cjs)跑在宿主进程里,re-fork 换不掉它,
129
+ // 所以重启时额外把本包 lib/ 从 require 缓存里摘掉再 require 一遍,两层一起换新。
130
+ //
131
+ // 换不掉的:lib/index.js、lib/client.js、lib/mobile/* 这些宿主侧模块本身。它们已经
132
+ // 在 fiber 上跑着,V8 里的引用摘不掉。status().sidecar.scope 把这件事如实报出来,
133
+ // 免得以为「重启了」就等于「所有改动都生效了」。
134
+ // 本代 `lib/` 目录。默认用本文件位置推(同目录就是 lib/),允许调用方覆盖成
135
+ // 「当前活跃的那一代」——代际目录里的副本推出来的是它自己,两者通常相同,
136
+ // 但显式传入能让「清缓存清的是不是在跑的那一份」这件事变得确定。
137
+ const PLUGIN_LIB_DIR = (deps.pluginLibDir || __dirname).replace(/[\\/]+$/, '') + path.sep
138
+ const purgePluginLibCache = () => {
139
+ let purged = 0
140
+ for (const key of Object.keys(require.cache)) {
141
+ if (!key.startsWith(PLUGIN_LIB_DIR)) continue
142
+ delete require.cache[key]
143
+ purged += 1
144
+ }
145
+ return purged
146
+ }
147
+ const restartSidecar = async () => {
148
+ // 串行化:两次并发重启会互相 stop 掉对方刚起的 worker。
149
+ if (restartInFlight) return restartInFlight
150
+ restartInFlight = (async () => {
151
+ const before = { tunnel: sidecarSnapshot.tunnel, lan: sidecarSnapshot.lan, state: sidecarState }
152
+ let purged = 0
153
+ try {
154
+ await sidecar.stop()
155
+ purged = purgePluginLibCache()
156
+ // 重新 require 的是 facade 及其依赖(relay/tunnel 等)——缓存已清,拿到的
157
+ // 是磁盘上的新代码。worker 更是直接 re-fork,天然是新的。
158
+ const Fresh = require('./sidecar-facade.cjs').SidecarFacade
159
+ sidecar = new Fresh({ parentPort: ctx.webServer.port, token: sidecarToken, onDown: markSidecarDown })
160
+ sidecarState = 'starting'
161
+ await sidecar.start()
162
+ await syncSidecar()
163
+ sidecarState = 'healthy'
164
+ sidecarRestarts += 1
165
+ sidecarRestartedAt = Date.now()
166
+ sidecarRestartError = null
167
+ return { ok: true, before, after: { tunnel: sidecarSnapshot.tunnel, lan: sidecarSnapshot.lan, state: sidecarState }, purgedModules: purged }
168
+ } catch (error) {
169
+ sidecarRestartError = error instanceof Error ? error.message : String(error)
170
+ sidecarState = `error: ${sidecarRestartError}`
171
+ // 起不来就把状态如实留在 error,而不是假装成功。宿主侧后续的 /tunnel/send
172
+ // 会因为没有 worker 而明确报 'sidecar facade is not running'。
173
+ return { ok: false, error: sidecarRestartError, before, purgedModules: purged }
174
+ } finally {
175
+ restartInFlight = null
176
+ }
177
+ })()
178
+ return restartInFlight
179
+ }
180
+
181
+ // ── 健康轮询 ─────────────────────────────────────────────────────────
182
+ // 隧道/局域网状态的权威回写点(状态跳变的即时回写见上面的 applyTunnelState)。
183
+ const onTunnelUp = (fn) => { tunnelUpSubscribers.add(fn); return () => tunnelUpSubscribers.delete(fn) }
184
+
185
+ const start = () => {
186
+ let disposed = false
187
+ void sidecar.start().then(async () => {
188
+ if (disposed) return
189
+ await syncSidecar()
190
+ sidecarState = 'healthy'
191
+ }).catch(error => { sidecarState = `error: ${error instanceof Error ? error.message : String(error)}` })
192
+ const healthTimer = setInterval(async () => {
193
+ try {
194
+ const st = await sidecar.request('/status')
195
+ // 轮询拿到的才是权威状态,必须回写 sidecarSnapshot:`configure()` 返回的是
196
+ // 瞬时快照,那时 startLan() 的 start() 还没 resolve,lan.state 恒为
197
+ // 'connecting'。原来这次轮询只用来判断隧道连接跳变,结果被直接丢弃,于是
198
+ // /api/relay/status 永远谎报局域网"没起来",尽管 0.0.0.0:lanPort 早在监听。
199
+ if (st) sidecarSnapshot = st
200
+ // 边沿判定与即时上报共用(noteTunnelConnected):这里只是权威兜底,
201
+ // 隧道在两次轮询之间断又连时补上那一次全量补推。
202
+ noteTunnelConnected(st?.tunnel?.state === 'connected')
203
+ } catch (error) {
204
+ // 原来这里是空的 `catch { }`:轮询打不通(worker 死了、端口没人听)时
205
+ // 异常被吞掉,sidecarSnapshot 就永远冻在临死前那一帧,/api/relay/status
206
+ // 于是继续报 tunnel: connected、lan: listening。这跟 worker 侧 tunnel 少一层
207
+ // 身份守卫是同一类毛病——状态乐观会把后续判断一路带偏。偶发抖动会在下一个
208
+ // 轮询周期自愈(10s),期间镜像不推而已,比一直谎报安全得多。
209
+ markSidecarDown(error)
210
+ }
211
+ }, HEALTH_POLL_INTERVAL_MS)
212
+ return () => { disposed = true; void sidecar.stop(); clearInterval(healthTimer) }
213
+ }
214
+
215
+ return {
216
+ sidecarToken,
217
+ start,
218
+ syncSidecar,
219
+ restartSidecar,
220
+ onTunnelUp,
221
+ tunnelConnected,
222
+ /** ⚠️ 取值器:sidecar 会被自重启整体替换。 */
223
+ getSidecar: () => sidecar,
224
+ getSnapshot: () => sidecarSnapshot,
225
+ getState: () => sidecarState,
226
+ getRestarts: () => sidecarRestarts,
227
+ getRestartedAt: () => sidecarRestartedAt,
228
+ getRestartError: () => sidecarRestartError,
229
+ /**
230
+ * **对外报地址**用的局域网端口:以 sidecar 真正 bind 成功的那一个为准。
231
+ *
232
+ * 两条端口自适应会打架:宿主只在「配置端口被占」时换号并写回 state,sidecar 自己
233
+ * 还会再探一次,真实端口只出现在它上报的 `lan.port` 里。只认宿主那一个的后果实测过:
234
+ * 状态接口、connection-info、配对二维码都报 50933,实际监听 55583 —— 手机在家用
235
+ * Wi-Fi 拿到死地址,只能退回公网中转。尚未 listen 完(快照没有 port)时才回落。
236
+ */
237
+ getAdvertisedLanPort: () => {
238
+ const reported = sidecarSnapshot && sidecarSnapshot.lan ? sidecarSnapshot.lan.port : 0
239
+ if (Number.isInteger(reported) && reported > 0) return reported
240
+ return lanPortInUse()
241
+ },
242
+ markSidecarDown,
243
+ }
244
+ }
245
+
246
+ module.exports = { createSidecarLifecycle };
package/package.json CHANGED
@@ -1,6 +1,76 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.57",
4
+ "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "lib/impl.js",
8
+ "bin": "host.js",
9
+ "exports": {
10
+ ".": "./lib/impl.js",
11
+ "./client": "./lib/client.js",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/WNLUO/deepseek-harness-desktop.git",
17
+ "directory": "dsh-mobile-plugin"
18
+ },
19
+ "keywords": [
20
+ "dsh",
21
+ "deepseek-harness",
22
+ "plugin",
23
+ "mobile",
24
+ "relay"
25
+ ],
26
+ "files": [
27
+ "host.js",
28
+ "cordis.patch.yml",
29
+ "cordis.mobile-host.yml",
30
+ "config/**/*.yml",
31
+ "lib/**/*.js",
32
+ "lib/**/*.cjs",
33
+ "lib/**/*.html",
34
+ "lib/**/package.json",
35
+ "dsh-mobile-credentials",
36
+ "README.md"
37
+ ],
38
+ "publishConfig": {
39
+ "registry": "https://registry.npmjs.org/",
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "start": "node host.js",
44
+ "test": "node --test test/*.test.cjs && node test/lan-direct-e2e.cjs && node test/sidecar-facade.cjs"
45
+ },
46
+ "dependencies": {
47
+ "@deepseek-ai/dsh-tool-ask-user": "0.1.1-rc.2",
48
+ "qrcode": "1.5.4",
49
+ "ws": "8.18.0"
50
+ },
51
+ "peerDependencies": {
52
+ "@deepseek-ai/cordis": ">=4.0.1",
53
+ "@deepseek-ai/dsh-client-locale": ">=0.1.1-rc.2",
54
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.1-rc.2",
55
+ "@deepseek-ai/dsh-settings": ">=0.1.1-rc.2",
56
+ "@deepseek-ai/dsh-tools": ">=0.1.1-rc.2",
57
+ "@deepseek-ai/schemastery": "^3.18.1"
58
+ },
59
+ "dsh": {
60
+ "bundle": {
61
+ "patch": "./cordis.patch.yml"
62
+ },
63
+ "client": {
64
+ "platform": "web",
65
+ "immediately": true,
66
+ "inject": [
67
+ "@deepseek-ai/dsh-client-locale",
68
+ "@deepseek-ai/dsh-client-runtime"
69
+ ]
70
+ }
71
+ },
72
+ "devDependencies": {
73
+ "@deepseek-ai/dsh-host-directory-picker-browse": "0.1.1-rc.2",
74
+ "yaml": "2.9.1"
75
+ }
76
+ }