dsh-device-fleet 0.1.0-dev.1 → 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
@@ -9,6 +9,44 @@ DeepSeek Harness 的设备联邦插件:把登录同一账号、且都安装本
9
9
  - ✅ **Host 插件 + 直调 MVP(代码完成、单测通过,待真实 DSH 安装联调)**:`fleet_list` / `fleet_tools` / `fleet_call` + `fleet_read` / `fleet_fetch` / `fleet_run`、Tool Policy、Execution Slot、审批 fail-closed
10
10
  - ⏳ Room / Room Board、委派、审批收件箱 UI、设备页/配对/房间 UI(下一步)
11
11
 
12
+ ## 安装(开发版)
13
+
14
+ 已发布到 npm:**`dsh-device-fleet@0.1.0-dev.2`**(dist-tag `dev`)。
15
+
16
+ ```sh
17
+ # CLI / web profile
18
+ dsh plugin --profile web add dsh-device-fleet@dev
19
+
20
+ # 桌面 app:先完全退出 app,再安装,然后重新打开
21
+ dsh plugin --profile desktop add dsh-device-fleet@dev
22
+
23
+ # 离线安装包(拷到另一台设备)
24
+ dsh plugin --profile desktop add C:/path/to/dsh-device-fleet-0.1.0-dev.2.tgz
25
+ ```
26
+
27
+ 两台设备的 **DSH 版本与插件版本必须一致**(ADR-0024);不一致会在握手时直接拒绝。
28
+
29
+ ### 配对(Fleet 入网)
30
+
31
+ 1. 设备 A:对 agent 说“生成 Fleet 邀请码” → 模型调用 `fleet_invite`,拿到一次性邀请码(含 fleetKey,注意保密)。
32
+ 2. 设备 B:安装并重启后,对 agent 说“加入 Fleet:`<code>`” → `fleet_join` 把邀请排队;**重启 DSH** 后生效。
33
+ 3. 两台设备各自在 profile patch 里配置 `policy.allowedRoots` 与 `execution.cwd`(不配时直调读取一律被拒)。
34
+ 4. 设备 B:`fleet_list` 看到设备 A → `fleet_read(device=A, path="A 允许根内的文件")` 验证。
35
+
36
+ ```yaml
37
+ # 两个 profile 的 cordis.patch.yml 里给 device-fleet 行追加 config:
38
+ - id: device-fleet
39
+ config:
40
+ deviceName: desktop
41
+ execution:
42
+ cwd: D:/work
43
+ agentPreset: standard
44
+ policy:
45
+ allowedRoots: [D:/work]
46
+ ```
47
+
48
+ `fleet_run` 默认需要目标设备本机审批;审批收件箱 UI 尚未实现(当前 fail-closed)。第一阶段建议先用 `fleet_read` / `fleet_fetch` 验证链路。
49
+
12
50
  ## 设计文档
13
51
 
14
52
  - 术语表:[CONTEXT.md](./CONTEXT.md)
package/cordis.patch.yml CHANGED
@@ -1,4 +1,4 @@
1
- # dsh-device-fleet bundle patch — mounts the Host plugin. The settings below are
1
+ # dsh-device-fleet bundle patch 鈥?mounts the Host plugin. The settings below are
2
2
  # safe defaults; real values (relay address, allowed roots, execution cwd) are
3
3
  # meant to be edited in the profile or through the plugin settings page.
4
4
  - insert:
@@ -22,3 +22,5 @@
22
22
  allowedTools: []
23
23
  promptSection: true
24
24
  autoApproveAsk: false
