dsh-device-fleet 0.1.0-dev.12 → 0.1.0-dev.14

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.
@@ -0,0 +1,78 @@
1
+ // Read-model HTTP surface for the Fleet console (dev.13): device overview,
2
+ // room list and room detail. The approval routes stay in approval-web.js.
3
+ function json(res, status, value) {
4
+ res.statusCode = status
5
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6
+ res.setHeader('cache-control', 'no-store')
7
+ res.end(JSON.stringify(value))
8
+ }
9
+
10
+ function pathOf(request) {
11
+ try {
12
+ return new URL(request.url ?? '/', 'http://local').pathname
13
+ } catch {
14
+ return '/'
15
+ }
16
+ }
17
+
18
+ /**
19
+ * @param {object} options
20
+ * @param {object} options.webServer ctx.webServer
21
+ * @param {object} options.inbox ApprovalInbox
22
+ * @param {object} options.rooms RoomService
23
+ * @param {() => object} options.getOverview
24
+ * @returns {() => void} disposer
25
+ */
26
+ export function registerUiRoutes({ webServer, inbox, rooms, getOverview, logger = null }) {
27
+ if (!webServer?.register || !rooms) return () => {}
28
+ const disposers = []
29
+ disposers.push(
30
+ webServer.register({
31
+ kind: 'exact',
32
+ path: '/device-fleet/overview',
33
+ handler: async (req, res) => {
34
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
35
+ try {
36
+ json(res, 200, { ...getOverview(), approvals: inbox?.list?.() ?? [] })
37
+ } catch (error) {
38
+ logger?.warn?.(`[device-fleet] overview failed: ${String(error?.message ?? error)}`)
39
+ json(res, 500, { error: 'internal error' })
40
+ }
41
+ },
42
+ }),
43
+ )
44
+ disposers.push(
45
+ webServer.register({
46
+ kind: 'exact',
47
+ path: '/device-fleet/rooms',
48
+ handler: async (req, res) => {
49
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
50
+ try {
51
+ json(res, 200, { rooms: rooms.list() })
52
+ } catch (error) {
53
+ json(res, 500, { error: String(error?.message ?? error) })
54
+ }
55
+ },
56
+ }),
57
+ )
58
+ disposers.push(
59
+ webServer.register({
60
+ kind: 'prefix',
61
+ path: '/device-fleet/rooms/',
62
+ handler: async (req, res) => {
63
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
64
+ const path = pathOf(req)
65
+ const roomId = decodeURIComponent(path.slice('/device-fleet/rooms/'.length))
66
+ if (roomId === '') return json(res, 400, { error: 'room id is required' })
67
+ try {
68
+ json(res, 200, { room: rooms.detail(roomId, { limit: 100 }) })
69
+ } catch (error) {
70
+ json(res, error?.code === 'unknown-room' ? 404 : 500, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
71
+ }
72
+ },
73
+ }),
74
+ )
75
+ return () => {
76
+ for (const dispose of disposers) dispose()
77
+ }
78
+ }
@@ -489,6 +489,28 @@ export class FleetNode extends EventEmitter {
489
489
  return [...this.channels.keys()]
490
490
  }
491
491
 
492
+ /** Apply live network changes without recreating the node or its identity. */
493
+ async reconfigure({ name, direct, discovery, relay } = {}) {
494
+ if (this.closed) return
495
+ if (typeof name === 'string' && name !== '') this.name = name
496
+ const networkChanged = direct !== undefined || discovery !== undefined || relay !== undefined
497
+ if (!networkChanged) return
498
+ const wasStarted = this.started
499
+ await this.close()
500
+ this.closed = false
501
+ this.started = false
502
+ this.hintAttempt = 0
503
+ this.discovered.clear()
504
+ if (direct !== undefined) this.directConfig = direct
505
+ if (discovery !== undefined) this.discoveryConfig = discovery
506
+ if (relay !== undefined) this.relayConfig = relay
507
+ this.directServer = null
508
+ this.discovery = null
509
+ this.relayClient = null
510
+ this.directAddress = null
511
+ if (wasStarted) await this.start()
512
+ }
513
+
492
514
  async close() {
493
515
  this.closed = true
494
516
  clearTimeout(this.hintTimer)
@@ -6,6 +6,7 @@
6
6
  // Room, Room Board, delegation and the UI land in later steps.
7
7
  import { readFileSync } from 'node:fs'
8
8
  import { networkInterfaces } from 'node:os'
9
+ import { join } from 'node:path'
9
10
  import { fileURLToPath } from 'node:url'
10
11
  import { defineTool } from '@deepseek-ai/dsh-tools'
11
12
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
@@ -17,6 +18,9 @@ import { DelegationHost } from '../host/delegation.js'
17
18
  import { ApprovalInbox } from '../host/approval-inbox.js'
18
19
  import { RoomService } from '../host/room-service.js'
19
20
  import { registerApprovalRoutes } from '../host/approval-web.js'
21
+ import { registerUiRoutes } from '../host/ui-web.js'
22
+ import { registerConfigRoutes } from '../host/config-web.js'
23
+ import { RuntimeConfig } from '../host/runtime-config.js'
20
24
  import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
21
25
  import { ToolPolicy } from '../host/tool-policy.js'
22
26
  import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
@@ -126,7 +130,7 @@ function withManual(definitions) {
126
130
  }))
