dsh-plugin-mobile-link 0.7.0 → 0.8.1
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/README.md +27 -12
- package/cordis.patch.yml +5 -0
- package/lib/cli.js +11 -13
- package/lib/client.js +1453 -91
- package/lib/dlp.js +15 -0
- package/lib/enroll.js +249 -35
- package/lib/handoff.js +1 -1
- package/lib/hello.js +1 -1
- package/lib/history/fold-summary.js +85 -32
- package/lib/history/fold.js +11 -1
- package/lib/history/question-record.js +186 -0
- package/lib/i18n.js +177 -0
- package/lib/image-originals.js +121 -0
- package/lib/image-variants.js +315 -0
- package/lib/index.js +59 -2
- package/lib/journal.js +2 -0
- package/lib/link.js +274 -17
- package/lib/router.js +70 -8
- package/lib/routes.js +147 -0
- package/lib/session-digest.js +24 -10
- package/lib/session-scope.js +89 -0
- package/lib/state.js +3 -3
- package/lib/status.js +42 -11
- package/package.json +1 -1
package/lib/routes.js
CHANGED
|
@@ -86,6 +86,31 @@ function ttlFrom(value, fallbackMs) {
|
|
|
86
86
|
: fallbackMs
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Map an enrollment error code onto the HTTP status the panel sees. */
|
|
90
|
+
export function enrollStatus(code) {
|
|
91
|
+
switch (code) {
|
|
92
|
+
case 'enroll/already-registered':
|
|
93
|
+
case 'enroll/disabled':
|
|
94
|
+
return 409
|
|
95
|
+
case 'enroll/timeout':
|
|
96
|
+
return 504
|
|
97
|
+
case 'enroll/rate-limited':
|
|
98
|
+
return 429
|
|
99
|
+
case 'enroll/state-write':
|
|
100
|
+
case 'enroll/internal':
|
|
101
|
+
return 500
|
|
102
|
+
case 'enroll/bad-request':
|
|
103
|
+
case 'enroll/invalid-relay':
|
|
104
|
+
case 'enroll/placeholder-relay':
|
|
105
|
+
case 'enroll/unknown':
|
|
106
|
+
case 'enroll/used':
|
|
107
|
+
case 'enroll/expired':
|
|
108
|
+
return 400
|
|
109
|
+
default:
|
|
110
|
+
return 502
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
89
114
|
/**
|
|
90
115
|
* Register the routes.
|
|
91
116
|
* @returns {Array<() => void>} disposers (already wrapped by the caller in ctx.effect).
|
|
@@ -93,6 +118,9 @@ function ttlFrom(value, fallbackMs) {
|
|
|
93
118
|
export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, history }) {
|
|
94
119
|
const guard = makeGuard(ctx, logger)
|
|
95
120
|
const STATUS_ROUTE = '/mobile-link/status'
|
|
121
|
+
const ENROLL_ROUTE = '/mobile-link/enroll'
|
|
122
|
+
const REVOKE_ROUTE = '/mobile-link/devices/revoke'
|
|
123
|
+
const DEVICES_ROUTE = '/mobile-link/devices'
|
|
96
124
|
const PAIR_ROUTE = '/mobile-link/pair-code'
|
|
97
125
|
const REGENERATE_ROUTE = '/mobile-link/pair-code/regenerate'
|
|
98
126
|
const disposers = []
|
|
@@ -116,6 +144,125 @@ export function registerMobileLinkRoutes(ctx, { agent, logger, config = {}, hist
|
|
|
116
144
|
},
|
|
117
145
|
}))
|
|
118
146
|
|
|
147
|
+
disposers.push(ctx.webServer.register({
|
|
148
|
+
kind: 'exact',
|
|
149
|
+
path: ENROLL_ROUTE,
|
|
150
|
+
handler: async (req, res) => {
|
|
151
|
+
if (guard(req, res, { requireFence: true })) return
|
|
152
|
+
if (req.method !== 'POST') {
|
|
153
|
+
res.statusCode = 405
|
|
154
|
+
res.setHeader('allow', 'POST')
|
|
155
|
+
res.end()
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const body = await readJsonBody(req)
|
|
159
|
+
if (body === undefined) {
|
|
160
|
+
sendJson(res, 400, { ok: false, code: 'enroll/bad-request', message: 'invalid JSON body' })
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
const relayUrl = typeof body.relayUrl === 'string' ? body.relayUrl.trim() : ''
|
|
164
|
+
const inviteCode = typeof body.inviteCode === 'string' ? body.inviteCode.trim() : ''
|
|
165
|
+
const agentName = typeof body.agentName === 'string' ? body.agentName.trim() : ''
|
|
166
|
+
if (!relayUrl) {
|
|
167
|
+
sendJson(res, 400, {
|
|
168
|
+
ok: false,
|
|
169
|
+
code: 'enroll/bad-request',
|
|
170
|
+
message: 'relayUrl is required',
|
|
171
|
+
})
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
// 开放登记:不需要邀请码;旧调用方仍可带(历史兼容)。
|
|
176
|
+
const settled = await agent.enroll({
|
|
177
|
+
inviteCode: inviteCode || undefined,
|
|
178
|
+
relayUrl,
|
|
179
|
+
agentName: agentName || undefined,
|
|
180
|
+
})
|
|
181
|
+
logger.info?.(`mobile-link: panel enrollment succeeded for ${settled.agentId}`)
|
|
182
|
+
// Never return `agentSecret`: the browser has no use for it, and one
|
|
183
|
+
// XSS in the panel would otherwise hand over the machine's credential.
|
|
184
|
+
sendJson(res, 200, {
|
|
185
|
+
ok: true,
|
|
186
|
+
agentId: settled.agentId,
|
|
187
|
+
agentName: settled.agentName,
|
|
188
|
+
relayUrl: settled.relayUrl,
|
|
189
|
+
})
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// Read the code off the error rather than using `instanceof`: a hot
|
|
192
|
+
// reload can leave the route and the link on different module
|
|
193
|
+
// generations, and an identity check would then downgrade a precise
|
|
194
|
+
// "already registered" into a generic 500.
|
|
195
|
+
const code = typeof error?.code === 'string' && error.code.startsWith('enroll/')
|
|
196
|
+
? error.code
|
|
197
|
+
: 'enroll/internal'
|
|
198
|
+
const status = enrollStatus(code)
|
|
199
|
+
if (code === 'enroll/internal') {
|
|
200
|
+
logger.warn?.(`mobile-link: panel enrollment failed: ${String(error?.message ?? error)}`)
|
|
201
|
+
}
|
|
202
|
+
sendJson(res, status, { ok: false, code, message: String(error?.message ?? error) })
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
}))
|
|
206
|
+
|
|
207
|
+
disposers.push(ctx.webServer.register({
|
|
208
|
+
kind: 'exact',
|
|
209
|
+
path: DEVICES_ROUTE,
|
|
210
|
+
handler: async (req, res) => {
|
|
211
|
+
if (guard(req, res, { requireFence: true })) return
|
|
212
|
+
if (req.method !== 'GET') {
|
|
213
|
+
res.statusCode = 405
|
|
214
|
+
res.setHeader('allow', 'GET')
|
|
215
|
+
res.end()
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const result = await agent.listPairedDevices()
|
|
220
|
+
sendJson(res, 200, { ok: true, ...result })
|
|
221
|
+
} catch (error) {
|
|
222
|
+
sendJson(res, 502, {
|
|
223
|
+
ok: false,
|
|
224
|
+
code: 'devices/list-failed',
|
|
225
|
+
message: String(error?.message ?? error),
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}))
|
|
230
|
+
|
|
231
|
+
disposers.push(ctx.webServer.register({
|
|
232
|
+
kind: 'exact',
|
|
233
|
+
path: REVOKE_ROUTE,
|
|
234
|
+
handler: async (req, res) => {
|
|
235
|
+
if (guard(req, res, { requireFence: true })) return
|
|
236
|
+
if (req.method !== 'POST') {
|
|
237
|
+
res.statusCode = 405
|
|
238
|
+
res.setHeader('allow', 'POST')
|
|
239
|
+
res.end()
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
const body = await readJsonBody(req)
|
|
243
|
+
if (body === undefined) {
|
|
244
|
+
sendJson(res, 400, { ok: false, code: 'request/bad-request', message: 'invalid JSON body' })
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
const deviceId = typeof body.deviceId === 'string' ? body.deviceId.trim() : ''
|
|
248
|
+
if (!deviceId) {
|
|
249
|
+
sendJson(res, 400, { ok: false, code: 'request/device-id', message: 'deviceId is required' })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const result = await agent.revokeDevice(deviceId)
|
|
254
|
+
logger.info?.(`mobile-link: revoked device ${result.deviceId}`)
|
|
255
|
+
sendJson(res, 200, { ok: true, deviceId: result.deviceId })
|
|
256
|
+
} catch (error) {
|
|
257
|
+
sendJson(res, 502, {
|
|
258
|
+
ok: false,
|
|
259
|
+
code: 'devices/revoke-failed',
|
|
260
|
+
message: String(error?.message ?? error),
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}))
|
|
265
|
+
|
|
119
266
|
disposers.push(ctx.webServer.register({
|
|
120
267
|
kind: 'exact',
|
|
121
268
|
path: PAIR_ROUTE,
|
package/lib/session-digest.js
CHANGED
|
@@ -133,11 +133,16 @@ export function digestFingerprint(item) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* 一批会话的指纹表(sessionId → 指纹)。
|
|
138
|
+
*
|
|
139
|
+
* 键是宿主 `SessionSummary.sessionId`——会话身份在宿主只有这一个名字。
|
|
140
|
+
* 这里不认 `id`:多认一个名字就是第二份身份,正是它让指纹表恒为空(2026-10-27)。
|
|
141
|
+
*/
|
|
137
142
|
export function digestFingerprints(items) {
|
|
138
143
|
const map = new Map()
|
|
139
144
|
for (const item of items ?? []) {
|
|
140
|
-
if (item && typeof item.
|
|
145
|
+
if (item && typeof item.sessionId === 'string') map.set(item.sessionId, digestFingerprint(item))
|
|
141
146
|
}
|
|
142
147
|
return map
|
|
143
148
|
}
|
|
@@ -153,20 +158,29 @@ export function fingerprintsVersion(map) {
|
|
|
153
158
|
/**
|
|
154
159
|
* 相对上一次指纹表的增量。
|
|
155
160
|
*
|
|
156
|
-
* 入参 `previous` 为 Map 或 null
|
|
157
|
-
*
|
|
161
|
+
* 入参 `previous` 为 Map 或 null。返回:
|
|
162
|
+
* - `full`:能不能把这份结果当增量发;
|
|
163
|
+
* - `changed`:新增或内容变化的项(`full` 时为整份 items);
|
|
164
|
+
* - `removed`:消失的 sessionId(`full` 时为空);
|
|
165
|
+
* - `version` / `fingerprints`:下一次比较用的版本与指纹表。
|
|
166
|
+
*
|
|
167
|
+
* **身份不可识别时退化为全量**:摘要是优化,不是事实来源。只要有一项没有 `sessionId`,
|
|
168
|
+
* 就没法证明其余项没变;此时宁可多发一份全量,也不能回空增量——后者不报错、不更新,
|
|
169
|
+
* 界面看起来正常却永远冻结(2026-10-27 的现场)。
|
|
158
170
|
*/
|
|
159
171
|
export function digestDelta(previous, items) {
|
|
160
172
|
const list = Array.isArray(items) ? items : []
|
|
173
|
+
const identifiable = list.every((item) => item && typeof item.sessionId === 'string')
|
|
161
174
|
const next = digestFingerprints(list)
|
|
175
|
+
const version = fingerprintsVersion(next)
|
|
176
|
+
if (previous === null || !identifiable) {
|
|
177
|
+
return { full: true, changed: list, removed: [], version, fingerprints: next }
|
|
178
|
+
}
|
|
162
179
|
const changed = []
|
|
163
180
|
for (const item of list) {
|
|
164
|
-
if (
|
|
165
|
-
if (!previous || previous.get(item.id) !== next.get(item.id)) changed.push(item)
|
|
181
|
+
if (previous.get(item.sessionId) !== next.get(item.sessionId)) changed.push(item)
|
|
166
182
|
}
|
|
167
183
|
const removed = []
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
return { changed, removed, version: fingerprintsVersion(next), fingerprints: next }
|
|
184
|
+
for (const sessionId of previous.keys()) if (!next.has(sessionId)) removed.push(sessionId)
|
|
185
|
+
return { full: false, changed, removed, version, fingerprints: next }
|
|
172
186
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 哪些会话值得提醒手机(SR-02.08 / SR-02.15)。
|
|
3
|
+
*
|
|
4
|
+
* 只有**正式会话**算数:属于某个工作区、未归档、且不是子会话。未分组、子会话与
|
|
5
|
+
* 已归档会在列表里以行、桶头或转写出现,但它们不该让手机弹窗,也不该进顶部的
|
|
6
|
+
* 「等你回应」计数——一个子智能体跑完或停下来问问题,不是用户要处理的事。
|
|
7
|
+
*
|
|
8
|
+
* 判据全部来自宿主自己的账本,不做目录推断:
|
|
9
|
+
* - 子会话:`Session.header.origin === 'subagent'`(历史 fork 也可能有
|
|
10
|
+
* `parentSession`,所以只认 `origin`,与 `@deepseek-ai/dsh-session` 的分类口径一致);
|
|
11
|
+
* - 归档:`workspaceRegistry.archivedSessionIds`;
|
|
12
|
+
* - 属于工作区:某个 `Workspace.sessionIds` 里含这个 id。
|
|
13
|
+
*
|
|
14
|
+
* 服务没绑上时返回 `true`(保持旧行为):拿不到宿主账本就断言"非正式"会把正式会话的
|
|
15
|
+
* 提醒一起吞掉。两种失败模式里,漏一条提醒比整类提醒消失更容易被发现。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function sessionFrom(sessions, sessionId) {
|
|
19
|
+
if (typeof sessions?.get === 'function') {
|
|
20
|
+
try {
|
|
21
|
+
const found = sessions.get(sessionId)
|
|
22
|
+
if (found !== undefined && found !== null) return found
|
|
23
|
+
} catch {
|
|
24
|
+
// 会话在枚举与读取之间消失:交给下面的 list 兜底。
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (typeof sessions?.list !== 'function') return undefined
|
|
28
|
+
try {
|
|
29
|
+
const listed = sessions.list()
|
|
30
|
+
if (!Array.isArray(listed)) return undefined
|
|
31
|
+
return listed.find((entry) => (entry?.id ?? entry?.sessionId) === sessionId)
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function archivedIds(workspaces) {
|
|
38
|
+
try {
|
|
39
|
+
const ids = workspaces?.archivedSessionIds
|
|
40
|
+
return Array.isArray(ids) ? ids : []
|
|
41
|
+
} catch {
|
|
42
|
+
// 账本还没装好:按"没有归档"处理,不让一次读失败吞掉全部提醒。
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function workspaceMembers(workspaces) {
|
|
48
|
+
try {
|
|
49
|
+
const listed = workspaces?.list?.()
|
|
50
|
+
return Array.isArray(listed) ? listed : undefined
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {{
|
|
58
|
+
* sessions?: { get?: (id: string) => object | undefined, list?: () => object[] },
|
|
59
|
+
* workspaces?: {
|
|
60
|
+
* archivedSessionIds?: readonly string[],
|
|
61
|
+
* list?: () => Array<{ sessionIds?: readonly string[] }>,
|
|
62
|
+
* },
|
|
63
|
+
* }} [services]
|
|
64
|
+
* @returns {{ isFormal: (sessionId: string) => boolean }}
|
|
65
|
+
*/
|
|
66
|
+
export function createSessionScope({ sessions, workspaces } = {}) {
|
|
67
|
+
const canReadSessions = typeof sessions?.get === 'function' || typeof sessions?.list === 'function'
|
|
68
|
+
const canReadWorkspaces = typeof workspaces?.list === 'function'
|
|
69
|
+
const available = canReadSessions && canReadWorkspaces
|
|
70
|
+
|
|
71
|
+
function isFormal(sessionId) {
|
|
72
|
+
if (typeof sessionId !== 'string' || sessionId === '') return false
|
|
73
|
+
// 宿主服务还没绑上:保持旧行为,见文件头。
|
|
74
|
+
if (!available) return true
|
|
75
|
+
|
|
76
|
+
const session = sessionFrom(sessions, sessionId)
|
|
77
|
+
if (session === undefined) return false
|
|
78
|
+
if ((session?.header?.origin ?? session?.origin) === 'subagent') return false
|
|
79
|
+
if (archivedIds(workspaces).includes(sessionId)) return false
|
|
80
|
+
const members = workspaceMembers(workspaces)
|
|
81
|
+
// 工作区账本读不出来:无法判定,维持旧行为,别把整类提醒一起吞掉。
|
|
82
|
+
if (members === undefined) return true
|
|
83
|
+
return members.some((workspace) => (
|
|
84
|
+
Array.isArray(workspace?.sessionIds) && workspace.sessionIds.includes(sessionId)
|
|
85
|
+
))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { isFormal }
|
|
89
|
+
}
|
package/lib/state.js
CHANGED
|
@@ -106,9 +106,9 @@ export async function resolveIdentity({ stateFile, agentId, agentSecret, relayUr
|
|
|
106
106
|
}
|
|
107
107
|
if (!resolved.agentId || !resolved.agentSecret) {
|
|
108
108
|
throw new MissingIdentity(
|
|
109
|
-
`mobile-link: this computer is not registered yet.
|
|
110
|
-
+ `
|
|
111
|
-
+ `
|
|
109
|
+
`mobile-link: this computer is not registered yet. Open the "移动端连接" panel`
|
|
110
|
+
+ ` and click 登记, or run "dsh-mobile-link enroll --relay <relay-url>".`
|
|
111
|
+
+ ` (Provisioning by hand is still possible:`
|
|
112
112
|
+ ` "dsh-relay admin agent-register --account <acc> --name <name>`
|
|
113
113
|
+ ` --write-config ${path}".)`,
|
|
114
114
|
{ stateFile: path },
|
package/lib/status.js
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
* 函数只读 agent 的字段,不持有状态,因此可以直接被单测调用。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { isPlaceholderRelayUrl } from './dlp.js'
|
|
9
10
|
import { SERVER_CAPABILITIES, SERVER_VERSION, hostVersion } from './hello.js'
|
|
11
|
+
import { T, setLanguage } from './i18n.js'
|
|
12
|
+
|
|
13
|
+
if (process.env.DSH_LANG) setLanguage(process.env.DSH_LANG)
|
|
10
14
|
|
|
11
15
|
const PROTOCOL_VERSION = 1
|
|
12
16
|
|
|
@@ -16,10 +20,10 @@ const PROTOCOL_VERSION = 1
|
|
|
16
20
|
* 锚点标题与 `docs/ONBOARDING.md` 里的小节标题**逐字一致**,这样"锚点存在"可以机械核对。
|
|
17
21
|
*/
|
|
18
22
|
export const DSH_HINTS = {
|
|
19
|
-
config: { hint: '你手工配置的本机地址不对或已失效', docAnchor: '手工指定本机地址' },
|
|
20
|
-
'host-service': { hint: '当前宿主没有把本机访问方式交给插件(多为插件没装进正在运行的宿主)', docAnchor: '装在哪里:档案与插件入口' },
|
|
21
|
-
DSH_WEB_URL: { hint: '环境变量里的本机地址已过期', docAnchor: '手工指定本机地址' },
|
|
22
|
-
unreachable: { hint: '本机 DSH 不可达;请确认宿主正在运行', docAnchor: '排查:连不上时看这里' },
|
|
23
|
+
config: { hint: '你手工配置的本机地址不对或已失效', i18n: 'status.config', docAnchor: '手工指定本机地址' },
|
|
24
|
+
'host-service': { hint: '当前宿主没有把本机访问方式交给插件(多为插件没装进正在运行的宿主)', i18n: 'status.hostService', docAnchor: '装在哪里:档案与插件入口' },
|
|
25
|
+
DSH_WEB_URL: { hint: '环境变量里的本机地址已过期', i18n: 'status.dshWebUrl', docAnchor: '手工指定本机地址' },
|
|
26
|
+
unreachable: { hint: '本机 DSH 不可达;请确认宿主正在运行', i18n: 'status.unreachable', docAnchor: '排查:连不上时看这里' },
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
/**
|
|
@@ -28,9 +32,9 @@ export const DSH_HINTS = {
|
|
|
28
32
|
* 锚点标题与 `docs/ONBOARDING.md` 的小节标题逐字一致,`test/status.test.js` 会核对。
|
|
29
33
|
*/
|
|
30
34
|
export const HOST_HINTS = {
|
|
31
|
-
desktop: { hint: '你正在用官方桌面版(连接器已装进它的档案)', docAnchor: '安装:官方桌面版' },
|
|
32
|
-
web: { hint: '你正在用命令行 web 版', docAnchor: '安装:命令行 web 版' },
|
|
33
|
-
unknown: { hint: '无法识别当前宿主;若连不上,请按"排查"一节处理', docAnchor: '排查:连不上时看这里' },
|
|
35
|
+
desktop: { hint: '你正在用官方桌面版(连接器已装进它的档案)', i18n: 'status.desktop', docAnchor: '安装:官方桌面版' },
|
|
36
|
+
web: { hint: '你正在用命令行 web 版', i18n: 'status.web', docAnchor: '安装:命令行 web 版' },
|
|
37
|
+
unknown: { hint: '无法识别当前宿主;若连不上,请按"排查"一节处理', i18n: 'status.unknown', docAnchor: '排查:连不上时看这里' },
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/** 本机 DSH 侧的诊断块(老字段一个不少,新字段只增)。 */function dshBlock(agent) {
|
|
@@ -45,7 +49,7 @@ export const HOST_HINTS = {
|
|
|
45
49
|
error: agent.dshError ?? null,
|
|
46
50
|
// 新增(只增不改):失败分类 + 用户可读的一句话 + 文档锚点。
|
|
47
51
|
errorKind,
|
|
48
|
-
hint: entry?.hint ?? null,
|
|
52
|
+
hint: entry?.i18n ? T(entry.i18n) : entry?.hint ?? null,
|
|
49
53
|
docAnchor: entry?.docAnchor ?? null,
|
|
50
54
|
}
|
|
51
55
|
}
|
|
@@ -81,13 +85,33 @@ const STATUS_JOURNAL_ENTRIES = 20
|
|
|
81
85
|
*/
|
|
82
86
|
export function statusSnapshot(agent) {
|
|
83
87
|
const hostEntry = HOST_HINTS[agent.hostForm] ?? HOST_HINTS.unknown
|
|
88
|
+
// One resolved view of the relay address, shared by the form and the panel:
|
|
89
|
+
// a configured address outranks the identity, but the repository placeholder
|
|
90
|
+
// is "unset", never a value to prefill. `relayLocked` says the address came
|
|
91
|
+
// from config/env, so the form must be read-only — otherwise the panel would
|
|
92
|
+
// enroll with one address and the next boot would dial another.
|
|
93
|
+
const configuredRelay = typeof agent.config?.relayUrl === 'string' ? agent.config.relayUrl : ''
|
|
94
|
+
const realConfiguredRelay = configuredRelay && !isPlaceholderRelayUrl(configuredRelay)
|
|
95
|
+
? configuredRelay
|
|
96
|
+
: null
|
|
97
|
+
// `defaultRelayUrl` is a *form default*, not a dialing override: it only
|
|
98
|
+
// prefills the input, so the user can replace it and the identity file still
|
|
99
|
+
// wins on the next boot. A real `relayUrl` is the operator pin and locks it.
|
|
100
|
+
const configuredDefault = typeof agent.config?.defaultRelayUrl === 'string'
|
|
101
|
+
? agent.config.defaultRelayUrl
|
|
102
|
+
: ''
|
|
103
|
+
const defaultRelay = configuredDefault && !isPlaceholderRelayUrl(configuredDefault)
|
|
104
|
+
? configuredDefault
|
|
105
|
+
: null
|
|
106
|
+
const effectiveRelay = agent.identity?.relayUrl ?? realConfiguredRelay ?? null
|
|
107
|
+
const relayPrefill = realConfiguredRelay ?? defaultRelay ?? agent.identity?.relayUrl ?? null
|
|
84
108
|
return {
|
|
85
109
|
ok: true,
|
|
86
110
|
enabled: agent.enabled !== false,
|
|
87
111
|
// 新增(只增不改):宿主形态 + 它对应的一句话与文档锚点,
|
|
88
112
|
// 决定用户该按哪条说明安装/排障(FR-012)。
|
|
89
113
|
host: agent.hostForm,
|
|
90
|
-
hostHint: hostEntry.hint,
|
|
114
|
+
hostHint: hostEntry.i18n ? T(hostEntry.i18n) : hostEntry.hint,
|
|
91
115
|
hostDocAnchor: hostEntry.docAnchor,
|
|
92
116
|
protocolVersion: PROTOCOL_VERSION,
|
|
93
117
|
// Same facts as `_link/hello`, which is the channel the app actually uses:
|
|
@@ -102,11 +126,18 @@ export function statusSnapshot(agent) {
|
|
|
102
126
|
// computer half is installed but not yet registered, and what to run.
|
|
103
127
|
registered: !agent.needsEnroll,
|
|
104
128
|
needsEnroll: agent.needsEnroll,
|
|
129
|
+
// `panel` is a host-UI capability, not a phone capability: an older
|
|
130
|
+
// connector omits it, and the panel falls back to the command. It is
|
|
131
|
+
// deliberately not in SERVER_CAPABILITIES (that list is what the app asks
|
|
132
|
+
// for over `_link/hello`; the phone has no use for a panel form).
|
|
133
|
+
panel: true,
|
|
105
134
|
stateFile: agent.identity?.stateFile ?? agent.config.stateFile ?? null,
|
|
106
|
-
relayUrl:
|
|
135
|
+
relayUrl: effectiveRelay,
|
|
136
|
+
relayPrefill,
|
|
137
|
+
relayLocked: Boolean(realConfiguredRelay),
|
|
107
138
|
command: agent.needsEnroll ? agent.enrollHint() : null,
|
|
108
139
|
hint: agent.needsEnroll
|
|
109
|
-
? '
|
|
140
|
+
? T('status.unregistered')
|
|
110
141
|
: null,
|
|
111
142
|
},
|
|
112
143
|
state: agent.state,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-link",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Put your PC's DeepSeek Harness in your pocket: dials a relay over WSS so the native iOS client reaches this machine even without a public IP.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|