dsh-device-fleet 0.1.0-dev.1 → 0.1.0-dev.10

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/src/host/tools.js CHANGED
@@ -22,8 +22,71 @@ function fail(error) {
22
22
  }
23
23
 
24
24
  /** Pure handlers; wrapped into ToolDefinitions by `createFleetToolDefinitions`. */
25
- export function createFleetHandlers({ node, client = new DirectCallClient({ node }), pairing = null }) {
25
+ export function createFleetHandlers({
26
+ node,
27
+ client = new DirectCallClient({ node }),
28
+ pairing = null,
29
+ onPeerHint = null,
30
+ approvals = null,
31
+ rooms = null,
32
+ }) {
26
33
  return {
34
+ async fleet_approvals() {
35
+ if (!approvals?.list) return { text: 'Error: [unsupported] the approval inbox is unavailable' }
36
+ const items = approvals.list()
37
+ if (items.length === 0) return { text: '(no pending approvals)' }
38
+ const lines = items.map(
39
+ (item) =>
40
+ `- ${item.id} · ${item.fromDeviceId} · ${item.capability}${item.tool ? `/${String(item.tool)}` : ''} · ${item.expiresInMs}ms left · ${item.argsSummary}`,
41
+ )
42
+ return { text: lines.join('\n') }
43
+ },
44
+
45
+ async fleet_approve({ id, scope }) {
46
+ if (!approvals?.approve) return { text: 'Error: [unsupported] the approval inbox is unavailable' }
47
+ try {
48
+ const result = approvals.approve(id, { scope: scope ?? 'once' })
49
+ return { text: `approved ${result.id} (${result.scope})` }
50
+ } catch (error) {
51
+ return fail(error)
52
+ }
53
+ },
54
+
55
+ async fleet_deny({ id, reason }) {
56
+ if (!approvals?.deny) return { text: 'Error: [unsupported] the approval inbox is unavailable' }
57
+ try {
58
+ const result = approvals.deny(id, reason ?? 'denied by local user')
59
+ return { text: `denied ${result.id}` }
60
+ } catch (error) {
61
+ return fail(error)
62
+ }
63
+ },
64
+
65
+ async fleet_simulate_request({ fromDeviceId, capability, tool, reason }) {
66
+ if (!approvals?.simulate) return { text: 'Error: [unsupported] approval simulation is disabled' }
67
+ try {
68
+ return { text: await approvals.simulate({ fromDeviceId, capability, tool, reason }) }
69
+ } catch (error) {
70
+ return fail(error)
71
+ }
72
+ },
73
+ async fleet_status() {
74
+ if (typeof node.status !== 'function') return { text: 'Error: [unsupported] status is unavailable' }
75
+ return { text: JSON.stringify(node.status(), null, 2) }
76
+ },
77
+
78
+ async fleet_connect({ host, port }) {
79
+ if (typeof node.connectAddress !== 'function') return { text: 'Error: [unsupported] manual connect is unavailable' }
80
+ try {
81
+ node.addPeerHint?.({ host, port })
82
+ const peer = await node.connectAddress(host, port)
83
+ await onPeerHint?.({ host, port })
84
+ return { text: `connected to ${peer.deviceId} (${peer.name ?? 'device'}) at ${host}:${port}` }
85
+ } catch (error) {
86
+ return fail(error)
87
+ }
88
+ },
89
+
27
90
  async fleet_invite() {
28
91
  if (!pairing?.invite) return { text: 'Error: [unsupported] this device cannot create an invite' }
29
92
  try {
@@ -52,6 +115,123 @@ export function createFleetHandlers({ node, client = new DirectCallClient({ node
52
115
  }
53
116
  },
54
117
 
118
+ async room_create(args, exec) {
119
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
120
+ try {
121
+ const room = await rooms.upgradeHost({ agent: exec?.agent, policy: args?.policy ?? {} })
122
+ return {
123
+ text: [
124
+ `room created: ${room.roomId}`,
125
+ `host: ${room.members.find((member) => member.role === 'host')?.memberId ?? '?'}`,
126
+ 'Add participants with room_add_local (simulation) or the remote invite flow later, then room_send.',
127
+ ].join('\n'),
128
+ }
129
+ } catch (error) {
130
+ return fail(error)
131
+ }
132
+ },
133
+
134
+ async room_add_local({ roomId, name, agentPreset, cwd }) {
135
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
136
+ try {
137
+ const member = await rooms.addLocalParticipant({ roomId, name, agentPreset, cwd })
138
+ return { text: `participant added: ${member.memberId} (${member.name}) session=${member.sessionId}` }
139
+ } catch (error) {
140
+ return fail(error)
141
+ }
142
+ },
143
+
144
+ async room_remove_member({ roomId, memberId, reason }) {
145
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
146
+ try {
147
+ const result = await rooms.removeMember({ roomId, memberId, reason })
148
+ return { text: `member removed: ${result.memberId}` }
149
+ } catch (error) {
150
+ return fail(error)
151
+ }
152
+ },
153
+
154
+ async room_send({ roomId, to, text }, exec) {
155
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
156
+ try {
157
+ const from = { sessionId: exec?.agent?.session?.id }
158
+ const result = await rooms.send({ roomId, from, to, text })
159
+ const woken = result.woken.length > 0 ? `, woke ${result.woken.join(', ')}` : ''
160
+ return { text: `message ${result.messageId} sent to ${result.to.join(', ')}${woken}` }
161
+ } catch (error) {
162
+ return fail(error)
163
+ }
164
+ },
165
+
166
+ async room_messages({ roomId, limit }) {
167
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
168
+ try {
169
+ const items = rooms.messages({ roomId, limit })
170
+ if (items.length === 0) return { text: '(no messages)' }
171
+ const lines = items.map(
172
+ (message) => `- [${new Date(message.createdAt).toISOString()}] ${message.fromLabel} → ${message.to.join(', ')}: ${message.text}`,
173
+ )
174
+ return { text: lines.join('\n') }
175
+ } catch (error) {
176
+ return fail(error)
177
+ }
178
+ },
179
+
180
+ async room_status({ roomId }) {
181
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
182
+ try {
183
+ return { text: JSON.stringify(rooms.status(roomId), null, 2) }
184
+ } catch (error) {
185
+ return fail(error)
186
+ }
187
+ },
188
+
189
+ async room_task_add({ roomId, title, owner, dependsOn, description }) {
190
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
191
+ try {
192
+ const task = await rooms.addTask({ roomId, title, owner, dependsOn: dependsOn ?? [], description: description ?? '' })
193
+ return { text: `task ${task.id} added for ${task.owner}: ${task.title}` }
194
+ } catch (error) {
195
+ return fail(error)
196
+ }
197
+ },
198
+
199
+ async room_task_update({ roomId, id, status, note }) {
200
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
201
+ try {
202
+ const result = await rooms.updateTask({ roomId, id, status, note })
203
+ const handed = result.handedOff.length > 0 ? `; handed off to ${result.handedOff.map((item) => item.owner).join(', ')}` : ''
204
+ return { text: `task ${result.task.id} updated (${result.task.status})${handed}` }
205
+ } catch (error) {
206
+ return fail(error)
207
+ }
208
+ },
209
+
210
+ async room_task_list({ roomId }) {
211
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
212
+ try {
213
+ const tasks = rooms.listTasks(roomId)
214
+ if (tasks.length === 0) return { text: '(no tasks)' }
215
+ return {
216
+ text: tasks
217
+ .map((task) => `- [${task.status}] ${task.title} (${task.id}) owner=${task.owner}${task.dependsOn.length ? ` deps=${task.dependsOn.join(',')}` : ''}`)
218
+ .join('\n'),
219
+ }
220
+ } catch (error) {
221
+ return fail(error)
222
+ }
223
+ },
224
+
225
+ async room_close({ roomId, reason }) {
226
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
227
+ try {
228
+ await rooms.close({ roomId, reason: reason ?? 'closed by host' })
229
+ return { text: `room closed: ${roomId}` }
230
+ } catch (error) {
231
+ return fail(error)
232
+ }
233
+ },
234
+
55
235
  async fleet_list() {
56
236
  const lines = []
57
237
  lines.push(`this device: ${node.identity.deviceId} (${node.name})`)
@@ -93,6 +273,20 @@ export function createFleetHandlers({ node, client = new DirectCallClient({ node
93
273
  }
94
274
  },
95
275
 
276
+ async fleet_delegate({ device, task, label, cwd }) {
277
+ try {
278
+ const result = await client.delegate(
279
+ device,
280
+ { task, ...(label ? { label } : {}), ...(cwd ? { cwd } : {}) },
281
+ { timeoutMs: 200_000 },
282
+ )
283
+ const meta = result?.childId ? `\n[delegated on ${device} · ${String(result.durationMs ?? 0)}ms · child ${String(result.childId)}]` : ''
284
+ return { text: `${String(result?.text ?? '')}${meta}`.trim() }
285
+ } catch (error) {
286
+ return fail(error)
287
+ }
288
+ },
289
+
96
290
  async fleet_read({ device, path, offset, limit }) {
97
291
  try {
98
292
  const args = { file_path: path }
@@ -130,6 +324,174 @@ export function createFleetToolDefinitions({ defineTool, handlers }) {
130
324
  const output = textOutput()
131
325
  const commonDevice = { type: 'string', required: true, description: 'Target device id from `fleet_list`.' }
132
326
  return [
327
+ defineTool({
328
+ name: 'fleet_status',
329
+ description:
330
+ '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.',
331
+ parameters: {},
332
+ output,
333
+ execute: () => handlers.fleet_status(),
334
+ }),
335
+ defineTool({
336
+ name: 'fleet_connect',
337
+ description:
338
+ '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.',
339
+ parameters: {
340
+ host: { type: 'string', required: true, description: 'Target host/IP address on the LAN or VPN.' },
341
+ port: { type: 'number', required: true, description: 'Target device\'s Fleet direct port (from its fleet_status).' },
342
+ },
343
+ output,
344
+ execute: (args) => handlers.fleet_connect(args),
345
+ }),
346
+ defineTool({
347
+ name: 'fleet_approvals',
348
+ description:
349
+ 'List pending approval requests waiting on this device (remote direct calls or delegations whose Tool Policy is `ask`). Approve or deny them with `fleet_approve` / `fleet_deny`. Approvals expire after the configured timeout and fail closed.',
350
+ parameters: {},
351
+ output,
352
+ execute: () => handlers.fleet_approvals(),
353
+ }),
354
+ defineTool({
355
+ name: 'fleet_approve',
356
+ description:
357
+ 'Approve one pending approval request on this device. `scope: "once"` allows one call; `scope: "ttl"` allows that capability for the configured TTL (default 15 minutes). Permanent grants belong in the device settings, not here.',
358
+ parameters: {
359
+ id: { type: 'string', required: true, description: 'Approval request id from `fleet_approvals`.' },
360
+ scope: { type: 'string', enum: ['once', 'ttl'], description: 'Approval scope; defaults to once.' },
361
+ },
362
+ output,
363
+ execute: (args) => handlers.fleet_approve(args),
364
+ }),
365
+ defineTool({
366
+ name: 'fleet_deny',
367
+ description: 'Deny one pending approval request on this device.',
368
+ parameters: {
369
+ id: { type: 'string', required: true, description: 'Approval request id from `fleet_approvals`.' },
370
+ reason: { type: 'string', description: 'Optional reason returned to the caller.' },
371
+ },
372
+ output,
373
+ execute: (args) => handlers.fleet_deny(args),
374
+ }),
375
+ defineTool({
376
+ name: 'fleet_simulate_request',
377
+ description:
378
+ 'Development helper (only when approval simulation is enabled): create a fake pending approval request so the inbox flow can be exercised without a second device. It does not execute anything.',
379
+ parameters: {
380
+ fromDeviceId: { type: 'string', description: 'Pretend caller device id.' },
381
+ capability: { type: 'string', description: 'Capability name, e.g. run.' },
382
+ tool: { type: 'string', description: 'Optional tool name.' },
383
+ reason: { type: 'string', description: 'Optional reason shown in the request.' },
384
+ },
385
+ output,
386
+ execute: (args) => handlers.fleet_simulate_request(args),
387
+ }),
388
+ defineTool({
389
+ name: 'room_create',
390
+ description:
391
+ 'Upgrade the current session into a Room Host (local logic layer) so multiple agents can collaborate with shared messages and a shared task board. Add participants with `room_add_local` (simulation) or the future remote invite flow. Only do this when the user explicitly wants multi-agent collaboration, not for a one-off remote tool call.',
392
+ parameters: {
393
+ policy: { type: 'object', additionalProperties: true, description: 'Optional room policy overrides (invite/join/maxMembers/boardWrite/remove).' },
394
+ },
395
+ output,
396
+ execute: (args, exec) => handlers.room_create(args, exec),
397
+ }),
398
+ defineTool({
399
+ name: 'room_add_local',
400
+ description:
401
+ 'Simulation helper: add a real local DSH agent as a Room participant (its own session). Use it to exercise addressing/wake/board logic in one DSH before the remote participant transport exists.',
402
+ parameters: {
403
+ roomId: { type: 'string', required: true, description: 'Room id returned by room_create.' },
404
+ name: { type: 'string', description: 'Participant display name.' },
405
+ agentPreset: { type: 'string', description: 'Optional agent preset id for the participant session.' },
406
+ cwd: { type: 'string', description: 'Optional working directory for the participant session.' },
407
+ },
408
+ output,
409
+ execute: (args) => handlers.room_add_local(args),
410
+ }),
411
+ defineTool({
412
+ name: 'room_remove_member',
413
+ description: 'Remove a participant from a Room (the host cannot be removed; close the room instead).',
414
+ parameters: {
415
+ roomId: { type: 'string', required: true, description: 'Room id.' },
416
+ memberId: { type: 'string', required: true, description: 'Member id from room_status.' },
417
+ reason: { type: 'string', description: 'Optional reason recorded in the room log.' },
418
+ },
419
+ output,
420
+ execute: (args) => handlers.room_remove_member(args),
421
+ }),
422
+ defineTool({
423
+ name: 'room_send',
424
+ description:
425
+ 'Send one addressed Room message as the calling agent. The host appends it to the canonical room log and wakes only the addressed participants; unaddressed members do not run. To say different things to different members, send separate messages.',
426
+ parameters: {
427
+ roomId: { type: 'string', required: true, description: 'Room id.' },
428
+ to: { type: 'array', items: { type: 'string' }, required: true, description: 'Recipient member ids.' },
429
+ text: { type: 'string', required: true, description: 'Message body.' },
430
+ },
431
+ output,
432
+ execute: (args, exec) => handlers.room_send(args, exec),
433
+ }),
434
+ defineTool({
435
+ name: 'room_messages',
436
+ description: 'Read the Room transcript (the user-visible complete conversation, including agent-to-agent messages).',
437
+ parameters: {
438
+ roomId: { type: 'string', required: true, description: 'Room id.' },
439
+ limit: { type: 'number', description: 'Maximum recent messages to return (default 50).' },
440
+ },
441
+ output,
442
+ execute: (args) => handlers.room_messages(args),
443
+ }),
444
+ defineTool({
445
+ name: 'room_status',
446
+ description: 'Show one Room\'s members, policy, message count and task count.',
447
+ parameters: { roomId: { type: 'string', required: true, description: 'Room id.' } },
448
+ output,
449
+ execute: (args) => handlers.room_status(args),
450
+ }),
451
+ defineTool({
452
+ name: 'room_task_add',
453
+ description:
454
+ 'Add one shared Room Board task. Use dependsOn to express serial handoff: when all dependencies are done, the owner is woken with a "ready" message. Parallel tasks have no dependencies and do not block each other.',
455
+ parameters: {
456
+ roomId: { type: 'string', required: true, description: 'Room id.' },
457
+ title: { type: 'string', required: true, description: 'Task title.' },
458
+ owner: { type: 'string', required: true, description: 'Owner member id.' },
459
+ dependsOn: { type: 'array', items: { type: 'string' }, description: 'Task ids that must complete first.' },
460
+ description: { type: 'string', description: 'Optional task detail.' },
461
+ },
462
+ output,
463
+ execute: (args) => handlers.room_task_add(args),
464
+ }),
465
+ defineTool({
466
+ name: 'room_task_update',
467
+ description:
468
+ 'Update one Room Board task. Marking it done triggers serial handoff: owners of pending dependent tasks are woken. Keep status/steps current when starting and finishing your block.',
469
+ parameters: {
470
+ roomId: { type: 'string', required: true, description: 'Room id.' },
471
+ id: { type: 'string', required: true, description: 'Task id.' },
472
+ status: { type: 'string', enum: ['pending', 'running', 'done', 'blocked'], description: 'New status.' },
473
+ note: { type: 'string', description: 'Optional progress note.' },
474
+ },
475
+ output,
476
+ execute: (args) => handlers.room_task_update(args),
477
+ }),
478
+ defineTool({
479
+ name: 'room_task_list',
480
+ description: 'List the shared Room Board tasks with status, owner and dependencies.',
481
+ parameters: { roomId: { type: 'string', required: true, description: 'Room id.' } },
482
+ output,
483
+ execute: (args) => handlers.room_task_list(args),
484
+ }),
485
+ defineTool({
486
+ name: 'room_close',
487
+ description: 'Close a Room (archives its canonical log); participants are no longer woken afterwards.',
488
+ parameters: {
489
+ roomId: { type: 'string', required: true, description: 'Room id.' },
490
+ reason: { type: 'string', description: 'Optional close reason.' },
491
+ },
492
+ output,
493
+ execute: (args) => handlers.room_close(args),
494
+ }),
133
495
  defineTool({
134
496
  name: 'fleet_invite',
135
497
  description:
@@ -176,6 +538,19 @@ export function createFleetToolDefinitions({ defineTool, handlers }) {
176
538
  output,
177
539
  execute: (args) => handlers.fleet_call(args),
178
540
  }),
541
+ defineTool({
542
+ name: 'fleet_delegate',
543
+ description:
544
+ 'Delegate a one-shot task to another device\'s agent and wait for its final answer. Use it when the work needs that device\'s model and tools ("let B build/check this"), not just one tool call. The delegated agent runs on the target device; it does not join a Room and its history is not shared. The target device decides whether delegation is allowed or needs local approval.',
545
+ parameters: {
546
+ device: commonDevice,
547
+ task: { type: 'string', required: true, description: 'Self-contained task instructions for the target agent.' },
548
+ label: { type: 'string', description: 'Short label shown in the target device\'s subagent list.' },
549
+ cwd: { type: 'string', description: 'Optional working directory on the target device.' },
550
+ },
551
+ output,
552
+ execute: (args) => handlers.fleet_delegate(args),
553
+ }),
179
554
  defineTool({
180
555
  name: 'fleet_read',
181
556
  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()
@@ -204,6 +281,14 @@ export class FleetNode extends EventEmitter {
204
281
  if (current === channel) this.channels.delete(channel.peer.deviceId)
205
282
  this.rejectPendingForChannel(channel, Object.assign(new Error('peer connection closed'), { code: 'peer-offline' }))
206
283
  this.emit('peer-disconnected', { deviceId: channel.peer?.deviceId, via })
284
+ // Recover automatically: the peer may have restarted or moved; retry the
285
+ // known addresses instead of waiting for the next process start.
286
+ if (!this.closed && this.channels.size === 0 && this.peerHints.length > 0) {
287
+ clearTimeout(this.hintTimer)
288
+ this.hintTimer = null
289
+ this.hintAttempt = 0
290
+ this.scheduleHintConnects()
291
+ }
207
292
  })
208
293
  }
209
294
 
@@ -238,7 +323,15 @@ export class FleetNode extends EventEmitter {
238
323
 
239
324
  const expected = this.roster.get(deviceId) ?? { deviceId }
240
325
  const discovered = this.discovered.get(deviceId)
241
- const endpoint = host && port ? { host, port } : discovered ? { host: discovered.host, port: discovered.port } : null
326
+ const hinted = this.peerHints.find((entry) => entry.deviceId === deviceId)
327
+ const endpoint =
328
+ host && port
329
+ ? { host, port }
330
+ : discovered
331
+ ? { host: discovered.host, port: discovered.port }
332
+ : hinted
333
+ ? { host: hinted.host, port: hinted.port }
334
+ : null
242
335
  let directError = null
243
336
  if (endpoint) {
244
337
  try {
@@ -398,6 +491,8 @@ export class FleetNode extends EventEmitter {
398
491
 
399
492
  async close() {
400
493
  this.closed = true
494
+ clearTimeout(this.hintTimer)
495
+ this.hintTimer = null
401
496
  for (const channel of this.channels.values()) channel.close()
402
497
  this.channels.clear()
403
498
  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
  }