dsh-crossterm 0.7.2 → 0.7.4

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,40 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.7.4 — 2026-10-19
4
+
5
+ ### 没写收件人的信封不再卡死:回落「最近活跃会话」
6
+
7
+ - 现象:0.7.3 的跨设备 `term_send` 只写 `to.device`、不写会话名时,对方中转站收到信却**投不出去** ——
8
+ `relayDeliver()` 一看收件人列表为空就返回「信封里没有收件人(要填会话名)」,按设计「失败留件」,
9
+ 于是信一直躺在对方 `shared/<设备>/inbox/` 里。实测:本机 17:44 与 17:52 发给 YOCE_C 的两封都还在它 inbox 里、`done/` 是空的。
10
+ - 修法(`lib/index.js` 的 `relayDeliver`):收件人为空时不再直接失败,改为取本机会话列表里
11
+ **活跃会话优先、否则第一个**(`const latest = list.find((s) => s && s.active) || list[0]`),
12
+ 用它做收件人并把「(信封没写收件人,按「最近活跃会话」投给「X」)」写进正文;
13
+ 连一个会话都没有时才回「信封里没有收件人,本机也没有可投递的会话」。
14
+ - 效果:别的设备直接 `term_send` 到本机(不指会话)也能进到当前在聊的那个会话;发信侧照旧只写设备名即可。
15
+ - 探针 `probe-relay-fallback.mjs`:5 项全 PASS(fallback 分支、活跃会话优先、无会话明确失败、提示文案、名字回落短号);
16
+ `probe-cloud-send.mjs` 24 项不回归。
17
+ - 要不要跟着动手:**要** —— 收信方必须 ≥0.7.4 才能接住「没写收件人」的信;发信方 0.7.3 就够。
18
+
19
+ ## 0.7.3 — 2026-10-19
20
+
21
+ ### 跨设备 term_send:本机总线找不到时自动走云端信封
22
+
23
+ - 背景:0.7.2 把总线镜像默认关掉之后,`term_send` 只能看见本机总线里的节点,**跨设备发信会直接报「找不到终端」**;
24
+ 收信侧(云端中转站)本来就是好的,缺的是发信侧的出口。(实测:YOCE_C 0.7.1 来信能进会话,回信只能手工写信封。)
25
+ - 新文件 `lib/cloud/match.js`(纯函数):`normTarget()`、`matchDevice(devices, raw, { selfId })` —— 先 nodeId / 终端名精确,
26
+ 再 nodeId 前缀 / 名字或主机名包含;同名候选里在线的优先、心跳新的优先;`selfId` 把本机排除掉。`deviceRow(device)` 把云端设备变成 `term_list` 的一行(`cloud:true`、`sessionCount`)。
27
+ - `lib/index.js`:`term.send()` 在 `resolveNode()` 落空时改为查 `cloudDevice(target)`(`home.devices()` + `matchDevice`,
28
+ 排除本机),命中就走新的 `sendViaCloud()` → `home.send({toDevice, toSession, text, files, subject, from})`;
29
+ 回包带 `via:'cloud'`、`mailId`、`to`,提示「对方中转站 20 秒内取走」。找不到才报错,文案区分「本机总线没有 / 云端设备也没有」。
30
+ - `term_list`:本地节点之外补上云端设备(同 nodeId 不重复),渲染成「(云端设备 v0.7.3,在线)N 个会话」;
31
+ 这样跨设备发信前能直接看到目标 nodeId/名字。
32
+ - 工具描述同步:`term_send` 说明「别的机器(云端设备)也认」;`term_list` 说明含云端设备。
33
+ - 探针 `probe-cloud-send.mjs`:**24 项全 PASS**(精确/前缀/包含匹配、大小写、selfId 排除、在线优先、空表与空目标、
34
+ `deviceRow` 字段、`index.js` 里兜底与 `via:'cloud'` 接线、`home.send` 仍走 `relay.sendEnvelope`)。
35
+ - 已知边界:跨设备 **一发多投**(`term_broadcast` 走特征码)仍然只在本机总线上扩散;云端群发后续再做。
36
+ - 要不要跟着动手:**不用** —— 但两边都要 ≥0.7.3 才能互相 `term_send`;只升一边时可以用云端收件箱写信封兜底。
37
+
3
38
  ## 0.7.2 — 2026-10-19
4
39
 
5
40
  ### 停用云端老文件夹:设备心跳搬进账号共享目录
package/README.md CHANGED
@@ -28,15 +28,13 @@ 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.4)**
32
+
33
+ - **没写收件人的信不再卡住**:别的设备直接 `term_send` 到本机(只写设备名、不指会话)时,收信方会投给
34
+ **最近活跃的会话**,并在正文注明「信封没写收件人,按最近活跃会话投给 X」;连一个会话都没有才留件。
35
+ - 起因:0.7.3 发信只写设备名,对方 `relayDeliver()` 见收件人为空就判失败、按设计留件 —— 实测两封信在对方 inbox 里躺了半小时。
36
+ - 只改收信侧:发信方 0.7.3 就够,**收信方要 ≥0.7.4**。
37
+ - 要不要跟着动手:**要** —— 至少把收信的那台升上来。
40
38
 
41
39
  ## 一、安装与卸载
