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 +35 -0
- package/README.md +7 -9
- package/lib/bus.js +1 -1
- package/lib/cloud/match.js +45 -0
- package/lib/index.js +97 -5
- package/package.json +1 -1
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.
|
|
32
|
-
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
-
|
|
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
|
@@ -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}
|
|
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
|
-
|
|
819
|
+
let targets = (Array.isArray(to) ? to : String(to || '').split(/[,,;;\s]+/))
|
|
752
820
|
.map((s) => String(s || '').trim())
|
|
753
821
|
.filter(Boolean)
|
|
754
|
-
|
|
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
|
|
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
|
|
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: '消息内容' },
|