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 CHANGED
@@ -11,7 +11,7 @@ DeepSeek Harness 的设备联邦插件:把登录同一账号、且都安装本
11
11
 
12
12
  ## 安装(开发版)
13
13
 
14
- 已发布到 npm:**`dsh-device-fleet@0.1.0-dev.1`**(dist-tag `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.1.tgz
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.1`
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.1.tgz>
8
- - 本地离线包:`dsh-device-fleet-0.1.0-dev.1.tgz`(仓库根目录,可拷贝安装)
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.1`,DSH 版本也要同一发布线;
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.2",
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:
@@ -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 endpoint = host && port ? { host, port } : discovered ? { host: discovered.host, port: discovered.port } : null
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) {
@@ -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
  }
@@ -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) {