dsh-device-fleet 0.1.0-dev.2 → 0.1.0-dev.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/README.md +2 -2
- package/docs/notes/dev-release.md +12 -4
- package/package.json +1 -1
- package/src/host/store.js +4 -2
- package/src/host/tools.js +35 -0
- package/src/net/fleet-node.js +88 -1
- package/src/net/identity.js +12 -1
- package/src/plugin/index.js +15 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ DeepSeek Harness 的设备联邦插件:把登录同一账号、且都安装本
|
|
|
11
11
|
|
|
12
12
|
## 安装(开发版)
|
|
13
13
|
|
|
14
|
-
已发布到 npm:**`dsh-device-fleet@0.1.0-dev.
|
|
14
|
+
已发布到 npm:**`dsh-device-fleet@0.1.0-dev.2`**(dist-tag `dev`)。
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
17
|
# CLI / web profile
|
|
@@ -21,7 +21,7 @@ dsh plugin --profile web add dsh-device-fleet@dev
|
|
|
21
21
|
dsh plugin --profile desktop add dsh-device-fleet@dev
|
|
22
22
|
|
|
23
23
|
# 离线安装包(拷到另一台设备)
|
|
24
|
-
dsh plugin --profile desktop add C:/path/to/dsh-device-fleet-0.1.0-dev.
|
|
24
|
+
dsh plugin --profile desktop add C:/path/to/dsh-device-fleet-0.1.0-dev.2.tgz
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
两台设备的 **DSH 版本与插件版本必须一致**(ADR-0024);不一致会在握手时直接拒绝。
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# 开发版发布记录
|
|
2
2
|
|
|
3
3
|
- 包名:`dsh-device-fleet`
|
|
4
|
-
- 版本:`0.1.0-dev.
|
|
4
|
+
- 版本:`0.1.0-dev.2`
|
|
5
5
|
- dist-tag:`dev`(同时也可用精确版本安装)
|
|
6
6
|
- registry:<https://registry.npmjs.org/dsh-device-fleet>
|
|
7
|
-
- tarball:<https://registry.npmjs.org/dsh-device-fleet/-/dsh-device-fleet-0.1.0-dev.
|
|
8
|
-
- 本地离线包:`dsh-device-fleet-0.1.0-dev.
|
|
7
|
+
- tarball:<https://registry.npmjs.org/dsh-device-fleet/-/dsh-device-fleet-0.1.0-dev.2.tgz>
|
|
8
|
+
- 本地离线包:`dsh-device-fleet-0.1.0-dev.2.tgz`(仓库根目录,可拷贝安装)
|
|
9
9
|
- 发布时间:2026-11-09(Asia/Shanghai)
|
|
10
10
|
- 内容:网络层 v1/v2 + Host 插件 + 直调 MVP(`fleet_invite/join/list/tools/call/read/fetch/run`);Room/Board/委派/审批 UI 未包含。
|
|
11
11
|
|
|
@@ -21,8 +21,16 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
|
|
|
21
21
|
|
|
22
22
|
## 已知限制
|
|
23
23
|
|
|
24
|
-
- 严格同版本:两台设备必须安装同一 `0.1.0-dev.
|
|
24
|
+
- 严格同版本:两台设备必须安装同一 `0.1.0-dev.2`,DSH 版本也要同一发布线;
|
|
25
25
|
- `fleet_run` 的 `ask` 审批目前 fail-closed(审批收件箱未实现);
|
|
26
26
|
- `fleet_fetch` 仅返回文本;
|
|
27
27
|
- 首次加载会在 `$DSH_HOME/device-fleet/` 自动创建身份与独立 Fleet;加入已有 Fleet 需要先 `fleet_join` 再重启;
|
|
28
28
|
- 默认 `direct.host=0.0.0.0` + UDP 组播发现;Windows 防火墙可能弹窗,需允许。
|
|
29
|
+
|
|
30
|
+
## 安装激活记录(本机 desktop profile,2026-11-09)
|
|
31
|
+
|
|
32
|
+
- 第一次 install_bundle 被拒:`package.json` 带 UTF-8 BOM → 去掉 BOM 后通过;
|
|
33
|
+
- 第一次 activate 失败:`@deepseek-ai/schemastery` 只有默认导出(`import Schema from ...`),改为默认导入;
|
|
34
|
+
- 第二次 activate 失败:Cordis 直接访问未 inject 的服务会抛错 → 可选服务统一走 `ctx.get(name)`;
|
|
35
|
+
- 最终 `set_plugin include:device-fleet enabled=true` → `application: applied`,8 个 `fleet_*` 工具出现在模型工具表,`fleet_list` 返回本机 device id,`taskui_probe` 仍为 v0.4.2 / tasks=247(与 task-list 无冲突)。
|
|
36
|
+
- 已发布 `0.1.0-dev.2`(包含上述激活修复);`0.1.0-dev.1` 会激活失败,不要使用。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-device-fleet",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.3",
|
|
4
4
|
"description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/plugin/index.js",
|
package/src/host/store.js
CHANGED
|
@@ -64,6 +64,7 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
|
|
|
64
64
|
joinedAt: Date.now(),
|
|
65
65
|
accountFingerprint: decoded.accountFingerprint ?? null,
|
|
66
66
|
relay: decoded.relay ?? null,
|
|
67
|
+
endpoints: decoded.endpoints ?? [],
|
|
67
68
|
}
|
|
68
69
|
writeJsonAtomic(path.join(dir, 'fleet.json'), fleet)
|
|
69
70
|
writeJsonAtomic(path.join(dir, 'enrollment.json'), enrollment)
|
|
@@ -102,8 +103,8 @@ export function saveConfig(state, config) {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/** Create an invite for this device's fleet. */
|
|
105
|
-
export function createInvite(state, { accountFingerprint = null, relay = null } = {}) {
|
|
106
|
-
return encodeInvite({ fleet: state.fleet, accountFingerprint, relay })
|
|
106
|
+
export function createInvite(state, { accountFingerprint = null, relay = null, endpoints = [] } = {}) {
|
|
107
|
+
return encodeInvite({ fleet: state.fleet, accountFingerprint, relay, endpoints })
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
/** Join a fleet from an invite code (overwrites fleet/enrollment, keeps identity). */
|
|
@@ -114,6 +115,7 @@ export function enrollFromInvite(state, code) {
|
|
|
114
115
|
joinedAt: Date.now(),
|
|
115
116
|
accountFingerprint: decoded.accountFingerprint ?? null,
|
|
116
117
|
relay: decoded.relay ?? null,
|
|
118
|
+
endpoints: decoded.endpoints ?? [],
|
|
117
119
|
}
|
|
118
120
|
saveFleet(state)
|
|
119
121
|
writeJsonAtomic(path.join(state.dataDir, 'enrollment.json'), state.enrollment)
|
package/src/host/tools.js
CHANGED
|
@@ -24,6 +24,22 @@ function fail(error) {
|
|
|
24
24
|
/** Pure handlers; wrapped into ToolDefinitions by `createFleetToolDefinitions`. */
|
|
25
25
|
export function createFleetHandlers({ node, client = new DirectCallClient({ node }), pairing = null }) {
|
|
26
26
|
return {
|
|
27
|
+
async fleet_status() {
|
|
28
|
+
if (typeof node.status !== 'function') return { text: 'Error: [unsupported] status is unavailable' }
|
|
29
|
+
return { text: JSON.stringify(node.status(), null, 2) }
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async fleet_connect({ host, port }) {
|
|
33
|
+
if (typeof node.connectAddress !== 'function') return { text: 'Error: [unsupported] manual connect is unavailable' }
|
|
34
|
+
try {
|
|
35
|
+
node.addPeerHint?.({ host, port })
|
|
36
|
+
const peer = await node.connectAddress(host, port)
|
|
37
|
+
return { text: `connected to ${peer.deviceId} (${peer.name ?? 'device'}) at ${host}:${port}` }
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return fail(error)
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
27
43
|
async fleet_invite() {
|
|
28
44
|
if (!pairing?.invite) return { text: 'Error: [unsupported] this device cannot create an invite' }
|
|
29
45
|
try {
|
|
@@ -130,6 +146,25 @@ export function createFleetToolDefinitions({ defineTool, handlers }) {
|
|
|
130
146
|
const output = textOutput()
|
|
131
147
|
const commonDevice = { type: 'string', required: true, description: 'Target device id from `fleet_list`.' }
|
|
132
148
|
return [
|
|
149
|
+
defineTool({
|
|
150
|
+
name: 'fleet_status',
|
|
151
|
+
description:
|
|
152
|
+
'Show this device\'s Fleet network status for troubleshooting: device id, direct listener address/port, discovery, connected peers, roster and invite/manual address hints. Use it when discovery does not find the other device.',
|
|
153
|
+
parameters: {},
|
|
154
|
+
output,
|
|
155
|
+
execute: () => handlers.fleet_status(),
|
|
156
|
+
}),
|
|
157
|
+
defineTool({
|
|
158
|
+
name: 'fleet_connect',
|
|
159
|
+
description:
|
|
160
|
+
'Connect to another device by explicit host and port when LAN discovery is unavailable. Use it with the port printed by that device\'s `fleet_status`; the peer id is learned from the authenticated handshake.',
|
|
161
|
+
parameters: {
|
|
162
|
+
host: { type: 'string', required: true, description: 'Target host/IP address on the LAN or VPN.' },
|
|
163
|
+
port: { type: 'number', required: true, description: 'Target device\'s Fleet direct port (from its fleet_status).' },
|
|
164
|
+
},
|
|
165
|
+
output,
|
|
166
|
+
execute: (args) => handlers.fleet_connect(args),
|
|
167
|
+
}),
|
|
133
168
|
defineTool({
|
|
134
169
|
name: 'fleet_invite',
|
|
135
170
|
description:
|
package/src/net/fleet-node.js
CHANGED
|
@@ -45,6 +45,7 @@ export class FleetNode extends EventEmitter {
|
|
|
45
45
|
discovery = false,
|
|
46
46
|
relay = null,
|
|
47
47
|
roster = [],
|
|
48
|
+
peerHints = [],
|
|
48
49
|
trustOnFirstUse = true,
|
|
49
50
|
acceptRelayConnections = true,
|
|
50
51
|
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
@@ -74,6 +75,10 @@ export class FleetNode extends EventEmitter {
|
|
|
74
75
|
this.roster = new Map()
|
|
75
76
|
for (const entry of roster) this.roster.set(entry.deviceId, { name: 'device', ...entry })
|
|
76
77
|
this.discovered = new Map()
|
|
78
|
+
this.peerHints = []
|
|
79
|
+
for (const hint of peerHints) this.addPeerHint(hint)
|
|
80
|
+
this.hintTimer = null
|
|
81
|
+
this.hintAttempt = 0
|
|
77
82
|
this.channels = new Map()
|
|
78
83
|
this.pendingRpc = new Map()
|
|
79
84
|
this.requestHandler = null
|
|
@@ -163,9 +168,81 @@ export class FleetNode extends EventEmitter {
|
|
|
163
168
|
}
|
|
164
169
|
|
|
165
170
|
this.emit('started', { direct: this.directAddress ?? null })
|
|
171
|
+
this.scheduleHintConnects()
|
|
166
172
|
return this
|
|
167
173
|
}
|
|
168
174
|
|
|
175
|
+
/** Remember an address hint for a peer (from an invite or a manual `fleet_connect`). */
|
|
176
|
+
addPeerHint(hint) {
|
|
177
|
+
if (!hint || typeof hint.host !== 'string' || !Number.isInteger(hint.port) || hint.port <= 0) return
|
|
178
|
+
const entry = {
|
|
179
|
+
deviceId: typeof hint.deviceId === 'string' ? hint.deviceId : undefined,
|
|
180
|
+
host: hint.host,
|
|
181
|
+
port: hint.port,
|
|
182
|
+
}
|
|
183
|
+
if (entry.deviceId === this.identity.deviceId) return
|
|
184
|
+
const exists = this.peerHints.some(
|
|
185
|
+
(item) => item.host === entry.host && item.port === entry.port && item.deviceId === entry.deviceId,
|
|
186
|
+
)
|
|
187
|
+
if (!exists) this.peerHints.push(entry)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Connect to a raw address; learns the peer id from the handshake. */
|
|
191
|
+
async connectAddress(host, port, { expectedDeviceId = null, timeoutMs = this.connectTimeoutMs } = {}) {
|
|
192
|
+
if (!host || !Number.isInteger(port) || port <= 0) throw new Error('connectAddress needs host and port')
|
|
193
|
+
const expected = expectedDeviceId ? { deviceId: expectedDeviceId } : null
|
|
194
|
+
const socket = await connectDirect({ host, port, timeoutMs })
|
|
195
|
+
const channel = this.createInitiator(socket, expected)
|
|
196
|
+
await this.waitReady(channel, timeoutMs)
|
|
197
|
+
return channel.peer
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Retry the invite/manual hints in the background until one peer connects. */
|
|
201
|
+
scheduleHintConnects() {
|
|
202
|
+
if (this.hintTimer || this.closed) return
|
|
203
|
+
const attempt = async () => {
|
|
204
|
+
this.hintTimer = null
|
|
205
|
+
if (this.closed) return
|
|
206
|
+
if (this.peerHints.length === 0 || this.channels.size > 0) return
|
|
207
|
+
try {
|
|
208
|
+
for (const hint of this.peerHints) {
|
|
209
|
+
if (this.closed || this.channels.size > 0) return
|
|
210
|
+
try {
|
|
211
|
+
const peer = await this.connectAddress(hint.host, hint.port, { expectedDeviceId: hint.deviceId })
|
|
212
|
+
this.emit('hint-connected', { hint, peer })
|
|
213
|
+
return
|
|
214
|
+
} catch {
|
|
215
|
+
// try the next hint; the timer below retries
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
if (!this.closed && this.channels.size === 0) {
|
|
220
|
+
this.hintAttempt += 1
|
|
221
|
+
const delay = Math.min(10_000, 1000 * 2 ** Math.min(this.hintAttempt, 4))
|
|
222
|
+
this.hintTimer = setTimeout(() => void attempt(), delay)
|
|
223
|
+
this.hintTimer.unref?.()
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
this.hintTimer = setTimeout(() => void attempt(), 1500)
|
|
228
|
+
this.hintTimer.unref?.()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
status() {
|
|
232
|
+
return {
|
|
233
|
+
deviceId: this.identity.deviceId,
|
|
234
|
+
name: this.name,
|
|
235
|
+
pluginVersion: this.pluginVersion,
|
|
236
|
+
dshVersion: this.dshVersion,
|
|
237
|
+
direct: this.directAddress ? { host: this.directAddress.host, port: this.directAddress.port } : null,
|
|
238
|
+
relay: this.relayClient ? { online: this.relayClient.online === true } : null,
|
|
239
|
+
discovery: this.discovery ? { enabled: true, peers: [...this.discovered.keys()] } : { enabled: false, peers: [] },
|
|
240
|
+
roster: this.rosterEntries().map((entry) => ({ deviceId: entry.deviceId, name: entry.name ?? 'device' })),
|
|
241
|
+
connected: this.peerIds(),
|
|
242
|
+
peerHints: [...this.peerHints],
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
169
246
|
async acceptIncomingRelay(controller) {
|
|
170
247
|
if (!this.acceptRelayConnections) {
|
|
171
248
|
controller.reject()
|
|
@@ -238,7 +315,15 @@ export class FleetNode extends EventEmitter {
|
|
|
238
315
|
|
|
239
316
|
const expected = this.roster.get(deviceId) ?? { deviceId }
|
|
240
317
|
const discovered = this.discovered.get(deviceId)
|
|
241
|
-
const
|
|
318
|
+
const hinted = this.peerHints.find((entry) => entry.deviceId === deviceId)
|
|
319
|
+
const endpoint =
|
|
320
|
+
host && port
|
|
321
|
+
? { host, port }
|
|
322
|
+
: discovered
|
|
323
|
+
? { host: discovered.host, port: discovered.port }
|
|
324
|
+
: hinted
|
|
325
|
+
? { host: hinted.host, port: hinted.port }
|
|
326
|
+
: null
|
|
242
327
|
let directError = null
|
|
243
328
|
if (endpoint) {
|
|
244
329
|
try {
|
|
@@ -398,6 +483,8 @@ export class FleetNode extends EventEmitter {
|
|
|
398
483
|
|
|
399
484
|
async close() {
|
|
400
485
|
this.closed = true
|
|
486
|
+
clearTimeout(this.hintTimer)
|
|
487
|
+
this.hintTimer = null
|
|
401
488
|
for (const channel of this.channels.values()) channel.close()
|
|
402
489
|
this.channels.clear()
|
|
403
490
|
for (const [id, pending] of this.pendingRpc) {
|
package/src/net/identity.js
CHANGED
|
@@ -70,7 +70,7 @@ export function createFleet({ name = 'fleet' } = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function encodeInvite({ fleet, accountFingerprint = null, relay = null }) {
|
|
73
|
+
export function encodeInvite({ fleet, accountFingerprint = null, relay = null, endpoints = [] }) {
|
|
74
74
|
const payload = {
|
|
75
75
|
v: 1,
|
|
76
76
|
fleetId: fleet.fleetId,
|
|
@@ -78,6 +78,7 @@ export function encodeInvite({ fleet, accountFingerprint = null, relay = null })
|
|
|
78
78
|
name: fleet.name,
|
|
79
79
|
accountFingerprint,
|
|
80
80
|
relay,
|
|
81
|
+
endpoints: Array.isArray(endpoints) ? endpoints : [],
|
|
81
82
|
createdAt: Date.now(),
|
|
82
83
|
}
|
|
83
84
|
return INVITE_PREFIX + Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
@@ -99,6 +100,16 @@ export function decodeInvite(code) {
|
|
|
99
100
|
fleet: { fleetId: payload.fleetId, fleetKey: payload.fleetKey, name: payload.name ?? 'fleet' },
|
|
100
101
|
accountFingerprint: payload.accountFingerprint ?? null,
|
|
101
102
|
relay: payload.relay ?? null,
|
|
103
|
+
endpoints: Array.isArray(payload.endpoints)
|
|
104
|
+
? payload.endpoints.filter(
|
|
105
|
+
(entry) =>
|
|
106
|
+
entry &&
|
|
107
|
+
typeof entry.host === 'string' &&
|
|
108
|
+
Number.isInteger(entry.port) &&
|
|
109
|
+
entry.port > 0 &&
|
|
110
|
+
(entry.deviceId === undefined || typeof entry.deviceId === 'string'),
|
|
111
|
+
)
|
|
112
|
+
: [],
|
|
102
113
|
createdAt: payload.createdAt ?? null,
|
|
103
114
|
}
|
|
104
115
|
}
|
package/src/plugin/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// land now (a prompt section and a first-use manual + orchestration nudge).
|
|
6
6
|
// Room, Room Board, delegation and the UI land in later steps.
|
|
7
7
|
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { networkInterfaces } from 'node:os'
|
|
8
9
|
import { fileURLToPath } from 'node:url'
|
|
9
10
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
10
11
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
@@ -143,6 +144,7 @@ export function apply(ctx, config) {
|
|
|
143
144
|
discovery: config.discovery ? true : false,
|
|
144
145
|
relay: config.relay ?? state.enrollment?.relay ?? null,
|
|
145
146
|
roster: state.roster,
|
|
147
|
+
peerHints: state.enrollment?.endpoints ?? [],
|
|
146
148
|
pluginVersion,
|
|
147
149
|
dshVersion,
|
|
148
150
|
capabilities: CAPABILITIES,
|
|
@@ -213,6 +215,18 @@ export function apply(ctx, config) {
|
|
|
213
215
|
}
|
|
214
216
|
|
|
215
217
|
const client = new DirectCallClient({ node })
|
|
218
|
+
const localEndpoints = () => {
|
|
219
|
+
const port = node.directAddress?.port
|
|
220
|
+
if (!Number.isInteger(port) || port <= 0) return []
|
|
221
|
+
const endpoints = []
|
|
222
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
223
|
+
for (const entry of entries ?? []) {
|
|
224
|
+
if (entry.family !== 'IPv4' || entry.internal) continue
|
|
225
|
+
endpoints.push({ deviceId: state.identity.deviceId, host: entry.address, port })
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return endpoints
|
|
229
|
+
}
|
|
216
230
|
const pairing = {
|
|
217
231
|
async invite() {
|
|
218
232
|
const fingerprint =
|
|
@@ -222,6 +236,7 @@ export function apply(ctx, config) {
|
|
|
222
236
|
return createInvite(state, {
|
|
223
237
|
accountFingerprint: fingerprint,
|
|
224
238
|
relay: config.relay ?? state.enrollment?.relay ?? null,
|
|
239
|
+
endpoints: localEndpoints(),
|
|
225
240
|
})
|
|
226
241
|
},
|
|
227
242
|
async join(code) {
|