dsh-crossterm 0.7.1 → 0.7.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 CHANGED
@@ -1,5 +1,48 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.7.3 — 2026-10-19
4
+
5
+ ### 跨设备 term_send:本机总线找不到时自动走云端信封
6
+
7
+ - 背景:0.7.2 把总线镜像默认关掉之后,`term_send` 只能看见本机总线里的节点,**跨设备发信会直接报「找不到终端」**;
8
+ 收信侧(云端中转站)本来就是好的,缺的是发信侧的出口。(实测:YOCE_C 0.7.1 来信能进会话,回信只能手工写信封。)
9
+ - 新文件 `lib/cloud/match.js`(纯函数):`normTarget()`、`matchDevice(devices, raw, { selfId })` —— 先 nodeId / 终端名精确,
10
+ 再 nodeId 前缀 / 名字或主机名包含;同名候选里在线的优先、心跳新的优先;`selfId` 把本机排除掉。`deviceRow(device)` 把云端设备变成 `term_list` 的一行(`cloud:true`、`sessionCount`)。
11
+ - `lib/index.js`:`term.send()` 在 `resolveNode()` 落空时改为查 `cloudDevice(target)`(`home.devices()` + `matchDevice`,
12
+ 排除本机),命中就走新的 `sendViaCloud()` → `home.send({toDevice, toSession, text, files, subject, from})`;
13
+ 回包带 `via:'cloud'`、`mailId`、`to`,提示「对方中转站 20 秒内取走」。找不到才报错,文案区分「本机总线没有 / 云端设备也没有」。
14
+ - `term_list`:本地节点之外补上云端设备(同 nodeId 不重复),渲染成「(云端设备 v0.7.3,在线)N 个会话」;
15
+ 这样跨设备发信前能直接看到目标 nodeId/名字。
16
+ - 工具描述同步:`term_send` 说明「别的机器(云端设备)也认」;`term_list` 说明含云端设备。
17
+ - 探针 `probe-cloud-send.mjs`:**24 项全 PASS**(精确/前缀/包含匹配、大小写、selfId 排除、在线优先、空表与空目标、
18
+ `deviceRow` 字段、`index.js` 里兜底与 `via:'cloud'` 接线、`home.send` 仍走 `relay.sendEnvelope`)。
19
+ - 已知边界:跨设备 **一发多投**(`term_broadcast` 走特征码)仍然只在本机总线上扩散;云端群发后续再做。
20
+ - 要不要跟着动手:**不用** —— 但两边都要 ≥0.7.3 才能互相 `term_send`;只升一边时可以用云端收件箱写信封兜底。
21
+
22
+ ## 0.7.2 — 2026-10-19
23
+
24
+ ### 停用云端老文件夹:设备心跳搬进账号共享目录
25
+
26
+ - 背景(User said, paraphrased):查清云盘老目录 `/dsh云盘/crossterm/bus` 里没有必须迁移的数据后,停用老文件夹。
27
+ - 老目录盘点:17 个目录 / 412 个文件 / 679,619 B,其中 200 个 `.md` 是历史邮件、200 个 `.tmp` 是原子写残留、
28
+ 12 个 `json` 是状态镜像;本机 `F:\dsh\001X\crossterm-data\mail` 有 253 个(342,402 B)、`inbox` 306 个(537,410 B),
29
+ 云端那份只是子集。唯一可能陌生的是发往本机的 2 封 `pending`,内容是 10-19 00:52 的跨终端验收测试信。
30
+ - 新文件 `lib/cloud/presence.js`:`DEVICE_FILE='_device.json'`、`makePresence()`、`writePresence({pan,layout,info})`
31
+ (写到 `shared/<deviceId>/_device.json`)、`listPresences({pan,layout,onlineMs=3分钟})`(遍历共享目录、按 `at` 倒序、标 `online`)。
32
+ - `lib/cloud/home.js`:新增 `deviceInfo` 参数(实时快照:版本/会话数/链/pid)、`presenceNow()`、`devices()`;
33
+ `status().presence = { at, error, intervalMs }`;`start()` 起 `presenceIntervalMs`(默认 60 s)定时器,启动 5 s 先报一次。
34
+ - `lib/index.js`:`enableCloud()` 里只有 `cfg.busSyncEnabled === true` 才创建老 `createCloud` 镜像;云端目录(home)
35
+ 一律启用。`term_cloud` 的 `once` 在镜像停用后明确回「总线镜像已停用」,status 兜底对象带 `design:'cloud-home'` 与 `home`。
36
+ - `lib/bus.js` DEFAULTS:新增 `busSyncEnabled: false`(老镜像默认关闭,需要时在 config.json 里开回来)与 `presenceIntervalMs: 60000`。
37
+ - `lib/web.js`:`/crossterm/api/devices` 合并两份来源 —— 老目录 `listCloudDevices(cloudRoot)`(给 `<0.7.2` 的设备)
38
+ + 新布局 `listPresences()`(`source: 'layout'`,同 nodeId 时新布局优先);`status.cloud` 增 `design`,
39
+ 非镜像模式下 `root` 显示账号目录、`intervalMs` 显示心跳间隔。
40
+ - 探针 `probe-presence.mjs`:16 项全 PASS(心跳字段/落点/两台设备在线判定/排序/跳过无心跳目录/无布局返回 no-layout/
41
+ 上传失败抛错/home 集成:`presenceNow`、`status.presence`、`devices`、`start` 后间隔)。
42
+ - 兼容:还跑 `<0.7.2` 的设备仍然读老目录里的邮件与 presence,所以老目录**只停写、不删除**;
43
+ 等所有设备升到 0.7.2 之后再整体删掉。
44
+ - 要不要跟着动手:**要** —— 别的机器升级到 0.7.2 后云端设备列表才来自新布局;老目录可在全部升级后删除。
45
+
3
46
  ## 0.7.1 — 2026-10-19