25
+
26
+
@@ -0,0 +1,36 @@
1
+ # 开发版发布记录
2
+
3
+ - 包名:`dsh-device-fleet`
4
+ - 版本:`0.1.0-dev.2`
5
+ - dist-tag:`dev`(同时也可用精确版本安装)
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.2.tgz>
8
+ - 本地离线包:`dsh-device-fleet-0.1.0-dev.2.tgz`(仓库根目录,可拷贝安装)
9
+ - 发布时间:2026-11-09(Asia/Shanghai)
10
+ - 内容:网络层 v1/v2 + Host 插件 + 直调 MVP(`fleet_invite/join/list/tools/call/read/fetch/run`);Room/Board/委派/审批 UI 未包含。
11
+
12
+ ## 安装
13
+
14
+ ```sh
15
+ dsh plugin --profile web add dsh-device-fleet@dev
16
+ # 桌面 app:完全退出后
17
+ dsh plugin --profile desktop add dsh-device-fleet@dev
18
+ ```
19
+
20
+ 发布时 npm 对新包做了短暂的处理/占位(`0.0.0-stage`),约几分钟后真实版本生效;后续版本号递增、tag 复用 `dev`。
21
+
22
+ ## 已知限制
23
+
24
+ - 严格同版本:两台设备必须安装同一 `0.1.0-dev.2`,DSH 版本也要同一发布线;
25
+ - `fleet_run` 的 `ask` 审批目前 fail-closed(审批收件箱未实现);
26
+ - `fleet_fetch` 仅返回文本;
27
+ - 首次加载会在 `$DSH_HOME/device-fleet/` 自动创建身份与独立 Fleet;加入已有 Fleet 需要先 `fleet_join` 再重启;
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,45 +1,45 @@
1
- {
2
- "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.1",
4
- "description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
5
- "type": "module",
6
- "main": "src/plugin/index.js",
7
- "exports": {
8
- ".": "./src/plugin/index.js",
9
- "./net": "./src/net/index.js",
10
- "./host": "./src/host/index.js",
11
- "./package.json": "./package.json"
12
- },
13
- "engines": {
14
- "node": "\u003e=22"
15
- },
16
- "dsh": {
17
- "bundle": {
18
- "patch": "./cordis.patch.yml"
19
- }
20
- },
21
- "peerDependencies": {
22
- "@deepseek-ai/cordis": "~4.1.0",
23
- "@deepseek-ai/dsh-tools": "^0.2.2-rc.1",
24
- "@deepseek-ai/dsh-llm": "^0.2.2-rc.1",
25
- "@deepseek-ai/schemastery": "~3.19.0"
26
- },
27
- "scripts": {
28
- "test": "node test/net.test.mjs \u0026\u0026 node test/host.test.mjs",
29
- "test:net": "node test/net.test.mjs",
30
- "test:host": "node test/host.test.mjs"
31
- },
32
- "files": [
33
- "src",
34
- "cordis.patch.yml",
35
- "README.md",
36
- "CONTEXT.md",
37
- "docs",
38
- "LICENSE"
39
- ],
40
- "publishConfig": {
41
- "access": "public",
42
- "tag": "dev"
43
- },
44
- "license": "MIT"
45
- }
1
+ {
2
+ "name": "dsh-device-fleet",
3
+ "version": "0.1.0-dev.3",
4
+ "description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
5
+ "type": "module",
6
+ "main": "src/plugin/index.js",
7
+ "exports": {
8
+ ".": "./src/plugin/index.js",
9
+ "./net": "./src/net/index.js",
10
+ "./host": "./src/host/index.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "engines": {
14
+ "node": ">=22"
15
+ },
16
+ "dsh": {
17
+ "bundle": {
18
+ "patch": "./cordis.patch.yml"
19
+ }
20
+ },
21
+ "peerDependencies": {
22
+ "@deepseek-ai/cordis": "~4.1.0",
23
+ "@deepseek-ai/dsh-tools": "^0.2.2-rc.1",
24
+ "@deepseek-ai/dsh-llm": "^0.2.2-rc.1",
25
+ "@deepseek-ai/schemastery": "~3.19.0"
26
+ },
27
+ "scripts": {
28
+ "test": "node test/net.test.mjs && node test/host.test.mjs",
29
+ "test:net": "node test/net.test.mjs",
30
+ "test:host": "node test/host.test.mjs"
31
+ },
32
+ "files": [
33
+ "src",
34
+ "cordis.patch.yml",
35
+ "README.md",
36
+ "CONTEXT.md",
37
+ "docs",
38
+ "LICENSE"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "tag": "dev"
43
+ },
44
+ "license": "MIT"
45
+ }
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,10 +5,11 @@
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'
11
- import { Schema } from '@deepseek-ai/schemastery'
12
+ import Schema from '@deepseek-ai/schemastery'
12
13
  import { FleetNode } from '../net/fleet-node.js'
