dsh-plugin-mobile-link 0.8.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/lib/cli.js +8 -9
- package/lib/client.js +972 -95
- package/lib/handoff.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 +50 -0
- package/lib/journal.js +2 -0
- package/lib/link.js +83 -10
- package/lib/router.js +63 -2
- package/lib/session-scope.js +89 -0
- package/lib/status.js +13 -10
- package/package.json +1 -1
package/lib/link.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { EventEmitter } from 'node:events'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
8
9
|
|
|
9
10
|
import { DshClient } from './dsh-client.js'
|
|
10
|
-
import { agentEndpoint, decodeFrame, deviceListEndpoint, deviceRevokeEndpoint, encodeFrameChecked, isPlaceholderRelayUrl, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
|
|
11
|
+
import { agentEndpoint, decodeFrame, deviceListEndpoint, deviceRevokeEndpoint, encodeFrameChecked, isPlaceholderRelayUrl, nextBackoff, normalizeRelayUrl, pongFor, qrPayload } from './dlp.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* 进程级登记表:**同一个进程里只允许有一条到中转的 agent 连接**。
|
|
@@ -30,11 +31,17 @@ import {
|
|
|
30
31
|
} from './router.js'
|
|
31
32
|
import { SERVER_VERSION, SERVER_CAPABILITIES, hostVersion } from './hello.js'
|
|
32
33
|
import { createJobsRoster } from './jobs-roster.js'
|
|
33
|
-
import { MissingIdentity, defaultStatePath, readState, resolveIdentity } from './state.js'
|
|
34
|
+
import { MissingIdentity, defaultStatePath, readState, resolveIdentity, writeState } from './state.js'
|
|
34
35
|
import { connect as wsConnect } from './ws.js'
|
|
35
36
|
|
|
36
37
|
const PROTOCOL_VERSION = 1
|
|
37
38
|
|
|
39
|
+
// WebSocket 关闭码:中转据此区分"计划内重连"和"需要处理的异常"(SR-08.08)。
|
|
40
|
+
// 4002 已用于中转协议错误;这里补上两类自愈失败的码,不要再把 1000 复用给它们。
|
|
41
|
+
const CLOSE_NORMAL = 1000
|
|
42
|
+
const CLOSE_WATCHDOG = 4003
|
|
43
|
+
const CLOSE_HEARTBEAT = 4004
|
|
44
|
+
|
|
38
45
|
|
|
39
46
|
function sleep(ms, signal) {
|
|
40
47
|
return new Promise((resolve) => {
|
|
@@ -54,6 +61,55 @@ function numberFromEnv(raw, fallback) {
|
|
|
54
61
|
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
// ── 持久码缓存 ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 上次铸出来的持久码放在身份文件旁边(0600)。
|
|
68
|
+
*
|
|
69
|
+
* 中转只存码的哈希,铸码接口不会把同一张码再给一次,所以「小窗复用同一张持久码」
|
|
70
|
+
* (docs/PAIRING.md「二维码」一行)只能由连接器自己记住:面板每次打开都会请求一次码,
|
|
71
|
+
* 没有这份缓存就变成每开一次、每重载一次都多一张永久有效的码。
|
|
72
|
+
*/
|
|
73
|
+
function pairCodeCachePath(config) {
|
|
74
|
+
if (config?.pairCodePath) return config.pairCodePath
|
|
75
|
+
return join(dirname(config?.stateFile || defaultStatePath()), 'pair-code.json')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function rememberPairCode(config, minted) {
|
|
79
|
+
if (!minted?.code) return
|
|
80
|
+
try {
|
|
81
|
+
await writeState(pairCodeCachePath(config), {
|
|
82
|
+
code: minted.code,
|
|
83
|
+
agentId: minted.agentId,
|
|
84
|
+
relayUrl: minted.relayUrl,
|
|
85
|
+
maxPairedDevices: minted.maxPairedDevices ?? null,
|
|
86
|
+
pairedDevices: Number(minted.pairedDevices ?? 0),
|
|
87
|
+
savedAt: Date.now(),
|
|
88
|
+
})
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// 缓存写不进去只影响"下次还会不会换码",不该让这次取码失败。
|
|
91
|
+
config?.logger?.warn?.(`mobile-link: could not remember the pairing code: ${messageOf(error)}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readRememberedPairCode(config, identity) {
|
|
96
|
+
const file = await readState(pairCodeCachePath(config))
|
|
97
|
+
if (!file || typeof file.code !== 'string' || !file.code) return null
|
|
98
|
+
// 身份变了(重新登记、换了中转)就不是同一台电脑的码,不能拿来复用。
|
|
99
|
+
if (file.agentId !== identity.agentId || file.relayUrl !== identity.relayUrl) return null
|
|
100
|
+
return {
|
|
101
|
+
code: file.code,
|
|
102
|
+
expiresAt: null,
|
|
103
|
+
ttlMs: null,
|
|
104
|
+
persistent: true,
|
|
105
|
+
maxPairedDevices: Number(file.maxPairedDevices ?? 0) || null,
|
|
106
|
+
pairedDevices: Number(file.pairedDevices ?? 0),
|
|
107
|
+
relayUrl: identity.relayUrl,
|
|
108
|
+
agentId: identity.agentId,
|
|
109
|
+
qrPayload: qrPayload({ relay: identity.relayUrl, code: file.code }),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
57
113
|
export class MobileLinkAgent extends EventEmitter {
|
|
58
114
|
constructor({
|
|
59
115
|
relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl,
|
|
@@ -138,6 +194,9 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
138
194
|
/// 历史读取(spec 007):宿主 `sessionQuery` 就绪后由插件挂上;没挂上时
|
|
139
195
|
/// 路由回 `link/unsupported`,手机继续走 `session/page`。
|
|
140
196
|
this.setHistoryService = (service) => { this.router.history = service }
|
|
197
|
+
/// 图片两变体(预览 + 可选原图):宿主 `attachments` / `fileUploads` 就绪后由插件挂上;
|
|
198
|
+
/// 没挂上时三个 `_link/image*` 方法回 `link/unsupported`(手机显示"原图不可用")。
|
|
199
|
+
this.setImageVariants = (service) => { this.router.bindImageVariants(service) }
|
|
141
200
|
/// 活动名单与 `link/jobs` 推帧(spec 018):帧由 router 广播给在线设备,
|
|
142
201
|
/// 订阅与去抖都在 roster 里。宿主服务由 `setJobsServices` 挂上。
|
|
143
202
|
this.jobsRoster = createJobsRoster({
|
|
@@ -147,6 +206,9 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
147
206
|
})
|
|
148
207
|
this.router.bindJobsRoster(this.jobsRoster)
|
|
149
208
|
this.setJobsServices = (services) => this.jobsRoster.bindServices(services)
|
|
209
|
+
/// 提醒口径(SR-02.08):只报正式会话。宿主 `ctx.sessions` / `ctx.workspaceRegistry`
|
|
210
|
+
/// 由插件在 `wireSessionScope` 里挂上;缺一个就维持旧行为(全报)。
|
|
211
|
+
this.setSessionScopeServices = (services) => this.router.bindSessionScope(services)
|
|
150
212
|
this.relay = undefined
|
|
151
213
|
this.socket = undefined
|
|
152
214
|
this.state = 'idle'
|
|
@@ -681,7 +743,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
681
743
|
this.lastError = `relay 超过 ${Math.round(idle / 1000)}s 没有动静(看门狗拆连接)`
|
|
682
744
|
this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
|
|
683
745
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
684
|
-
this.#teardownSocket(this.lastError)
|
|
746
|
+
this.#teardownSocket(this.lastError, CLOSE_HEARTBEAT, 'heartbeat timeout')
|
|
685
747
|
this._lastProgressAt = this.now()
|
|
686
748
|
return
|
|
687
749
|
}
|
|
@@ -689,7 +751,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
689
751
|
this.lastError = `链路 ${Math.round(idle / 1000)}s 没有进展(看门狗重建连接)`
|
|
690
752
|
this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
|
|
691
753
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
692
|
-
this.#teardownSocket(this.lastError)
|
|
754
|
+
this.#teardownSocket(this.lastError, CLOSE_WATCHDOG, 'watchdog timeout')
|
|
693
755
|
this._lastProgressAt = this.now()
|
|
694
756
|
}
|
|
695
757
|
}, every)
|
|
@@ -707,7 +769,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
707
769
|
if (this.now() - this._lastPong > this.pongTimeoutMs) {
|
|
708
770
|
this.lastError = `relay stopped answering pings (>${this.pongTimeoutMs}ms)`
|
|
709
771
|
this.logger.warn?.(`mobile-link: ${this.lastError}`)
|
|
710
|
-
this.#teardownSocket(this.lastError)
|
|
772
|
+
this.#teardownSocket(this.lastError, CLOSE_HEARTBEAT, 'heartbeat timeout')
|
|
711
773
|
return
|
|
712
774
|
}
|
|
713
775
|
this.#rawSend({ t: 'ping', ts: this.now() })
|
|
@@ -720,14 +782,14 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
720
782
|
this._heartbeat = undefined
|
|
721
783
|
}
|
|
722
784
|
|
|
723
|
-
#teardownSocket(reason) {
|
|
785
|
+
#teardownSocket(reason, closeCode = CLOSE_NORMAL, closeReason = 'reconnecting') {
|
|
724
786
|
this.#stopHeartbeat()
|
|
725
787
|
const socket = this.socket
|
|
726
788
|
const from = this.state
|
|
727
789
|
this.socket = undefined
|
|
728
790
|
if (socket) {
|
|
729
791
|
try {
|
|
730
|
-
socket.close(
|
|
792
|
+
socket.close(closeCode, closeReason)
|
|
731
793
|
} catch {
|
|
732
794
|
/* already gone */
|
|
733
795
|
}
|
|
@@ -744,6 +806,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
744
806
|
// connectedForMs 用来区分"刚连上就掉"和"挂了很久才掉"。
|
|
745
807
|
this.journal.record('link.close', {
|
|
746
808
|
reason,
|
|
809
|
+
closeCode,
|
|
747
810
|
from,
|
|
748
811
|
connectedForMs: this.connectedAt === undefined ? undefined : this.now() - this.connectedAt,
|
|
749
812
|
// 有系统错误才带这个字段(正常关闭时不该凭空多一个 null)。
|
|
@@ -886,7 +949,7 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
886
949
|
this.router.onMuxDown(reason)
|
|
887
950
|
}
|
|
888
951
|
|
|
889
|
-
|
|
952
|
+
// ── relay-side helpers ──────────────────────────────────────────────────
|
|
890
953
|
|
|
891
954
|
/**
|
|
892
955
|
* Ask the relay to mint a pairing code for this agent (relay NOTES.md §2).
|
|
@@ -895,13 +958,23 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
895
958
|
*/
|
|
896
959
|
async mintPairCode({ ttlMs = null, fetchImpl = fetch } = {}) {
|
|
897
960
|
const identity = this.identity ?? await resolveIdentity(this.config)
|
|
898
|
-
|
|
961
|
+
// 持久码:手上已经有一张就直接给它。中转那边只有哈希,重铸只会多出一张有效码。
|
|
962
|
+
if (ttlMs == null) {
|
|
963
|
+
const remembered = await readRememberedPairCode(this.config, identity)
|
|
964
|
+
if (remembered) return remembered
|
|
965
|
+
}
|
|
966
|
+
const minted = await mintPairCode({ identity, ttlMs, fetchImpl })
|
|
967
|
+
if (minted.persistent) await rememberPairCode(this.config, minted)
|
|
968
|
+
return minted
|
|
899
969
|
}
|
|
900
970
|
|
|
901
971
|
/** 用户点「重新生成」:作废旧码、铸新的,**不碰已配对设备**(032)。 */
|
|
902
972
|
async regeneratePairCode({ fetchImpl = fetch } = {}) {
|
|
903
973
|
const identity = this.identity ?? await resolveIdentity(this.config)
|
|
904
|
-
|
|
974
|
+
const minted = await regeneratePairCode({ identity, fetchImpl })
|
|
975
|
+
// 中转已经把旧码全部作废,这里换成新的那张。
|
|
976
|
+
await rememberPairCode(this.config, minted)
|
|
977
|
+
return minted
|
|
905
978
|
}
|
|
906
979
|
|
|
907
980
|
/** Snapshot for `GET /mobile-link/status`(载荷本身在 status.js,便于守住"只增不改")。 */
|
package/lib/router.js
CHANGED
|
@@ -16,9 +16,11 @@ import { FileInbox, FILE_BEGIN, FILE_CHUNK, FILE_END, isFileMethod } from './fil
|
|
|
16
16
|
import { FileFetcher, FSGET_CANCEL } from './files-out.js'
|
|
17
17
|
import { GitBridge, isGitMethod } from './git.js'
|
|
18
18
|
import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
|
|
19
|
+
import { isImageVariantMethod } from './image-variants.js'
|
|
19
20
|
import { isHistoryMethod } from './history.js'
|
|
20
21
|
import { trimRecordsForWire } from './history/records.js'
|
|
21
22
|
import { isJobsMethod } from './jobs-roster.js'
|
|
23
|
+
import { createSessionScope } from './session-scope.js'
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* 后台大文件上传的桥接帧(relay → 连接器),名字见 `docs/relay-contract.json`
|
|
@@ -176,6 +178,12 @@ export class DeviceRouter {
|
|
|
176
178
|
// 「跑完了 / 待你回应」这类事件到了就报一声(见 `notifyFor`)。默认空函数,
|
|
177
179
|
// 测试与不关心提醒的调用方可以不管它。帧由 link.js 发(router 不发帧)。
|
|
178
180
|
this.notify = notify
|
|
181
|
+
/**
|
|
182
|
+
* 提醒口径(SR-02.08):只报正式会话。`undefined` = 宿主服务还没绑上,按旧行为
|
|
183
|
+
* 全报;绑上之后由 `session-scope.js` 判定。列表顶部的待答计数在 App 侧另算,
|
|
184
|
+
* 走同一份「正式会话」定义。
|
|
185
|
+
*/
|
|
186
|
+
this.sessionScope = undefined
|
|
179
187
|
// 生命周期事件(设备 attach/detach 等)的去处,由 link.js 接到流水(journal.js)。
|
|
180
188
|
// 默认空函数,所以 router 单独用时(单测、本地脚本)不需要日志设施。
|
|
181
189
|
this.onEvent = onEvent
|
|
@@ -204,6 +212,15 @@ export class DeviceRouter {
|
|
|
204
212
|
// 活动名单与 `link/jobs` 推帧(spec 018)。订阅与去抖都在 roster 里,router 只负责
|
|
205
213
|
// 转交设备生命周期与把帧广播出去。
|
|
206
214
|
this.jobsRoster = undefined
|
|
215
|
+
// 图片的两变体能力(预览 + 可选原图):由 index.js 注入宿主 `attachments` /
|
|
216
|
+
// `fileUploads` 后挂上。没挂上时三个保留方法回 `link/unsupported`,
|
|
217
|
+
// 手机显示"原图不可用",预览照旧。
|
|
218
|
+
this.imageVariants = undefined
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 挂上图片变体能力;`undefined` 表示宿主服务缺失(如实报不支持)。 */
|
|
222
|
+
bindImageVariants(service) {
|
|
223
|
+
this.imageVariants = service
|
|
207
224
|
}
|
|
208
225
|
|
|
209
226
|
/**
|
|
@@ -218,6 +235,21 @@ export class DeviceRouter {
|
|
|
218
235
|
for (const deviceId of this.devices.keys()) roster.addDevice(deviceId)
|
|
219
236
|
}
|
|
220
237
|
|
|
238
|
+
/**
|
|
239
|
+
* 挂上提醒口径所需的宿主账本(`ctx.sessions` / `ctx.workspaceRegistry`)。
|
|
240
|
+
*
|
|
241
|
+
* 传 `undefined` 摘掉:宿主换代或插件卸载后不能拿着失效服务对象去判定。
|
|
242
|
+
*/
|
|
243
|
+
bindSessionScope(services) {
|
|
244
|
+
this.sessionScope = services === undefined ? undefined : createSessionScope(services)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 这条会话是否值得报一声(只看正式会话;服务未绑上时保持旧行为)。 */
|
|
248
|
+
#shouldNotify(sessionId) {
|
|
249
|
+
if (typeof sessionId !== 'string' || sessionId === '') return false
|
|
250
|
+
return this.sessionScope === undefined || this.sessionScope.isFormal(sessionId)
|
|
251
|
+
}
|
|
252
|
+
|
|
221
253
|
#changed() {
|
|
222
254
|
this.onChange()
|
|
223
255
|
}
|
|
@@ -755,6 +787,30 @@ export class DeviceRouter {
|
|
|
755
787
|
return
|
|
756
788
|
}
|
|
757
789
|
|
|
790
|
+
// 图片变体(预览 + 原图):手机先经 `_link/file*` 把字节落进收件箱,再来这里
|
|
791
|
+
// 让连接器把它们交给 Host 附件服务并登记关联。自己回答而不是转发:Host
|
|
792
|
+
// 没有对应的 RPC,而这正是保留方法存在的理由。
|
|
793
|
+
if (isImageVariantMethod(frame.method)) {
|
|
794
|
+
let result
|
|
795
|
+
try {
|
|
796
|
+
if (this.imageVariants === undefined) {
|
|
797
|
+
result = {
|
|
798
|
+
ok: false,
|
|
799
|
+
error: errorObject('link/unsupported', 'image variants are unavailable on this connector'),
|
|
800
|
+
}
|
|
801
|
+
} else {
|
|
802
|
+
result = { ok: true, value: await this.imageVariants.handle(frame.method, frame.args ?? {}) }
|
|
803
|
+
}
|
|
804
|
+
} catch (error) {
|
|
805
|
+
result = {
|
|
806
|
+
ok: false,
|
|
807
|
+
error: errorObject(error?.code ?? 'link/failed', messageOf(error), error?.details ?? {}),
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
|
|
758
814
|
// Git also runs here, on the machine that holds the work tree: the Host has
|
|
759
815
|
// no endpoint that runs a command, and the review a person wants — what
|
|
760
816
|
// changed, what the diff says, what the last commits were — is all local.
|
|
@@ -1018,6 +1074,9 @@ export class DeviceRouter {
|
|
|
1018
1074
|
* `api-session/error`(`:3150`)**有意不推**:它不一定是"跑完了"(也可能是一轮中途
|
|
1019
1075
|
* 报错后继续),拿它当"跑完了"会发出一堆噪音提醒。这是决定,不是漏做。
|
|
1020
1076
|
*
|
|
1077
|
+
* **只报正式会话**(SR-02.08):未分组、子会话与已归档的运行结束和待确认不报。
|
|
1078
|
+
* 这就是 `#shouldNotify` 的唯一判据来源;主机服务绑上之前维持旧行为。
|
|
1079
|
+
*
|
|
1021
1080
|
* 不做去抖、不做队列、不做重试:一轮才一条,发失败(中转断了)就丢。`turnEnd` 丢一条
|
|
1022
1081
|
* 不值得为它做重试;`attention` 丢不了,它同时被记账,手机回来会补发。
|
|
1023
1082
|
*/
|
|
@@ -1025,11 +1084,13 @@ export class DeviceRouter {
|
|
|
1025
1084
|
if (!value || typeof value !== 'object') return
|
|
1026
1085
|
if (value.type === 'emit' && value.event === 'api-session/status' && value.args?.[1] === false) {
|
|
1027
1086
|
const sid = String(value.args[0] ?? '')
|
|
1028
|
-
if (sid) this.notify({ kind: 'turnEnd', sid })
|
|
1087
|
+
if (this.#shouldNotify(sid)) this.notify({ kind: 'turnEnd', sid })
|
|
1029
1088
|
return
|
|
1030
1089
|
}
|
|
1031
1090
|
if (value.type === 'waterfall') {
|
|
1032
|
-
this
|
|
1091
|
+
if (this.#shouldNotify(value.agentId)) {
|
|
1092
|
+
this.notify({ kind: 'attention', sid: value.agentId, eid: value.eventId })
|
|
1093
|
+
}
|
|
1033
1094
|
}
|
|
1034
1095
|
}
|
|
1035
1096
|
|
|
@@ -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/status.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
import { isPlaceholderRelayUrl } from './dlp.js'
|
|
10
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)
|
|
11
14
|
|
|
12
15
|
const PROTOCOL_VERSION = 1
|
|
13
16
|
|
|
@@ -17,10 +20,10 @@ const PROTOCOL_VERSION = 1
|
|
|
17
20
|
* 锚点标题与 `docs/ONBOARDING.md` 里的小节标题**逐字一致**,这样"锚点存在"可以机械核对。
|
|
18
21
|
*/
|
|
19
22
|
export const DSH_HINTS = {
|
|
20
|
-
config: { hint: '你手工配置的本机地址不对或已失效', docAnchor: '手工指定本机地址' },
|
|
21
|
-
'host-service': { hint: '当前宿主没有把本机访问方式交给插件(多为插件没装进正在运行的宿主)', docAnchor: '装在哪里:档案与插件入口' },
|
|
22
|
-
DSH_WEB_URL: { hint: '环境变量里的本机地址已过期', docAnchor: '手工指定本机地址' },
|
|
23
|
-
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: '排查:连不上时看这里' },
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
/**
|
|
@@ -29,9 +32,9 @@ export const DSH_HINTS = {
|
|
|
29
32
|
* 锚点标题与 `docs/ONBOARDING.md` 的小节标题逐字一致,`test/status.test.js` 会核对。
|
|
30
33
|
*/
|
|
31
34
|
export const HOST_HINTS = {
|
|
32
|
-
desktop: { hint: '你正在用官方桌面版(连接器已装进它的档案)', docAnchor: '安装:官方桌面版' },
|
|
33
|
-
web: { hint: '你正在用命令行 web 版', docAnchor: '安装:命令行 web 版' },
|
|
34
|
-
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: '排查:连不上时看这里' },
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/** 本机 DSH 侧的诊断块(老字段一个不少,新字段只增)。 */function dshBlock(agent) {
|
|
@@ -46,7 +49,7 @@ export const HOST_HINTS = {
|
|
|
46
49
|
error: agent.dshError ?? null,
|
|
47
50
|
// 新增(只增不改):失败分类 + 用户可读的一句话 + 文档锚点。
|
|
48
51
|
errorKind,
|
|
49
|
-
hint: entry?.hint ?? null,
|
|
52
|
+
hint: entry?.i18n ? T(entry.i18n) : entry?.hint ?? null,
|
|
50
53
|
docAnchor: entry?.docAnchor ?? null,
|
|
51
54
|
}
|
|
52
55
|
}
|
|
@@ -108,7 +111,7 @@ export function statusSnapshot(agent) {
|
|
|
108
111
|
// 新增(只增不改):宿主形态 + 它对应的一句话与文档锚点,
|
|
109
112
|
// 决定用户该按哪条说明安装/排障(FR-012)。
|
|
110
113
|
host: agent.hostForm,
|
|
111
|
-
hostHint: hostEntry.hint,
|
|
114
|
+
hostHint: hostEntry.i18n ? T(hostEntry.i18n) : hostEntry.hint,
|
|
112
115
|
hostDocAnchor: hostEntry.docAnchor,
|
|
113
116
|
protocolVersion: PROTOCOL_VERSION,
|
|
114
117
|
// Same facts as `_link/hello`, which is the channel the app actually uses:
|
|
@@ -134,7 +137,7 @@ export function statusSnapshot(agent) {
|
|
|
134
137
|
relayLocked: Boolean(realConfiguredRelay),
|
|
135
138
|
command: agent.needsEnroll ? agent.enrollHint() : null,
|
|
136
139
|
hint: agent.needsEnroll
|
|
137
|
-
? '
|
|
140
|
+
? T('status.unregistered')
|
|
138
141
|
: null,
|
|
139
142
|
},
|
|
140
143
|
state: agent.state,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-link",
|
|
3
|
-
"version": "0.8.
|
|
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",
|