dsh-device-fleet 0.0.0-stage → 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.
Files changed (60) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -2
  4. package/client.js +167 -0
  5. package/cordis.patch.yml +26 -0
  6. package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
  7. package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
  8. package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
  9. package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
  10. package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
  11. package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
  12. package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
  13. package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
  14. package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
  15. package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
  16. package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
  17. package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
  18. package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
  19. package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
  20. package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
  21. package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
  22. package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
  23. package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
  24. package/docs/adr/0019-execution-context-configured-locally.md +24 -0
  25. package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
  26. package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
  27. package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
  28. package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
  29. package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
  30. package/docs/notes/approval-inbox-phase.md +31 -0
  31. package/docs/notes/design-decisions-summary.md +49 -0
  32. package/docs/notes/dev-release.md +67 -0
  33. package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
  34. package/docs/notes/network-v1-v2-implementation.md +71 -0
  35. package/docs/notes/pairing-session-2026-11-09.md +30 -0
  36. package/docs/notes/remote-tests-2026-11-09.md +33 -0
  37. package/docs/notes/room-logic-phase.md +31 -0
  38. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  39. package/package.json +51 -3
  40. package/src/host/approval-inbox.js +135 -0
  41. package/src/host/approval-web.js +95 -0
  42. package/src/host/delegation.js +127 -0
  43. package/src/host/direct-call.js +209 -0
  44. package/src/host/execution-slot.js +96 -0
  45. package/src/host/index.js +10 -0
  46. package/src/host/room-service.js +324 -0
  47. package/src/host/store.js +156 -0
  48. package/src/host/tool-policy.js +151 -0
  49. package/src/host/tools.js +590 -0
  50. package/src/index.js +3 -0
  51. package/src/net/direct.js +69 -0
  52. package/src/net/discovery.js +105 -0
  53. package/src/net/fleet-node.js +509 -0
  54. package/src/net/identity.js +167 -0
  55. package/src/net/index.js +9 -0
  56. package/src/net/protocol.js +73 -0
  57. package/src/net/relay-client.js +258 -0
  58. package/src/net/relay-server.js +366 -0
  59. package/src/net/secure-channel.js +538 -0
  60. package/src/plugin/index.js +380 -0
