dsh-device-fleet 0.1.0-dev.2 → 0.1.0-dev.20

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.
@@ -3,7 +3,7 @@
3
3
  // the raw-tool allowlist, and short-lived grants created by approvals.
4
4
  import path from 'node:path'
5
5
 
6
- export const CAPABILITIES = ['read', 'list', 'grep', 'fetch', 'run', 'tool']
6
+ export const CAPABILITIES = ['read', 'list', 'grep', 'fetch', 'run', 'tool', 'delegate']
7
7
 
8
8
  export const DEFAULT_POLICY = Object.freeze({
9
9
  allowedRoots: [],
@@ -13,7 +13,8 @@ export const DEFAULT_POLICY = Object.freeze({
13
13
  grep: 'auto',
14
14
  fetch: 'auto',
15
15
  run: 'ask',
16
- tool: 'deny',
16
+ tool: 'auto',
17
+ delegate: 'ask',
17
18
  },
18
19
  allowedTools: [],
19
20
  approvalTtlMs: 15 * 60 * 1000,
@@ -76,15 +77,17 @@ export class ToolPolicy {
76
77
  return { decision: 'allow', reason: 'run is allowed by policy', key: grantKey, capability: capabilityName }
77
78
  }
78
79
  if (capabilityName === 'tool') {
79
- if (!this.config.allowedTools.includes(String(tool))) {
80
- return {
81
- decision: 'deny',
82
- reason: `tool ${String(tool)} is not in the allowed tools list`,
83
- key: grantKey,
84
- capability: capabilityName,
85
- }
80
+ // Raw tools follow the normal DSH tool pipeline on this device: the
81
+ // target Session's tool pre-execute policy and approval seam decide
82
+ // whether a call runs, asks, or is rejected. `deny` disables raw tools
83
+ // entirely; `ask` adds the plugin-level approval in front of DSH.
84
+ if (mode === 'deny') {
85
+ return { decision: 'deny', reason: 'raw tool calls are disabled by policy', key: grantKey, capability: capabilityName }
86
+ }
87
+ if (mode === 'ask') {
88
+ return { decision: 'ask', reason: 'raw tool call requires local approval', key: grantKey, capability: capabilityName }
86
89
  }
87
- return { decision: 'allow', reason: 'tool is allowlisted', key: grantKey, capability: capabilityName }
90
+ return { decision: 'allow', reason: 'raw tool call follows the DSH tool pipeline', key: grantKey, capability: capabilityName }
88
91
  }
89
92
  if (mode === 'allow') return { decision: 'allow', reason: 'capability is allowed by policy', key: grantKey, capability: capabilityName }
90
93
  if (mode === 'auto') return { decision: 'allow', reason: 'capability is auto-approved', key: grantKey, capability: capabilityName }
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:
@@ -0,0 +1,133 @@
1
+ // Read/action HTTP surface for the Fleet console: device overview, room list and
2
+ // detail, room user actions, pairing actions, and approval history.
3
+ const MAX_BODY_BYTES = 128 * 1024
4
+
5
+ function json(res, status, value) {
6
+ res.statusCode = status
7
+ res.setHeader('content-type', 'application/json; charset=utf-8')
8
+ res.setHeader('cache-control', 'no-store')
9
+ res.end(JSON.stringify(value))
10
+ }
11
+
12
+ async function readJsonBody(req) {
13
+ const chunks = []
14
+ let size = 0
15
+ for await (const chunk of req) {
16
+ size += chunk.length
17
+ if (size > MAX_BODY_BYTES) throw Object.assign(new Error('request body is too large'), { code: 'body-too-large' })
18
+ chunks.push(chunk)
19
+ }
20
+ if (chunks.length === 0) return {}
21
+ try {
22
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
23
+ } catch {
24
+ throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
25
+ }
26
+ }
27
+
28
+ function statusFor(error) {
29
+ if (error?.code === 'unknown-room') return 404
30
+ return ['bad-request', 'bad-json', 'body-too-large', 'readonly', 'unknown-member', 'unknown-task'].includes(error?.code)
31
+ ? 400
32
+ : 500
33
+ }
34
+
35
+ function actionRoute(webServer, path, method, run) {
36
+ return webServer.register({
37
+ kind: 'exact',
38
+ path,
39
+ handler: async (req, res) => {
40
+ if (req.method !== method) return json(res, 405, { error: 'method not allowed' })
41
+ try {
42
+ const body = method === 'POST' ? await readJsonBody(req) : {}
43
+ json(res, 200, (await run(body, req)) ?? { ok: true })
44
+ } catch (error) {
45
+ json(res, statusFor(error), { error: String(error?.message ?? error), code: error?.code ?? 'error' })
46
+ }
47
+ },
48
+ })
49
+ }
50
+
51
+ /**
52
+ * @param {object} options
53
+ * @param {object} options.webServer ctx.webServer
54
+ * @param {object} options.inbox ApprovalInbox
55
+ * @param {object} options.rooms RoomService
56
+ * @param {() => object} options.getOverview
57
+ * @param {object} [options.actions] UI actions (room send/task/close, pairing)
58
+ */
59
+ export function registerUiRoutes({ webServer, inbox, rooms, getOverview, actions = null, logger = null }) {
60
+ if (!webServer?.register || !rooms) return () => {}
61
+ const disposers = []
62
+ disposers.push(
63
+ actionRoute(webServer, '/device-fleet/overview', 'GET', async () => ({
64
+ ...getOverview(),
65
+ approvals: inbox?.list?.() ?? [],
66
+ approvalHistory: inbox?.history?.() ?? [],
67
+ })),
68
+ )
69
+ disposers.push(
70
+ actionRoute(webServer, '/device-fleet/rooms', 'GET', async () => ({ rooms: rooms.list() })),
71
+ )
72
+ disposers.push(
73
+ actionRoute(webServer, '/device-fleet/room', 'GET', async (_body, req) => {
74
+ let roomId = ''
75
+ try {
76
+ roomId = new URL(req.url ?? '/', 'http://local').searchParams.get('id') ?? ''
77
+ } catch {
78
+ roomId = ''
79
+ }
80
+ if (roomId === '') throw Object.assign(new Error('room id is required'), { code: 'bad-request' })
81
+ return { room: rooms.detail(roomId, { limit: 100 }) }
82
+ }),
83
+ )
84
+ disposers.push(
85
+ actionRoute(webServer, '/device-fleet/room/send', 'POST', async (body) => {
86
+ if (!actions?.sendRoomMessage) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
87
+ return { ok: true, result: await actions.sendRoomMessage(body ?? {}) }
88
+ }),
89
+ )
90
+ disposers.push(
91
+ actionRoute(webServer, '/device-fleet/room/task', 'POST', async (body) => {
92
+ if (!actions?.roomTask) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
93
+ return { ok: true, result: await actions.roomTask(body ?? {}) }
94
+ }),
95
+ )
96
+ disposers.push(
97
+ actionRoute(webServer, '/device-fleet/room/close', 'POST', async (body) => {
98
+ if (!actions?.closeRoom) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
99
+ return { ok: true, result: await actions.closeRoom(body ?? {}) }
100
+ }),
101
+ )
102
+ disposers.push(
103
+ actionRoute(webServer, '/device-fleet/room/member/remove', 'POST', async (body) => {
104
+ if (!actions?.removeMember) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
105
+ return { ok: true, result: await actions.removeMember(body ?? {}) }
106
+ }),
107
+ )
108
+ disposers.push(
109
+ actionRoute(webServer, '/device-fleet/room/upgrade', 'POST', async (body) => {
110
+ if (!actions?.upgradeRoom) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
111
+ return { ok: true, room: await actions.upgradeRoom(body ?? {}) }
112
+ }),
113
+ )
114
+ disposers.push(
115
+ actionRoute(webServer, '/device-fleet/pair/invite', 'POST', async () => {
116
+ if (!actions?.createInvite) throw Object.assign(new Error('pairing actions are unavailable'), { code: 'unsupported' })
117
+ return { ok: true, code: await actions.createInvite() }
118
+ }),
119
+ )
120
+ disposers.push(
121
+ actionRoute(webServer, '/device-fleet/pair/connect', 'POST', async (body) => {
122
+ if (!actions?.connectAddress) throw Object.assign(new Error('pairing actions are unavailable'), { code: 'unsupported' })
123
+ return { ok: true, result: await actions.connectAddress(body ?? {}) }
124
+ }),
125
+ )
126
+ disposers.push(
127
+ actionRoute(webServer, '/device-fleet/approvals/history', 'GET', async () => ({ history: inbox?.history?.() ?? [] })),
128
+ )
129
+ if (logger) logger.debug?.('[device-fleet] UI action routes registered')
130
+ return () => {
131
+ for (const dispose of disposers) dispose()
132
+ }
133
+ }