13
14
  import { accountFingerprint } from '../net/identity.js'
14
15
  import { DirectCallClient, DirectCallHost } from '../host/direct-call.js'
@@ -115,7 +116,16 @@ function withManual(definitions) {
115
116
  export function apply(ctx, config) {
116
117
  const pluginVersion = packageVersion()
117
118
  const dshVersion = process.env.DSH_CLIENT_VERSION ?? 'unknown'
118
- const logger = ctx.logger ?? console
119
+ // Optional services: Cordis throws on direct property access for services the
120
+ // plugin did not inject, so every optional service goes through get().
121
+ const service = (name) => {
122
+ try {
123
+ return ctx.get?.(name)
124
+ } catch {
125
+ return undefined
126
+ }
127
+ }
128
+ const logger = service('logger') ?? console
119
129
 
120
130
  let state
121
131
  try {
@@ -134,14 +144,15 @@ export function apply(ctx, config) {
134
144
  discovery: config.discovery ? true : false,
135
145
  relay: config.relay ?? state.enrollment?.relay ?? null,
136
146
  roster: state.roster,
147
+ peerHints: state.enrollment?.endpoints ?? [],
137
148
  pluginVersion,
138
149
  dshVersion,
139
150
  capabilities: CAPABILITIES,
140
151
  })
141
152
 
142
153
  const policy = new ToolPolicy(config.policy)
143
- const sessionController = ctx.sessionController ?? ctx.get?.('sessionController')
144
- const fs = ctx.fs ?? ctx.get?.('fs')
154
+ const sessionController = service('sessionController')
155
+ const fs = service('fs')
145
156
  if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
146
157
  const slot = sessionController
147
158
  ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
@@ -186,7 +197,7 @@ export function apply(ctx, config) {
186
197
  })
187
198
 
188
199
  // Bind the local account fingerprint when the account service is composed.
189
- const account = ctx.get?.('deepseekAccount')
200
+ const account = service('deepseekAccount')
190
201
  if (account && typeof account.getDeviceIdentity === 'function') {
191
202
  void account
192
203
  .getDeviceIdentity()
@@ -204,6 +215,18 @@ export function apply(ctx, config) {
204
215
  }
205
216
 
206
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
+ }
207
230
  const pairing = {
208
231
  async invite() {
209
232
  const fingerprint =
@@ -213,6 +236,7 @@ export function apply(ctx, config) {
213
236
  return createInvite(state, {
214
237
  accountFingerprint: fingerprint,
215
238
  relay: config.relay ?? state.enrollment?.relay ?? null,
239
+ endpoints: localEndpoints(),
216
240
  })
217
241
  },
218
242
  async join(code) {
@@ -225,7 +249,7 @@ export function apply(ctx, config) {
225
249
  for (const definition of wrapped) ctx.tools.register(definition)
226
250
 
227
251
  // Adoption layer 2: one sentence, only when the Fleet has members.
228
- const systemPrompt = ctx.get?.('systemPrompt') ?? ctx.systemPrompt
252
+ const systemPrompt = service('systemPrompt')
229
253
  if (systemPrompt && config.promptSection !== false) {
230
254
  const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
231
255
  ctx.effect(() =>
@@ -271,6 +295,6 @@ export function apply(ctx, config) {
271
295
  void node.close()
272
296
  })
273
297
 
274
- ctx.logger?.info?.(`[device-fleet] v${pluginVersion} device ${state.identity.deviceId} (${config.deviceName})`)
298
+ logger.info?.(`[device-fleet] v${pluginVersion} device ${state.identity.deviceId} (${config.deviceName})`)
275
299
  void ready
276
300
  }