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/README.md +41 -1
- package/client.js +167 -0
- package/cordis.patch.yml +3 -1
- package/docs/notes/approval-inbox-phase.md +31 -0
- package/docs/notes/dev-release.md +67 -0
- package/docs/notes/pairing-session-2026-11-09.md +30 -0
- package/docs/notes/remote-tests-2026-11-09.md +33 -0
- package/docs/notes/room-logic-phase.md +31 -0
- package/package.json +54 -45
- package/src/host/approval-inbox.js +135 -0
- package/src/host/approval-web.js +95 -0
- package/src/host/delegation.js +127 -0
- package/src/host/direct-call.js +27 -8
- package/src/host/execution-slot.js +19 -5
- package/src/host/index.js +4 -0
- package/src/host/room-service.js +324 -0
- package/src/host/store.js +31 -3
- package/src/host/tool-policy.js +2 -1
- package/src/host/tools.js +376 -1
- package/src/net/fleet-node.js +96 -1
- package/src/net/identity.js +12 -1
- package/src/plugin/index.js +121 -17
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({
|
|
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:
|
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()
|
|
@@ -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
|
|
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) {
|
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
|
}
|