4
47
 
5
48
  ### 「更新」按钮在别的机器上装不上:三级兜底 + 全步骤回执
package/README.md CHANGED
@@ -28,14 +28,15 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
28
28
  > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
29
29
  > 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
30
30
 
31
- **本版更新(0.7.1)**
32
-
33
- - **修「别的机器点更新装不上」**:更新不再只依赖 PATH 里的 `dsh`,改成三级兜底 ——
34
- ① PATH 里的 `dsh`;② DSH 自带 CLI(`Electron --expose-internals <安装目录>\resources\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js`);
35
- ③ 直接调自带 pnpm(`<DSH_HOME>\dsh-runtimes\...\dependencies\pnpm` 或 `<安装目录>\resources\runtime\pnpm`)。
36
- - 装完会读 `<profile>\node_modules\dsh-crossterm\package.json` **核对真实版本**,分别提示「已更新到 X」「已经是最新」「更新没成功」。
37
- - 失败时卡片直接把每步的退出码、耗时、输出尾部显示出来;全过程写 `<总线目录>\logs\update.log`。
38
- - 顺带补上 `status.home.layout` 里缺的 `rulesVersion` / `rulesFailed` / `accountSource`。
31
+ **本版更新(0.7.3)**
32
+
33
+ - **跨设备 `term_send` 通了**:本机总线里找不到目标时,自动去云端设备列表里按 nodeId / 终端名找,
34
+ 命中就走云端信封 —— 对方的中转站 20 秒内取走并投进对应会话。回包里会带 `via:"cloud"` 和信封号。
35
+ - **`term_list` 会列出云端设备**:别的机器即使没写本机总线,也会显示成「(云端设备 v0.7.3,在线)N 个会话」,
36
+ 发信前能直接拿到 nodeId / 名字。
37
+ - 匹配规则:先 nodeId、终端名精确,再 nodeId 前缀、名字/主机名包含;同名候选里在线优先、心跳新的优先;本机自动排除。
38
+ - 已知边界:跨设备**一发多投**(`term_broadcast` 走特征码)仍只在本机总线上扩散,云端群发后续再做。
39
+ - 要不要跟着动手:**不用** —— 但两边都要 ≥0.7.3 才能互相 `term_send`。
39
40
 
40
41
  ## 一、安装与卸载
41
42
 
package/lib/bus.js CHANGED
@@ -22,7 +22,7 @@ import os from 'node:os'
22
22
  import path from 'node:path'
23
23
  import crypto from 'node:crypto'
24
24
 
25
- export const VERSION = '0.7.1'
25
+ export const VERSION = '0.7.3'
26
26
 
