dsh-crossterm 0.7.2 → 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,24 @@
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
+
3
22
  ## 0.7.2 — 2026-10-19
4
23
 
5
24
  ### 停用云端老文件夹:设备心跳搬进账号共享目录
package/README.md CHANGED
@@ -28,15 +28,15 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
28
28
  > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
29
29
  > 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
30
30
 
31
- **本版更新(0.7.2)**
32
-
33
- - **云端老文件夹停用**:不再把本机总线镜像到 `/dsh云盘/crossterm/bus`(老目录里 12 个 json 是状态镜像、
34
- 200 个 md 是本机邮件的子集、200 个 tmp 是原子写残留,没有必须迁移的数据)。
35
- - **设备心跳搬进账号共享目录**:`<云端根>/<账号>/shared/<设备>/_device.json`,每 60 s 写一次;
36
- 「云端设备」列表改为新布局优先,老目录只作 `<0.7.2` 设备的兼容来源。
37
- - 老镜像需要时可在 `config.json` 里把 `busSyncEnabled` 设 true 开回来;`term_cloud` 的 `once` 在镜像停用时给出说明。
38
- - 老目录**只停写、不删除**:还跑 `<0.7.2` 的设备仍从那里读邮件与 presence,等全部升级后再整体删掉。
39
- - 要不要跟着动手:**要** —— 别的机器升到 0.7.2 后,云端设备列表才会来自新布局。
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`。
40
40
 
41
41
  ## 一、安装与卸载
42
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.2'
25
+ export const VERSION = '0.7.3'
26
26
 
27
27
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
28
  export function dshHome() {
@@ -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
+ }
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) {
@@ -1369,7 +1437,7 @@ function buildTools(term, agent, part = 'all') {
1369
1437
 
1370
1438
  const list = defineTool({
1371
1439
  name: 'term_list',
1372
- description: '列出同一账号下所有在线的 DSH 终端(桌面端 / dsh web / dsh headless 等),以及每个终端当前活着的会话 id。要给别的终端发消息或传文件时,先用它确认目标。',
1440
+ description: '列出同一账号下所有在线的 DSH 终端(桌面端 / dsh web / dsh headless 等,含别的机器上的云端设备),以及每个终端当前活着的会话 id。要给别的终端发消息或传文件时,先用它确认目标。',
1373
1441
  parameters: {
1374
1442
  includeSelf: { type: 'boolean', description: '是否把自己也算进去,默认 false' },
1375
1443
  },
@@ -1386,6 +1454,11 @@ function buildTools(term, agent, part = 'all') {
1386
1454
  if (v.nodes.length) {
1387
1455
  lines.push('', `其它终端 ${v.nodes.length} 个:`)
1388
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
+ }
1389
1462
  const codes = Array.isArray(node.chains) && node.chains.length
1390
1463
  ? ` 特征码 ${[...new Set(node.chains.map((c) => c.code))].join('/')}`
1391
1464
  : ''
@@ -1434,6 +1507,16 @@ function buildTools(term, agent, part = 'all') {
1434
1507
  sessions: Array.isArray(node.sessions) ? node.sessions : [],
1435
1508
  chains: Array.isArray(node.chains) ? node.chains : [],
1436
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 { /* 云端抖动不影响本地列表 */ }
1437
1520
  return {
1438
1521
  ok: true,
1439
1522
  // 本终端:同一个 DSH 进程里可能开着好几个会话,它们彼此也算「房间里的成员」
@@ -1485,7 +1568,7 @@ function buildTools(term, agent, part = 'all') {
1485
1568
 
1486
1569
  const send = defineTool({
1487
1570
  name: 'term_send',
1488
- 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)。',
1489
1572
  parameters: {
1490
1573
  to: { type: 'string', description: '目标终端:nodeId 或显示名(term_list 里的 name)、特征码,"self" 表示本终端' },
1491
1574
  text: { type: 'string', description: '消息内容' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",