127
131
  }
128
132
 
129
- export function apply(ctx, config) {
133
+ export function apply(ctx, profileConfig) {
130
134
  const pluginVersion = packageVersion()
131
135
  const dshVersion = process.env.DSH_CLIENT_VERSION ?? 'unknown'
132
136
  // Optional services: Cordis throws on direct property access for services the
@@ -142,12 +146,20 @@ export function apply(ctx, config) {
142
146
 
143
147
  let state
144
148
  try {
145
- state = loadState({ dshHome: process.env.DSH_HOME, dataDir: config.dataDir, name: config.deviceName })
149
+ state = loadState({ dshHome: process.env.DSH_HOME, dataDir: profileConfig?.dataDir, name: profileConfig?.deviceName })
146
150
  } catch (error) {
147
151
  logger.error?.(`[device-fleet] cannot load state: ${String(error?.message ?? error)}`)
148
152
  return
149
153
  }
150
154
 
155
+ // Runtime config: profile patch is the base, UI edits are persisted in the
156
+ // state dir and applied live (no plugin/DSH restart).
157
+ const runtime = new RuntimeConfig({
158
+ base: profileConfig ?? {},
159
+ overridesFile: join(state.dataDir, 'config-overrides.json'),
160
+ })
161
+ const config = runtime.current
162
+
151
163
  const node = new FleetNode({
152
164
  identity: state.identity,
153
165
  fleet: state.fleet,
@@ -163,7 +175,7 @@ export function apply(ctx, config) {
163
175
  capabilities: CAPABILITIES,
164
176
  })
165
177
 
166
- const policy = new ToolPolicy(config.policy)
178
+ let policy = new ToolPolicy(config.policy)
167
179
  const sessionController = service('sessionController')
168
180
  const fs = service('fs')
169
181
  const subagents = service('subagents')
@@ -176,34 +188,52 @@ export function apply(ctx, config) {
176
188
  list: () => inbox.list(),
177
189
  approve: (id, options) => inbox.approve(id, options),
178
190
  deny: (id, reason) => inbox.deny(id, reason),
179
- simulate: config.approvals?.simulation
180
- ? async ({ fromDeviceId, capability, tool, reason }) => {
181
- const promise = inbox.request({
182
- fromDeviceId: fromDeviceId ?? 'simulated-device',
183
- capability: capability ?? 'run',
184
- tool: tool ?? null,
185
- reason: reason ?? 'simulated approval request',
186
- args: {},
187
- })
188
- const summary = inbox.list().at(-1)
189
- // Keep the promise from becoming an unhandled rejection when the
190
- // request expires without an answer; the demo only inspects the inbox.
191
- promise.catch(() => {})
192
- return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
193
- }
194
- : null,
191
+ simulate: async ({ fromDeviceId, capability, tool, reason }) => {
192
+ if (runtime.current.approvals?.simulation !== true) {
193
+ throw Object.assign(new Error('approval simulation is disabled'), { code: 'unsupported' })
194
+ }
195
+ const promise = inbox.request({
196
+ fromDeviceId: fromDeviceId ?? 'simulated-device',
197
+ capability: capability ?? 'run',
198
+ tool: tool ?? null,
199
+ reason: reason ?? 'simulated approval request',
200
+ args: {},
201
+ })
202
+ const summary = inbox.list().at(-1)
203
+ // Keep the promise from becoming an unhandled rejection when the
204
+ // request expires without an answer; the demo only inspects the inbox.
205
+ promise.catch(() => {})
206
+ return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
207
+ },
195
208
  }
196
- const requestApproval = config.autoApproveAsk
197
- ? async () => ({ approved: true, scope: 'once' })
198
- : async (request) => inbox.request(request)
209
+ const requestApproval = async (request) =>
210
+ runtime.current.autoApproveAsk === true ? { approved: true, scope: 'once' } : inbox.request(request)
199
211
 
200
- const rooms = new RoomService({ sessionController, createUserMessage })
212
+ const rooms = new RoomService({
213
+ sessionController,
214
+ createUserMessage,
215
+ stateFile: join(state.dataDir, 'rooms.json'),
216
+ })
201
217
 
202
218
  // Client approval panel data plane (dev.9). Optional service: host-only
203
219
  // deployments simply do not expose the panel routes.
204
220
  const webServer = service('webServer')
205
221
  if (webServer) {
206
222
  ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
223
+ ctx.effect(() => registerConfigRoutes({ webServer, runtime, logger }))
224
+ ctx.effect(() =>
225
+ registerUiRoutes({
226
+ webServer,
227
+ inbox,
228
+ rooms,
229
+ logger,
230
+ getOverview: () => ({
231
+ ...(node.status?.() ?? {}),
232
+ rooms: rooms.list(),
233
+ approvals: inbox.list(),
234
+ }),
235
+ }),
236
+ )
207
237
  }
208
238
  if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
209
239
  const slot = sessionController
@@ -298,7 +328,7 @@ export function apply(ctx, config) {
298
328
  : (state.enrollment?.accountFingerprint ?? null)
299
329
  return createInvite(state, {
300
330
  accountFingerprint: fingerprint,
301
- relay: config.relay ?? state.enrollment?.relay ?? null,
331
+ relay: runtime.current.relay ?? state.enrollment?.relay ?? null,
302
332
  endpoints: localEndpoints(),
303
333
  })
304
334
  },
@@ -326,13 +356,14 @@ export function apply(ctx, config) {
326
356
 
327
357
  // Adoption layer 2: one sentence, only when the Fleet has members.
328
358
  const systemPrompt = service('systemPrompt')
329
- if (systemPrompt && config.promptSection !== false) {
359
+ if (systemPrompt) {
330
360
  const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
331
361
  ctx.effect(() =>
332
362
  systemPrompt.section({
333
363
  name: 'device-fleet:members',
334
364
  order,
335
365
  text: () => {
366
+ if (runtime.current.promptSection === false) return ''
336
367
  const members = node.rosterEntries().length
337
368
  if (members === 0) return ''
338
369
  return `This DSH instance is part of a device Fleet with ${members} other device(s). Use fleet_list/fleet_read/fleet_fetch/fleet_run for work that belongs on another device; direct calls fail fast when that device is offline.`
@@ -341,6 +372,48 @@ export function apply(ctx, config) {
341
372
  )
342
373
  }
343
374
 
375
+ // Live config application: every UI save runs these handlers in-process.
376
+ runtime.onChange(async (next, previous) => {
377
+ if (JSON.stringify(next.policy) !== JSON.stringify(previous?.policy)) {
378
+ policy = new ToolPolicy(next.policy)
379
+ if (host) host.policy = policy
380
+ if (delegation) delegation.policy = policy
381
+ }
382
+ if (JSON.stringify(next.execution) !== JSON.stringify(previous?.execution)) {
383
+ slot?.setConfig(next.execution)
384
+ if (host) {
385
+ host.shellTool = next.execution?.shellTool ?? DEFAULT_SHELL_TOOL
386
+ host.approvalTimeoutMs = next.execution?.approvalTimeoutMs ?? 60_000
387
+ }
388
+ if (delegation) {
389
+ delegation.defaultCwd = next.execution?.cwd
390
+ delegation.defaultAgentPreset = next.execution?.agentPreset
391
+ delegation.provider = next.execution?.subagentProvider ?? 'spawn'
392
+ delegation.timeoutMs = next.execution?.delegateTimeoutMs ?? 180_000
393
+ }
394
+ }
395
+ if (JSON.stringify(next.approvals) !== JSON.stringify(previous?.approvals)) {
396
+ if (Number.isInteger(next.approvals?.ttlMs) && next.approvals.ttlMs >= 1000) inbox.ttlMs = next.approvals.ttlMs
397
+ if (Number.isInteger(next.approvals?.maxPending) && next.approvals.maxPending >= 1) inbox.maxPending = next.approvals.maxPending
398
+ }
399
+ if (next.deviceName !== previous?.deviceName) {
400
+ node.name = next.deviceName
401
+ if (host) host.name = next.deviceName
402
+ }
403
+ const networkChanged =
404
+ JSON.stringify(next.direct) !== JSON.stringify(previous?.direct) ||
405
+ next.discovery !== previous?.discovery ||
406
+ JSON.stringify(next.relay) !== JSON.stringify(previous?.relay)
407
+ if (networkChanged) {
408
+ await node.reconfigure({
409
+ name: next.deviceName,
410
+ direct: next.direct,
411
+ discovery: next.discovery ? true : false,
412
+ relay: next.relay ?? null,
413
+ })
414
+ }
415
+ })
416
+
344
417
  // Adoption layer 4: one-line nudge when the model starts orchestrating.
345
418
  ctx.on?.('tools/post-execute', async (exec, _result, next) => {
346
419
  const decision = await next()
@@ -369,9 +442,8 @@ export function apply(ctx, config) {
369
442
 
370
443
  ctx.effect(() => () => {
371
444
  inbox.close()
372
- for (const roomId of rooms.openRooms()) {
373
- void rooms.close({ roomId, reason: 'plugin unloaded' }).catch(() => {})
374
- }
445
+ // Rooms are intentionally left open (and persisted) so a plugin reload can
446
+ // rehydrate them instead of destroying a running collaboration.
375
447
  void node.close()
376
448
  })
377
449