42
40
 
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.4'
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) {
@@ -748,10 +816,18 @@ class CrossTerm {
748
816
 
749
817
  /** 中转站投递:把云端收件箱里的一封信投进本机对应会话(按收件人会话名) */
750
818
  async relayDeliver({ to, envelope = {}, files = [] }) {
751
- const targets = (Array.isArray(to) ? to : String(to || '').split(/[,,;;\s]+/))
819
+ let targets = (Array.isArray(to) ? to : String(to || '').split(/[,,;;\s]+/))
752
820
  .map((s) => String(s || '').trim())
753
821
  .filter(Boolean)
754
- if (!targets.length) return { ok: false, reason: '信封里没有收件人(要填会话名)' }
822
+ let fallbackNote = ''
823
+ if (!targets.length) {
824
+ // 信封里没写收件人(例如别的设备直接 term_send 到本机、没指会话)→ 投给最近活跃会话
825
+ const list = this.sessions()
826
+ const latest = list.find((s) => s && s.active) || list[0]
827
+ if (!latest) return { ok: false, reason: '信封里没有收件人,本机也没有可投递的会话' }
828
+ targets = [latest.name || latest.short]
829
+ fallbackNote = `(信封没写收件人,按「最近活跃会话」投给「${targets[0]}」)`
830
+ }
755
831
  const controller = this.sessionController()
756
832
  if (!controller || typeof controller.prompt !== 'function') {
757
833
  return { ok: false, reason: '本 Host 没有 sessionController.prompt,中转站只能留件' }
@@ -769,6 +845,7 @@ class CrossTerm {
769
845
  const bad = files.filter((f) => !f || !f.local)
770
846
  if (bad.length) lines.push('', `(有 ${bad.length} 个附件没下载下来:${bad.map((f) => (f && f.name) || '未知').join('、')})`)
771
847
  lines.push('', `(这封信由云端中转站投递;收件人:${targets.join('、')}。回复用 term_send。)`)
848
+ if (fallbackNote) lines.push(fallbackNote)
772
849
  const text = lines.join('\n')
773
850
  const delivered = []
774
851
  const failed = []
@@ -1369,7 +1446,7 @@ function buildTools(term, agent, part = 'all') {
1369
1446
 
1370
1447
  const list = defineTool({
1371
1448
  name: 'term_list',
1372
- description: '列出同一账号下所有在线的 DSH 终端(桌面端 / dsh web / dsh headless 等),以及每个终端当前活着的会话 id。要给别的终端发消息或传文件时,先用它确认目标。',
1449
+ description: '列出同一账号下所有在线的 DSH 终端(桌面端 / dsh web / dsh headless 等,含别的机器上的云端设备),以及每个终端当前活着的会话 id。要给别的终端发消息或传文件时,先用它确认目标。',
1373
1450
  parameters: {
1374
1451
  includeSelf: { type: 'boolean', description: '是否把自己也算进去,默认 false' },
1375
1452
  },
@@ -1386,6 +1463,11 @@ function buildTools(term, agent, part = 'all') {
1386
1463
  if (v.nodes.length) {
1387
1464
  lines.push('', `其它终端 ${v.nodes.length} 个:`)
1388
1465
  for (const node of v.nodes) {
1466
+ if (node.cloud) {
1467
+ lines.push(`- 「${node.name}」 nodeId=${node.nodeId}(云端设备${node.version ? ` v${node.version}` : ''}`
1468
+ + `${node.online ? ',在线' : ',心跳已过期'})${node.sessionCount ? ` ${node.sessionCount} 个会话` : ''}`)
1469
+ continue
1470
+ }
1389
1471
  const codes = Array.isArray(node.chains) && node.chains.length
1390
1472
  ? ` 特征码 ${[...new Set(node.chains.map((c) => c.code))].join('/')}`
1391
1473
  : ''
@@ -1434,6 +1516,16 @@ function buildTools(term, agent, part = 'all') {
1434
1516
  sessions: Array.isArray(node.sessions) ? node.sessions : [],
1435
1517
  chains: Array.isArray(node.chains) ? node.chains : [],
1436
1518
  }))
1519
+ // 云端设备(别的机器,不写本机总线):本地节点列表里没有的补一行
1520
+ const seenNode = new Set(nodes.map((n) => n.nodeId))
1521
+ seenNode.add(term.nodeId)
1522
+ try {
1523
+ for (const d of await term.cloudDevices()) {
1524
+ if (seenNode.has(d.nodeId)) continue
1525
+ nodes.push(deviceRow(d))
1526
+ seenNode.add(d.nodeId)
1527
+ }
1528
+ } catch { /* 云端抖动不影响本地列表 */ }
1437
1529
  return {
1438
1530
  ok: true,
1439
1531
  // 本终端:同一个 DSH 进程里可能开着好几个会话,它们彼此也算「房间里的成员」
@@ -1485,7 +1577,7 @@ function buildTools(term, agent, part = 'all') {
1485
1577
 
1486
1578
  const send = defineTool({
1487
1579
  name: 'term_send',
1488
- description: '把一条消息投递给另一个 DSH 终端(或同一个终端里的另一个会话)里正在运行的会话(消息会以 user 消息进入那个会话,那边的 agent 会像收到用户输入一样处理)。target 用 term_list 里的 nodeId 或终端显示名;同一个终端里的其它会话用 to="self" 并带上 sessionId;要一发多投就把 to 写成一串特征码(先 term_link)。',
1580
+ description: '把一条消息投递给另一个 DSH 终端(或同一个终端里的另一个会话)里正在运行的会话(消息会以 user 消息进入那个会话,那边的 agent 会像收到用户输入一样处理)。target 用 term_list 里的 nodeId 或终端显示名;别的机器(云端设备)也认,本机总线找不到时会自动走云端信封;同一个终端里的其它会话用 to="self" 并带上 sessionId;要一发多投就把 to 写成一串特征码(先 term_link)。',
1489
1581
  parameters: {
1490
1582
  to: { type: 'string', description: '目标终端:nodeId 或显示名(term_list 里的 name)、特征码,"self" 表示本终端' },
1491
1583
  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.4",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",