@@ -0,0 +1,590 @@
1
+ // Model-facing fleet tools (ADR-0014, ADR-0022).
2
+ //
3
+ // fleet_list / fleet_tools / fleet_call are the fixed tool surface; fleet_read,
4
+ // fleet_fetch and fleet_run are typed shortcuts over the same fleet.call path.
5
+ import { DirectCallClient } from './direct-call.js'
6
+
7
+ /** Shared text output contract (same shape the harness's own tools use). */
8
+ export function textOutput() {
9
+ return {
10
+ schema: {
11
+ type: 'object',
12
+ additionalProperties: false,
13
+ properties: { text: { type: 'string', required: true } },
14
+ },
15
+ render: (_args, value) => [{ type: 'text', text: value.text }],
16
+ }
17
+ }
18
+
19
+ function fail(error) {
20
+ const code = error?.code ?? 'error'
21
+ return { text: `Error: [${String(code)}] ${String(error?.message ?? error)}` }
22
+ }
23
+
24
+ /** Pure handlers; wrapped into ToolDefinitions by `createFleetToolDefinitions`. */
25
+ export function createFleetHandlers({
26
+ node,
27
+ client = new DirectCallClient({ node }),
28
+ pairing = null,
29
+ onPeerHint = null,
30
+ approvals = null,
31
+ rooms = null,
32
+ }) {
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
+
90
+ async fleet_invite() {
91
+ if (!pairing?.invite) return { text: 'Error: [unsupported] this device cannot create an invite' }
92
+ try {
93
+ const code = await pairing.invite()
94
+ return {
95
+ text: [
96
+ 'Fleet invite for another device (single use, keep it private):',
97
+ '',
98
+ code,
99
+ '',
100
+ 'On the other device install this plugin, then ask its agent to join with this code (`fleet_join`). It takes effect after that DSH restarts.',
101
+ ].join('\n'),
102
+ }
103
+ } catch (error) {
104
+ return fail(error)
105
+ }
106
+ },
107
+
108
+ async fleet_join({ code }) {
109
+ if (!pairing?.join) return { text: 'Error: [unsupported] this device cannot join a fleet' }
110
+ try {
111
+ await pairing.join(code)
112
+ return { text: 'Invite queued. Restart DSH on this device; the plugin will enroll on the next load and then be able to reach the Fleet.' }
113
+ } catch (error) {
114
+ return fail(error)
115
+ }
116
+ },
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
+
235
+ async fleet_list() {
236
+ const lines = []
237
+ lines.push(`this device: ${node.identity.deviceId} (${node.name})`)
238
+ const seen = new Set([node.identity.deviceId])
239
+ const online = new Set(node.peerIds())
240
+ const discovered = new Map([...node.discovered.values()].map((peer) => [peer.deviceId, peer]))
241
+ for (const entry of node.rosterEntries()) {
242
+ if (seen.has(entry.deviceId)) continue
243
+ seen.add(entry.deviceId)
244
+ lines.push(`- ${entry.deviceId} (${entry.name ?? 'device'}) ${online.has(entry.deviceId) ? 'online' : 'offline'}${online.has(entry.deviceId) ? '' : ' — direct calls fail while offline'}`)
245
+ }
246
+ for (const [deviceId, peer] of discovered) {
247
+ if (seen.has(deviceId)) continue
248
+ seen.add(deviceId)
249
+ lines.push(`- ${deviceId} (${peer.name ?? 'device'}) discovered${online.has(deviceId) ? ', online' : ''}`)
250
+ }
251
+ if (lines.length === 1) lines.push('(no other devices known yet; pair/enroll one with an invite first)')
252
+ return { text: lines.join('\n') }
253
+ },
254
+
255
+ async fleet_tools({ device }) {
256
+ try {
257
+ const result = await client.listTools(device)
258
+ const tools = Array.isArray(result?.tools) ? result.tools : []
259
+ if (tools.length === 0) return { text: `(no tools visible on ${device})` }
260
+ const lines = tools.map((tool) => `- ${tool.name}${tool.description ? `: ${String(tool.description).slice(0, 160)}` : ''}`)
261
+ return { text: lines.join('\n') }
262
+ } catch (error) {
263
+ return fail(error)
264
+ }
265
+ },
266
+
267
+ async fleet_call({ device, tool, parameters }) {
268
+ try {
269
+ const result = await client.call(device, { capability: 'tool', tool, args: parameters ?? {} })
270
+ return { text: client.render(result) }
271
+ } catch (error) {
272
+ return fail(error)
273
+ }
274
+ },
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
+
290
+ async fleet_read({ device, path, offset, limit }) {
291
+ try {
292
+ const args = { file_path: path }
293
+ if (offset !== undefined) args.offset = offset
294
+ if (limit !== undefined) args.limit = limit
295
+ const result = await client.call(device, { capability: 'read', tool: 'read', args })
296
+ return { text: client.render(result) }
297
+ } catch (error) {
298
+ return fail(error)
299
+ }
300
+ },
301
+
302
+ async fleet_fetch({ device, path }) {
303
+ try {
304
+ const result = await client.call(device, { capability: 'fetch', args: { path } })
305
+ return { text: client.render(result) }
306
+ } catch (error) {
307
+ return fail(error)
308
+ }
309
+ },
310
+
311
+ async fleet_run({ device, command }) {
312
+ try {
313
+ const result = await client.call(device, { capability: 'run', args: { command } })
314
+ return { text: client.render(result) }
315
+ } catch (error) {
316
+ return fail(error)
317
+ }
318
+ },
319
+ }
320
+ }
321
+
322
+ /** Build the model-facing ToolDefinitions once `defineTool` is available. */
323
+ export function createFleetToolDefinitions({ defineTool, handlers }) {
324
+ const output = textOutput()
325
+ const commonDevice = { type: 'string', required: true, description: 'Target device id from `fleet_list`.' }
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
+ }),
495
+ defineTool({
496
+ name: 'fleet_invite',
497
+ description:
498
+ 'Create a Fleet invite code for another device. Only do this when the user asks to pair a new device. Treat the returned code as a secret; it carries the fleet key.',
499
+ parameters: {},
500
+ output,
501
+ execute: () => handlers.fleet_invite(),
502
+ }),
503
+ defineTool({
504
+ name: 'fleet_join',
505
+ description:
506
+ 'Join an existing Fleet with an invite code from `fleet_invite` on another device. The enrollment is queued and takes effect after DSH restarts on this device.',
507
+ parameters: {
508
+ code: { type: 'string', required: true, description: 'Invite code produced by fleet_invite on another device.' },
509
+ },
510
+ output,
511
+ execute: (args) => handlers.fleet_join(args),
512
+ }),
513
+ defineTool({
514
+ name: 'fleet_list',
515
+ description:
516
+ 'List the devices in this Fleet with their online status. Call it before any `fleet_*` call to learn which device id to use. Offline devices are listed too, but direct calls to them fail fast; use a Room/delegation instead of waiting.',
517
+ parameters: {},
518
+ output,
519
+ execute: () => handlers.fleet_list(),
520
+ }),
521
+ defineTool({
522
+ name: 'fleet_tools',
523
+ description:
524
+ 'List the tools another device exposes to direct calls (name + description). Use it only when you need an untyped tool; the typed shortcuts `fleet_read` / `fleet_fetch` / `fleet_run` cover the common cases.',
525
+ parameters: { device: commonDevice },
526
+ output,
527
+ execute: (args) => handlers.fleet_tools(args),
528
+ }),
529
+ defineTool({
530
+ name: 'fleet_call',
531
+ description:
532
+ 'Call one tool on another device directly. Direct calls are stateless: no Room, no shared conversation, no remote history. Use `fleet_tools` first to discover names/arguments, or prefer the typed shortcuts. The target device decides whether to allow, ask its user, or reject.',
533
+ parameters: {
534
+ device: commonDevice,
535
+ tool: { type: 'string', required: true, description: 'Tool name on the target device.' },
536
+ parameters: { type: 'object', additionalProperties: true, description: 'Arguments object for that tool.' },
537
+ },
538
+ output,
539
+ execute: (args) => handlers.fleet_call(args),
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
+ }),
554
+ defineTool({
555
+ name: 'fleet_read',
556
+ description:
557
+ 'Read a file on another device (shortcut over `fleet_call` with the target `read` tool). Paths must be inside the roots that device allows. Use it to inspect logs/docs on another machine without opening a Room.',
558
+ parameters: {
559
+ device: commonDevice,
560
+ path: { type: 'string', required: true, description: 'Absolute path on the target device.' },
561
+ offset: { type: 'number', description: 'Line offset, when supported by the target read tool.' },
562
+ limit: { type: 'number', description: 'Maximum lines to return.' },
563
+ },
564
+ output,
565
+ execute: (args) => handlers.fleet_read(args),
566
+ }),
567
+ defineTool({
568
+ name: 'fleet_fetch',
569
+ description:
570
+ 'Fetch a file from another device and return its text content. Use it for "look at B\'s file and bring it over"; for large binaries prefer `fleet_run` with a transfer command.',
571
+ parameters: {
572
+ device: commonDevice,
573
+ path: { type: 'string', required: true, description: 'Absolute path on the target device.' },
574
+ },
575
+ output,
576
+ execute: (args) => handlers.fleet_fetch(args),
577
+ }),
578
+ defineTool({
579
+ name: 'fleet_run',
580
+ description:
581
+ 'Run a shell command on another device (shortcut over `fleet_call` with the target shell tool). The target device\'s Tool Policy decides whether the command is auto-allowed, needs its local approval, or is rejected; an offline or timed-out approval fails. Use it for builds/tests and other work that belongs on that machine.',
582
+ parameters: {
583
+ device: commonDevice,
584
+ command: { type: 'string', required: true, description: 'Shell command to run on the target device.' },
585
+ },
586
+ output,
587
+ execute: (args) => handlers.fleet_run(args),
588
+ }),
589
+ ]
590
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ // Placeholder entry for the DSH plugin. The Host/Client halves land in a later
2
+ // step; for now the network layer is exported so it can be tested and reused.
3
+ export * as net from './net/index.js'
@@ -0,0 +1,69 @@
1
+ // Plain TCP direct transport: a listening server and an outbound dialer.
2
+ // The secure channel runs on top; this module only owns sockets.
3
+ import net from 'node:net'
4
+
5
+ /**
6
+ * Start a direct listener.
7
+ * @param {{host?: string, port?: number}} [options]
8
+ * @returns {{server: net.Server, ready: Promise<{host: string, port: number}>, close: () => Promise<void>}}
9
+ */
10
+ export function createDirectServer({ host = '0.0.0.0', port = 0 } = {}) {
11
+ const server = net.createServer({ allowHalfOpen: false })
12
+ const ready = new Promise((resolve, reject) => {
13
+ const onError = (error) => {
14
+ server.off('listening', onListening)
15
+ reject(error)
16
+ }
17
+ const onListening = () => {
18
+ server.off('error', onError)
19
+ const address = server.address()
20
+ resolve({ host: typeof address === 'object' && address ? address.address : host, port: address?.port ?? port })
21
+ }
22
+ server.once('error', onError)
23
+ server.once('listening', onListening)
24
+ server.listen(port, host)
25
+ })
26
+ return {
27
+ server,
28
+ ready,
29
+ close: () =>
30
+ new Promise((resolve) => {
31
+ if (!server.listening) return resolve()
32
+ server.close(() => resolve())
33
+ }),
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Dial a direct endpoint.
39
+ * @param {{host: string, port: number, timeoutMs?: number}} options
40
+ * @returns {Promise<import('node:net').Socket>}
41
+ */
42
+ export function connectDirect({ host, port, timeoutMs = 5_000 }) {
43
+ return new Promise((resolve, reject) => {
44
+ const socket = net.connect({ host, port })
45
+ const timer = setTimeout(() => {
46
+ cleanup()
47
+ socket.destroy()
48
+ const error = new Error(`direct connect to ${host}:${String(port)} timed out`)
49
+ error.code = 'connect-timeout'
50
+ reject(error)
51
+ }, timeoutMs)
52
+ timer.unref?.()
53
+ const cleanup = () => {
54
+ clearTimeout(timer)
55
+ socket.off('connect', onConnect)
56
+ socket.off('error', onError)
57
+ }
58
+ const onConnect = () => {
59
+ cleanup()
60
+ resolve(socket)
61
+ }
62
+ const onError = (error) => {
63
+ cleanup()
64
+ reject(error)
65
+ }
66
+ socket.once('connect', onConnect)
67
+ socket.once('error', onError)
68
+ })
69
+ }