27
27
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
28
  export function dshHome() {
@@ -61,6 +61,9 @@ const DEFAULTS = {
61
61
  inboxRoot: '',
62
62
  // 云总线(百度网盘)同步:默认关闭,开启后插件自己按 cloudIntervalMs 往返镜像。
63
63
  cloudEnabled: false,
64
+ // 0.7.2 起老的总线镜像(cloudRoot 那一套)默认不开,云端只用「账号/设备/共享收件箱」目录。
65
+ // 还跑 <0.7.2 的设备仍会把状态写进 cloudRoot,本机用只读方式兼容它。
66
+ busSyncEnabled: false,
64
67
  cloudRoot: '/dsh云盘/crossterm/bus',
65
68
  cloudIntervalMs: 4000,
66
69
  // 云端目录(0.3.39 起):<cloudHome>/<账号>/<设备>/ + <账号>/shared/<设备>/inbox
@@ -68,6 +71,7 @@ const DEFAULTS = {
68
71
  cloudAccount: '',
69
72
  relayIntervalMs: 20000,
70
73
  backupIntervalMs: 300000,
74
+ presenceIntervalMs: 60000,
71
75
  backupSessionsPerRun: 20,
72
76
  backupAttachmentsPerRun: 20,
73
77
  attachmentBackupEnabled: true,
package/lib/cloud/home.js CHANGED
@@ -4,19 +4,21 @@ import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { ensureLayout, readLayoutCache, DEFAULT_HOME } from './layout.js'
6
6
  import { createRelay } from './relay.js'
7
+ import { writePresence, listPresences } from './presence.js'
7
8
  import { backupSessions, backupAttachments, backupSummary } from './session-backup.js'
8
9
 
9
10
  /**
10
11
  * @param {object} opts
11
12
  * @param {object} opts.cfg 插件配置(readConfig 结果)
12
13
  * @param {object} opts.device { nodeId, name, version, platform, sessions }
14
+ * @param {Function} [opts.deviceInfo] 返回当前设备快照(版本/会话数/链),心跳用;不传就用 device
13
15
  * @param {object} opts.pan 云盘客户端
14
16
  * @param {string} opts.busDir 总线根(<DSH_HOME>/crossterm)
15
17
  * @param {Function} opts.deliver async ({ to, envelope, files }) => { ok, sessionId, reason }
16
18
  * @param {Function} [opts.names] 返回 { <会话短号>: { name } },给 _session.json 补显示名
17
19
  * @param {Function} [opts.log]
18
20
  */
19
- export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log = () => {} }) {
21
+ export function createCloudHome({ cfg, device, deviceInfo, pan, busDir, deliver, names, log = () => {} }) {
20
22
  const home = path.dirname(busDir)
21
23
  const root = path.join(busDir, 'cloud')
22
24
  fs.mkdirSync(root, { recursive: true })
@@ -33,6 +35,9 @@ export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log
33
35
  let backupBusy = null
34
36
  let relayTimer = 0
35
37
  let backupTimer = 0
38
+ let presenceTimer = 0
39
+ let presenceAt = 0
40
+ let presenceError = ''
36
41
 
37
42
  const relay = createRelay({
38
43
  pan,
@@ -114,6 +119,44 @@ export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log
114
119
  return relay.sendEnvelope(opts)
115
120
  }
116
121
 
122
+ /** 把本机状态写进共享目录(0.7.2:取代云端老目录的 presence 镜像) */
123
+ async function presenceNow() {
124
+ try {
125
+ const layout = await getLayout(false)
126
+ if (!layout || !layout.paths) return { ok: false, reason: 'no-layout' }
127
+ const extra = typeof deviceInfo === 'function' ? deviceInfo() || {} : {}
128
+ const ret = await writePresence({
129
+ pan,
130
+ layout,
131
+ info: {
132
+ nodeId: device.nodeId,
133
+ name: extra.name || device.name,
134
+ hostname: extra.hostname || device.hostname || '',
135
+ version: extra.version || device.version,
136
+ platform: extra.platform || device.platform,
137
+ pid: extra.pid || device.pid || 0,
138
+ sessions: extra.sessions ?? device.sessions ?? 0,
139
+ chains: extra.chains || [],
140
+ startedAt: extra.startedAt || 0,
141
+ },
142
+ log: (line) => log(line),
143
+ })
144
+ presenceAt = Date.now()
145
+ presenceError = ret.ok ? '' : ret.reason || 'unknown'
146
+ return ret
147
+ } catch (e) {
148
+ presenceError = (e && e.message) || String(e)
149
+ return { ok: false, reason: presenceError }
150
+ }
151
+ }
152
+
153
+ /** 读云端所有设备心跳(新布局;不含老目录) */
154
+ async function devices() {
155
+ const layout = await getLayout(false)
156
+ if (!layout || !layout.paths) return { ok: false, devices: [] }
157
+ return listPresences({ pan, layout, log: (line) => log(line) })
158
+ }
159
+
117
160
  function status() {
118
161
  const layout = readLayoutCache(cacheFile)
119
162
  return {
@@ -132,6 +175,7 @@ export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log
132
175
  : null,
133
176
  relay: { ...relay.stats(), intervalMs: relayTimer },
134
177
  backup: { ...backupSummary(backupState), intervalMs: backupTimer },
178
+ presence: { at: presenceAt, error: presenceError, intervalMs: presenceTimer },
135
179
  roots: { sessions: sessionsRoot, attachments: attachmentsRoot },
136
180
  }
137
181
  }
@@ -141,14 +185,17 @@ export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log
141
185
  started = true
142
186
  relayTimer = Number(cfg.relayIntervalMs || 20000)
143
187
  backupTimer = Number(cfg.backupIntervalMs || 300000)
188
+ presenceTimer = Number(cfg.presenceIntervalMs || 60000)
144
189
  void getLayout(false).catch(() => {})
145
190
  timers.push(setInterval(() => { void relayNow().catch(() => {}) }, relayTimer))
146
191
  timers.push(setInterval(() => { void backupNow().catch(() => {}) }, backupTimer))
192
+ timers.push(setInterval(() => { void presenceNow().catch(() => {}) }, presenceTimer))
147
193
  for (const t of timers) if (t && typeof t.unref === 'function') t.unref()
148
194
  // 启动 8 秒后先跑一轮收信、30 秒后跑第一轮备份(别和宿主启动抢资源)
149
195
  setTimeout(() => { void relayNow().catch(() => {}) }, 8000)
150
196
  setTimeout(() => { void backupNow().catch(() => {}) }, 30000)
151
- log(`云端目录就绪:relay ${relayTimer}ms / backup ${backupTimer}ms(根 ${cfg.cloudHome || DEFAULT_HOME})`)
197
+ setTimeout(() => { void presenceNow().catch(() => {}) }, 5000)
198
+ log(`云端目录就绪:relay ${relayTimer}ms / backup ${backupTimer}ms / presence ${presenceTimer}ms(根 ${cfg.cloudHome || DEFAULT_HOME})`)
152
199
  }
153
200
 
154
201
  function stop() {
@@ -157,5 +204,5 @@ export function createCloudHome({ cfg, device, pan, busDir, deliver, names, log
157
204
  timers = []
158
205
  }
159
206
 
160
- return { start, stop, status, getLayout, backupNow, relayNow, send, cacheFile, relayDir }
207
+ return { start, stop, status, getLayout, backupNow, relayNow, send, presenceNow, devices, cacheFile, relayDir }
161
208
  }
@@ -0,0 +1,45 @@
1
+ // 云端设备匹配:term_send / term_list 用,纯函数便于单测
2
+ export function normTarget(raw) {
3
+ return String(raw || '').trim().toLowerCase()
4
+ }
5
+
6
+ /**
7
+ * 在云端设备列表里找目标(nodeId 精确 / 名字精确 / nodeId 前缀 / 名字包含)。
8
+ * devices 元素至少要有 nodeId、name;可带 online。
9
+ * selfId 用来把「本机」排除掉(云端列表里可能还留着本机上次的心跳)。
10
+ */
11
+ export function matchDevice(devices, raw, opts = {}) {
12
+ const list = Array.isArray(devices) ? devices.filter((d) => d && d.nodeId) : []
13
+ const selfId = String(opts.selfId || '')
14
+ const pool = selfId ? list.filter((d) => d.nodeId !== selfId) : list
15
+ const target = normTarget(raw)
16
+ if (!target || !pool.length) return undefined
17
+ const exact = pool.filter((d) => String(d.nodeId).toLowerCase() === target
18
+ || String(d.name || '').toLowerCase() === target)
19
+ const loose = pool.filter((d) => String(d.nodeId).toLowerCase().startsWith(target)
20
+ || String(d.name || '').toLowerCase().includes(target)
21
+ || String(d.hostname || '').toLowerCase().includes(target))
22
+ const hits = exact.length ? exact : loose
23
+ if (!hits.length) return undefined
24
+ // 在线的优先,其次心跳新的
25
+ return hits.slice().sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0)
26
+ || (Number(b.at) || 0) - (Number(a.at) || 0))[0]
27
+ }
28
+
29
+ /** 云端设备 → term_list 里的一行(本地总线里没有它时才补) */
30
+ export function deviceRow(d) {
31
+ return {
32
+ nodeId: d.nodeId,
33
+ name: d.name || d.nodeId,
34
+ hostname: d.hostname || '',
35
+ workspace: '',
36
+ pid: d.pid,
37
+ ageMs: Number(d.at) ? Math.max(0, Date.now() - Number(d.at)) : 0,
38
+ online: d.online !== false,
39
+ sessions: [],
40
+ chains: Array.isArray(d.chains) ? d.chains : [],
41
+ cloud: true,
42
+ version: d.version || '',
43
+ sessionCount: Number(d.sessions) || 0,
44
+ }
45
+ }
@@ -0,0 +1,71 @@
1
+ // 新布局下的设备心跳:<accountDir>/shared/<deviceId>/_device.json
2
+ //
3
+ // 为什么要它:0.7.2 起不再把本地总线镜像到云端老目录(/dsh云盘/crossterm/bus),
4
+ // 但「云端设备」列表得继续有数据 —— 于是每台设备把自己的状态写进账号共享目录。
5
+ // 老目录仍在读(给还跑旧版本的设备留兼容),只是不再写。
6
+
7
+ export const DEVICE_FILE = '_device.json'
8
+
9
+ /** 组装一份心跳内容(全字段可 JSON 序列化) */
10
+ export function makePresence({
11
+ nodeId = '',
12
+ name = '',
13
+ hostname = '',
14
+ version = '',
15
+ platform = '',
16
+ pid = 0,
17
+ sessions = 0,
18
+ chains = [],
19
+ startedAt = 0,
20
+ at = Date.now(),
21
+ } = {}) {
22
+ return {
23
+ nodeId,
24
+ name,
25
+ hostname,
26
+ version,
27
+ platform,
28
+ pid,
29
+ sessions,
30
+ chains: Array.isArray(chains) ? chains : [],
31
+ startedAt,
32
+ at,
33
+ }
34
+ }
35
+
36
+ /** 把心跳写进共享目录(覆盖自己那份) */
37
+ export async function writePresence({ pan, layout, info, log = () => {} }) {
38
+ const paths = layout && layout.paths
39
+ if (!paths || !paths.deviceRoot) return { ok: false, reason: 'no-layout' }
40
+ const remote = `${paths.deviceRoot}/${DEVICE_FILE}`
41
+ const payload = makePresence(info)
42
+ const buf = Buffer.from(JSON.stringify(payload, null, 2) + '\n', 'utf8')
43
+ await pan.putBuffer(buf, remote)
44
+ log(`presence: 已上报 ${payload.name || payload.nodeId} v${payload.version} → ${remote}`)
45
+ return { ok: true, remote, payload }
46
+ }
47
+
48
+ /** 列出所有设备的共享目录,逐个取 _device.json */
49
+ export async function listPresences({ pan, layout, onlineMs = 3 * 60 * 1000, now = Date.now(), log = () => {} }) {
50
+ const paths = layout && layout.paths
51
+ if (!paths || !paths.sharedDir) return { ok: false, devices: [], files: 0, reason: 'no-layout' }
52
+ const list = await pan.lsAll(paths.sharedDir)
53
+ const entries = (list && list.list) || []
54
+ const devices = []
55
+ let files = 0
56
+ for (const entry of entries) {
57
+ if (entry.isdir !== 1) continue
58
+ const remote = `${paths.sharedDir}/${entry.server_filename}/${DEVICE_FILE}`
59
+ try {
60
+ const got = await pan.getBuffer(remote)
61
+ const info = JSON.parse(got.buffer.toString('utf8'))
62
+ devices.push({ ...info, online: now - Number(info.at || 0) < onlineMs })
63
+ files += 1
64
+ } catch {
65
+ // 目录里还没心跳(或刚建好),跳过
66
+ }
67
+ }
68
+ devices.sort((a, b) => Number(b.at || 0) - Number(a.at || 0))
69
+ log(`presence: 共享目录里读到 ${devices.length} 台设备(文件 ${files})`)
70
+ return { ok: true, devices, files, tmpFiles: 0 }
71
+ }
package/lib/index.js CHANGED
@@ -38,6 +38,7 @@ import { createPan } from './cloud/pan.js'
38
38
  import { createCloud } from './cloud/sync.js'
39
39
  import { createCloudHome } from './cloud/home.js'
40
40
  import { runSetup } from './cloud/setup.js'
41
+ import { deviceRow, matchDevice } from './cloud/match.js'
41
42
  import { registerWeb } from './web.js'
42
43
 
43
44
  const PLUGIN = 'dsh-crossterm'
@@ -342,6 +343,67 @@ class CrossTerm {
342
343
  return pool[0]
343
344
  }
344
345
 
346
+ // ── 云端设备(跨设备投递,0.7.3 起)─────────────────────────────────
347
+
348
+ /** 云端设备列表:没连云端、没布局、或还读不到时统一回空数组 */
349
+ async cloudDevices() {
350
+ if (!this.home) return []
351
+ try {
352
+ const r = await this.home.devices()
353
+ return r && r.ok && Array.isArray(r.devices) ? r.devices : []
354
+ } catch (e) {
355
+ this.warn(`cloudDevices: ${(e && e.message) || String(e)}`)
356
+ return []
357
+ }
358
+ }
359
+
360
+ /** 按 nodeId / 终端名在云端设备里找目标(排除本机) */
361
+ async cloudDevice(to) {
362
+ const list = await this.cloudDevices()
363
+ return matchDevice(list, to, { selfId: this.nodeId })
364
+ }
365
+
366
+ /** 走云端信封投递:对方中转站轮询自己的 inbox 取信 */
367
+ async sendViaCloud({ device, sessionId, text, files = [], subject = '', fromSessionId }) {
368
+ if (!this.home) return { ok: false, detail: '云端目录还没起来,暂时没法跨设备投递' }
369
+ const from = {
370
+ nodeId: this.nodeId,
371
+ name: this.cfg.name,
372
+ hostname: os.hostname(),
373
+ version: VERSION,
374
+ device: this.nodeId,
375
+ sessionId: fromSessionId || '',
376
+ sessionLabel: fromSessionId ? this.labelOf(fromSessionId) : '',
377
+ }
378
+ const r = await this.home.send({
379
+ toDevice: device.nodeId,
380
+ toSession: sessionId || '',
381
+ text,
382
+ files: Array.isArray(files) ? files : [],
383
+ subject,
384
+ from,
385
+ })
386
+ if (!r || !r.ok) {
387
+ return {
388
+ ok: false,
389
+ detail: `云端投递失败:${(r && r.reason) || '未知原因'}`,
390
+ to: device.nodeId,
391
+ via: 'cloud',
392
+ }
393
+ }
394
+ await this.note(`cloud sent ${r.mailId} -> ${device.nodeId}`)
395
+ const fileNames = (Array.isArray(files) ? files : []).map((f) => path.basename(f))
396
+ return {
397
+ ok: true,
398
+ detail: `已通过云端投递给「${device.name || device.nodeId}」(设备 ${device.nodeId},信封 ${r.mailId})`
399
+ + `${fileNames.length ? `(含 ${fileNames.length} 个附件)` : ''};对方中转站 20 秒内取走`,
400
+ mailId: r.mailId,
401
+ to: device.nodeId,
402
+ via: 'cloud',
403
+ files: fileNames,
404
+ }
405
+ }
406
+
345
407
  // ── 投递(接收侧)──────────────────────────────────────────────────────
346
408
 
347
409
  async landFiles(msg, sessionId) {
@@ -610,9 +672,15 @@ class CrossTerm {
610
672
  const target = raw || (sessionId ? this.nodeId : '')
611
673
  const node = await this.resolveNode(target)
612
674
  if (!node) {
675
+ // 本机总线里没有:再看云端设备(0.7.3 起跨设备也能直接 term_send)
676
+ const device = await this.cloudDevice(target)
677
+ if (device) {
678
+ return this.sendViaCloud({ device, sessionId, text, files, subject, fromSessionId })
679
+ }
613
680
  return {
614
681
  ok: false,
615
- detail: `找不到终端或特征码「${target || to}」;先调用 term_list 看有哪些节点在线,或先 term_link 建一串特征码`,
682
+ detail: `找不到终端或特征码「${target || to}」;本机总线里没有,云端设备里也没有。`
683
+ + '先用 term_list 看有哪些节点在线(云端设备要求本机已连云端),或先 term_link 建一串特征码',
616
684
  }
617
685
  }
618
686
  if (node.nodeId === this.nodeId && !sessionId) {
@@ -1205,27 +1273,30 @@ class CrossTerm {
1205
1273
  }
1206
1274
  }
1207
1275
 
1208
- /** 启动云总线同步(百度网盘):只有配置里 cloudEnabled 才会跑 */
1276
+ /** 启动云端:0.7.2 起默认只跑「云端目录」(账号/设备/共享收件箱/中转站/备份),
1277
+ * 老的总线镜像(cloudRoot=/dsh云盘/crossterm/bus)需要 busSyncEnabled=true 才跑 */
1209
1278
  enableCloud() {
1210
- if (this.cloud) return this.cloud
1211
1279
  const pan = createPan({
1212
1280
  appFile: this.cfg.baiduAppFile,
1213
1281
  authFile: this.cfg.baiduAuthFile,
1214
1282
  log: (m) => void this.note('cloud: ' + m),
1215
1283
  })
1216
- this.cloud = createCloud({
1217
- busDir: this.bus.root,
1218
- mailDir: this.cfg.mailDir,
1219
- inboxDir: this.cfg.inboxRoot,
1220
- cloudRoot: this.cfg.cloudRoot,
1221
- stateFile: this.cfg.cloudStateFile,
1222
- intervalMs: this.cfg.cloudIntervalMs,
1223
- pan,
1224
- log: (m) => void this.note('cloud: ' + m),
1225
- })
1226
- this.cloud.start()
1227
- void this.note(`cloud sync 启动 → ${this.cfg.cloudRoot}(${this.cfg.cloudIntervalMs}ms)`)
1284
+ if (this.cfg.busSyncEnabled && !this.cloud) {
1285
+ this.cloud = createCloud({
1286
+ busDir: this.bus.root,
1287
+ mailDir: this.cfg.mailDir,
1288
+ inboxDir: this.cfg.inboxRoot,
1289
+ cloudRoot: this.cfg.cloudRoot,
1290
+ stateFile: this.cfg.cloudStateFile,
1291
+ intervalMs: this.cfg.cloudIntervalMs,
1292
+ pan,
1293
+ log: (m) => void this.note('cloud: ' + m),
1294
+ })
1295
+ this.cloud.start()
1296
+ void this.note(`总线镜像启动 → ${this.cfg.cloudRoot}(${this.cfg.cloudIntervalMs}ms)`)
1297
+ }
1228
1298
  this.enableCloudHome(pan)
1299
+ this.pan = pan
1229
1300
  return this.cloud
1230
1301
  }
1231
1302
 
@@ -1246,6 +1317,17 @@ class CrossTerm {
1246
1317
  busDir: this.bus.root,
1247
1318
  deliver: (opts) => this.relayDeliver(opts),
1248
1319
  names: () => this.sessionNames(),
1320
+ deviceInfo: () => ({
1321
+ nodeId: this.nodeId,
1322
+ name: this.cfg.name,
1323
+ hostname: os.hostname(),
1324
+ version: VERSION,
1325
+ platform: process.platform,
1326
+ pid: process.pid,
1327
+ sessions: this.sessions().length,
1328
+ chains: [...new Set([...this.links.values()].map((l) => l.code))],
1329
+ startedAt: this.startedAt || 0,
1330
+ }),
1249
1331
  log: (m) => void this.note('cloud: ' + m),
1250
1332
  })
1251
1333
  this.home.start()
@@ -1355,7 +1437,7 @@ function buildTools(term, agent, part = 'all') {
1355
1437
 
1356
1438
  const list = defineTool({
1357
1439
  name: 'term_list',
1358
- description: '列出同一账号下所有在线的 DSH 终端(桌面端 / dsh web / dsh headless 等),以及每个终端当前活着的会话 id。要给别的终端发消息或传文件时,先用它确认目标。',
1440
+ description: '列出同一账号下所有在线的 DSH 终端(桌面端 / dsh web / dsh headless 等,含别的机器上的云端设备),以及每个终端当前活着的会话 id。要给别的终端发消息或传文件时,先用它确认目标。',
1359
1441
  parameters: {
1360
1442
  includeSelf: { type: 'boolean', description: '是否把自己也算进去,默认 false' },
1361
1443
  },
@@ -1372,6 +1454,11 @@ function buildTools(term, agent, part = 'all') {
1372
1454
  if (v.nodes.length) {
1373
1455
  lines.push('', `其它终端 ${v.nodes.length} 个:`)
1374
1456
  for (const node of v.nodes) {
1457
+ if (node.cloud) {
1458
+ lines.push(`- 「${node.name}」 nodeId=${node.nodeId}(云端设备${node.version ? ` v${node.version}` : ''}`
1459
+ + `${node.online ? ',在线' : ',心跳已过期'})${node.sessionCount ? ` ${node.sessionCount} 个会话` : ''}`)
1460
+ continue
1461
+ }
1375
1462
  const codes = Array.isArray(node.chains) && node.chains.length
1376
1463
  ? ` 特征码 ${[...new Set(node.chains.map((c) => c.code))].join('/')}`
1377
1464
  : ''
@@ -1420,6 +1507,16 @@ function buildTools(term, agent, part = 'all') {
1420
1507
  sessions: Array.isArray(node.sessions) ? node.sessions : [],
1421
1508
  chains: Array.isArray(node.chains) ? node.chains : [],
1422
1509
  }))
1510
+ // 云端设备(别的机器,不写本机总线):本地节点列表里没有的补一行
1511
+ const seenNode = new Set(nodes.map((n) => n.nodeId))
1512
+ seenNode.add(term.nodeId)
1513
+ try {
1514
+ for (const d of await term.cloudDevices()) {
1515
+ if (seenNode.has(d.nodeId)) continue
1516
+ nodes.push(deviceRow(d))
1517
+ seenNode.add(d.nodeId)
1518
+ }
1519
+ } catch { /* 云端抖动不影响本地列表 */ }
1423
1520
  return {
1424
1521
  ok: true,
1425
1522
  // 本终端:同一个 DSH 进程里可能开着好几个会话,它们彼此也算「房间里的成员」
@@ -1471,7 +1568,7 @@ function buildTools(term, agent, part = 'all') {
1471
1568
 
1472
1569
  const send = defineTool({
1473
1570
  name: 'term_send',
1474
- description: '把一条消息投递给另一个 DSH 终端(或同一个终端里的另一个会话)里正在运行的会话(消息会以 user 消息进入那个会话,那边的 agent 会像收到用户输入一样处理)。target 用 term_list 里的 nodeId 或终端显示名;同一个终端里的其它会话用 to="self" 并带上 sessionId;要一发多投就把 to 写成一串特征码(先 term_link)。',
1571
+ description: '把一条消息投递给另一个 DSH 终端(或同一个终端里的另一个会话)里正在运行的会话(消息会以 user 消息进入那个会话,那边的 agent 会像收到用户输入一样处理)。target 用 term_list 里的 nodeId 或终端显示名;别的机器(云端设备)也认,本机总线找不到时会自动走云端信封;同一个终端里的其它会话用 to="self" 并带上 sessionId;要一发多投就把 to 写成一串特征码(先 term_link)。',
1475
1572
  parameters: {
1476
1573
  to: { type: 'string', description: '目标终端:nodeId 或显示名(term_list 里的 name)、特征码,"self" 表示本终端' },
1477
1574
  text: { type: 'string', description: '消息内容' },
@@ -1803,10 +1900,23 @@ function buildTools(term, agent, part = 'all') {
1803
1900
  }
1804
1901
  else if (action === 'once') {
1805
1902
  const c = term.cloud || term.enableCloud()
1903
+ if (!c) return { ok: false, detail: '总线镜像已停用(0.7.2 起默认只跑云端目录:账号/设备/共享收件箱),改用 action="home" 或 "relay"/"backup"' }
1806
1904
  const result = await c.once()
1807
1905
  return { ok: true, status: c.status(), result }
1808
1906
  }
1809
- const status = term.cloud ? term.cloud.status() : { running: false, intervalMs: term.cfg.cloudIntervalMs, cloudRoot: term.cfg.cloudRoot, cycles: 0, up: 0, down: 0, merged: 0 }
1907
+ const status = term.cloud
1908
+ ? term.cloud.status()
1909
+ : {
1910
+ running: Boolean(term.home),
1911
+ design: term.home ? 'cloud-home' : 'off',
1912
+ intervalMs: term.cfg.cloudIntervalMs,
1913
+ cloudRoot: term.home ? term.cfg.cloudHome || 'dsh' : '',
1914
+ cycles: 0,
1915
+ up: 0,
1916
+ down: 0,
1917
+ merged: 0,
1918
+ home: term.home ? term.home.status() : null,
1919
+ }
1810
1920
  return { ok: true, status }
1811
1921
  } catch (e) {
1812
1922
  return { ok: false, detail: (e && e.message) || String(e) }
package/lib/web.js CHANGED
@@ -8,6 +8,7 @@ import { spawn } from 'node:child_process'
8
8
  import { VERSION, readConfig, writeConfig } from './bus.js'
9
9
  import { createPan } from './cloud/pan.js'
10
10
  import { listCloudDevices, localDevice } from './cloud/devices.js'
11
+ import { listPresences } from './cloud/presence.js'
11
12
  import { runSetup } from './cloud/setup.js'
12
13
 
13
14
  const PKG = 'dsh-crossterm'
@@ -344,7 +345,10 @@ function statusOf(term) {
344
345
  /* ignore */
345
346
  }
346
347
  const cloud = term.cloud
348
+ const home = term.home
349
+ const homeStatus = home ? home.status() : null
347
350
  const provider = cloudProvider(cfg)
351
+ const busMirror = cfg.busSyncEnabled === true
348
352
  return {
349
353
  ok: true,
350
354
  version: VERSION,
@@ -358,14 +362,19 @@ function statusOf(term) {
358
362
  // 没配云盘时不要把默认路径当成「已连接的云端」显示出来
359
363
  configured: provider.configured,
360
364
  enabled: cfg.cloudEnabled === true && provider.configured,
361
- running: provider.configured && Boolean(cloud && cloud.running),
362
- root: provider.configured ? cfg.cloudRoot || '' : '',
363
- intervalMs: cfg.cloudIntervalMs || 0,
365
+ running: provider.configured && Boolean((cloud && cloud.running) || home),
366
+ root: provider.configured
367
+ ? busMirror
368
+ ? cfg.cloudRoot || ''
369
+ : (homeStatus && homeStatus.layout && homeStatus.layout.paths && homeStatus.layout.paths.accountDir) || cfg.cloudRoot || ''
370
+ : '',
371
+ intervalMs: busMirror ? cfg.cloudIntervalMs || 0 : (cfg.presenceIntervalMs || 60000),
372
+ design: busMirror ? 'bus+home' : 'home',
364
373
  provider,
365
374
  },
366
375
  chains,
367
- // 云端目录(0.3.39):账号/角色/设备目录/中转站/会话备份
368
- home: term.home ? term.home.status() : null,
376
+ // 云端目录(0.3.39):账号/角色/设备目录/中转站/会话备份/设备心跳
377
+ home: homeStatus,
369
378
  }
370
379
  }
371
380
 
@@ -482,6 +491,19 @@ export function registerWeb(ctx, term, log) {
482
491
  log: (m) => log('devices: ' + m),
483
492
  })
484
493
  const result = await listCloudDevices(pan, cfg.cloudRoot)
494
+ let fresh = { devices: [], files: 0 }
495
+ try {
496
+ const layout = term.home ? await term.home.getLayout(false) : null
497
+ if (layout) fresh = await listPresences({ pan, layout, log: (m) => log('devices: ' + m) })
498
+ } catch (error) {
499
+ log('devices: 新布局读取失败 ' + String((error && error.message) || error))
500
+ }
501
+ const byId = new Map()
502
+ for (const d of result.devices || []) byId.set(d.nodeId, d)
503
+ for (const d of fresh.devices || []) byId.set(d.nodeId, { ...byId.get(d.nodeId), ...d, source: 'layout' })
504
+ result.devices = [...byId.values()].sort((a, b) => Number(b.lastSeen ? Date.parse(b.lastSeen) : b.at || 0) - Number(a.lastSeen ? Date.parse(a.lastSeen) : a.at || 0))
505
+ delete result.tmpFiles
506
+ result.files = (result.files || 0) + (fresh.files || 0)
485
507
  const me = await localDevice(path.join(cfg.busDir || '', 'presence'), info.nodeId || '')
486
508
  if (me && !result.devices.some((d) => d.nodeId === me.nodeId)) result.devices.unshift(me)
487
509
  json(res, 200, { ...result, configured: true, me: info.nodeId || '' })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",