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

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.
@@ -5,17 +5,22 @@
5
5
  // land now (a prompt section and a first-use manual + orchestration nudge).
6
6
  // Room, Room Board, delegation and the UI land in later steps.
7
7
  import { readFileSync } from 'node:fs'
8
+ import { networkInterfaces } from 'node:os'
8
9
  import { fileURLToPath } from 'node:url'
9
10
  import { defineTool } from '@deepseek-ai/dsh-tools'
10
11
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
11
- import { Schema } from '@deepseek-ai/schemastery'
12
+ import Schema from '@deepseek-ai/schemastery'
12
13
  import { FleetNode } from '../net/fleet-node.js'
13
14
  import { accountFingerprint } from '../net/identity.js'
14
15
  import { DirectCallClient, DirectCallHost } from '../host/direct-call.js'
16
+ import { DelegationHost } from '../host/delegation.js'
17
+ import { ApprovalInbox } from '../host/approval-inbox.js'
18
+ import { RoomService } from '../host/room-service.js'
19
+ import { registerApprovalRoutes } from '../host/approval-web.js'
15
20
  import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
16
21
  import { ToolPolicy } from '../host/tool-policy.js'
17
22
  import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
18
- import { accountMatches, createInvite, loadState, saveRoster, writePendingEnrollment } from '../host/store.js'
23
+ import { accountMatches, createInvite, loadState, savePeerHint, saveRoster, writePendingEnrollment } from '../host/store.js'
19
24
 
20
25
  export const name = 'device-fleet'
21
26
 
@@ -59,9 +64,11 @@ export const Config = Schema.object({
59
64
  agentPreset: Schema.string(),
60
65
  shellTool: Schema.string().default(DEFAULT_SHELL_TOOL),
61
66
  approvalTimeoutMs: Schema.number().default(60000),
67
+ delegateTimeoutMs: Schema.number().default(180000),
68
+ subagentProvider: Schema.string().default('spawn'),
62
69
  })
63
- .default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000 })
64
- .description('Execution Slot: the hidden DSH session used to execute remote tool calls.'),
70
+ .default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000, delegateTimeoutMs: 180000, subagentProvider: 'spawn' })
71
+ .description('Execution Slot and delegation settings for direct calls and one-shot delegation.'),
65
72
  policy: Schema.object({
66
73
  allowedRoots: Schema.array(Schema.string()).default([]),
67
74
  capabilities: Schema.any().default({}),
@@ -72,6 +79,13 @@ export const Config = Schema.object({
72
79
  .description('Direct-call Tool Policy owned by this device (ADR-0004/ADR-0019).'),
73
80
  promptSection: Schema.boolean().default(true).description('Render the one-sentence Fleet nudge when the Fleet has members.'),
74
81
  autoApproveAsk: Schema.boolean().default(false).description('Testing only: auto-approve every local approval request.'),
82
+ approvals: Schema.object({
83
+ ttlMs: Schema.number().default(60000),
84
+ maxPending: Schema.number().default(50),
85
+ simulation: Schema.boolean().default(false),
86
+ })
87
+ .default({ ttlMs: 60000, maxPending: 50, simulation: false })
88
+ .description('Local approval inbox (ADR-0020). `simulation` exposes fleet_simulate_request for local testing.'),
75
89
  })
76
90
 
77
91
  function packageVersion() {
@@ -115,7 +129,16 @@ function withManual(definitions) {
115
129
  export function apply(ctx, config) {
116
130
  const pluginVersion = packageVersion()
117
131
  const dshVersion = process.env.DSH_CLIENT_VERSION ?? 'unknown'
118
- const logger = ctx.logger ?? console
132
+ // Optional services: Cordis throws on direct property access for services the
133
+ // plugin did not inject, so every optional service goes through get().
134
+ const service = (name) => {
135
+ try {
136
+ return ctx.get?.(name)
137
+ } catch {
138
+ return undefined
139
+ }
140
+ }
141
+ const logger = service('logger') ?? console
119
142
 
120
143
  let state
121
144
  try {
@@ -134,19 +157,74 @@ export function apply(ctx, config) {
134
157
  discovery: config.discovery ? true : false,
135
158
  relay: config.relay ?? state.enrollment?.relay ?? null,
136
159
  roster: state.roster,
160
+ peerHints: [...(state.enrollment?.endpoints ?? []), ...(state.peerHints ?? [])],
137
161
  pluginVersion,
138
162
  dshVersion,
139
163
  capabilities: CAPABILITIES,
140
164
  })
141
165
 
142
166
  const policy = new ToolPolicy(config.policy)
143
- const sessionController = ctx.sessionController ?? ctx.get?.('sessionController')
144
- const fs = ctx.fs ?? ctx.get?.('fs')
167
+ const sessionController = service('sessionController')
168
+ const fs = service('fs')
169
+ const subagents = service('subagents')
170
+ const inbox = new ApprovalInbox({
171
+ ttlMs: config.approvals?.ttlMs ?? 60_000,
172
+ maxPending: config.approvals?.maxPending ?? 50,
173
+ })
174
+ inbox.on('request', (request) => logger.info?.(`[device-fleet] approval requested by ${request.fromDeviceId}: ${request.capability}/${String(request.tool ?? '')}`))
175
+ const approvalsApi = {
176
+ list: () => inbox.list(),
177
+ approve: (id, options) => inbox.approve(id, options),
178
+ 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,
195
+ }
196
+ const requestApproval = config.autoApproveAsk
197
+ ? async () => ({ approved: true, scope: 'once' })
198
+ : async (request) => inbox.request(request)
199
+
200
+ const rooms = new RoomService({ sessionController, createUserMessage })
201
+
202
+ // Client approval panel data plane (dev.9). Optional service: host-only
203
+ // deployments simply do not expose the panel routes.
204
+ const webServer = service('webServer')
205
+ if (webServer) {
206
+ ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
207
+ }
145
208
  if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
146
209
  const slot = sessionController
147
210
  ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
148
211
  : null
149
212
 
213
+ const delegation =
214
+ slot && subagents
215
+ ? new DelegationHost({
216
+ subagents,
217
+ slot,
218
+ policy,
219
+ deviceId: state.identity.deviceId,
220
+ defaultCwd: config.execution?.cwd,
221
+ defaultAgentPreset: config.execution?.agentPreset,
222
+ provider: config.execution?.subagentProvider ?? 'spawn',
223
+ timeoutMs: config.execution?.delegateTimeoutMs ?? 180_000,
224
+ requestApproval,
225
+ })
226
+ : null
227
+
150
228
  const host = slot
151
229
  ? new DirectCallHost({
152
230
  slot,
@@ -159,12 +237,8 @@ export function apply(ctx, config) {
159
237
  capabilities: CAPABILITIES,
160
238
  shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
161
239
  approvalTimeoutMs: config.execution?.approvalTimeoutMs ?? 60_000,
162
- requestApproval: config.autoApproveAsk
163
- ? async () => true
164
- : async (request) => {
165
- logger.info?.(`[device-fleet] approval required from ${request.fromDeviceId} for ${request.capability}/${String(request.tool)} (no approval UI yet)`)
166
- return false
167
- },
240
+ delegation,
241
+ requestApproval,
168
242
  })
169
243
  : null
170
244
 
@@ -186,7 +260,7 @@ export function apply(ctx, config) {
186
260
  })
187
261
 
188
262
  // Bind the local account fingerprint when the account service is composed.
189
- const account = ctx.get?.('deepseekAccount')
263
+ const account = service('deepseekAccount')
190
264
  if (account && typeof account.getDeviceIdentity === 'function') {
191
265
  void account
192
266
  .getDeviceIdentity()
@@ -204,6 +278,18 @@ export function apply(ctx, config) {
204
278
  }
205
279
 
206
280
  const client = new DirectCallClient({ node })
281
+ const localEndpoints = () => {
282
+ const port = node.directAddress?.port
283
+ if (!Number.isInteger(port) || port <= 0) return []
284
+ const endpoints = []
285
+ for (const entries of Object.values(networkInterfaces())) {
286
+ for (const entry of entries ?? []) {
287
+ if (entry.family !== 'IPv4' || entry.internal) continue
288
+ endpoints.push({ deviceId: state.identity.deviceId, host: entry.address, port })
289
+ }
290
+ }
291
+ return endpoints
292
+ }
207
293
  const pairing = {
208
294
  async invite() {
209
295
  const fingerprint =
@@ -213,19 +299,33 @@ export function apply(ctx, config) {
213
299
  return createInvite(state, {
214
300
  accountFingerprint: fingerprint,
215
301
  relay: config.relay ?? state.enrollment?.relay ?? null,
302
+ endpoints: localEndpoints(),
216
303
  })
217
304
  },
218
305
  async join(code) {
219
306
  writePendingEnrollment(state, code)
220
307
  },
221
308
  }
222
- const handlers = createFleetHandlers({ node, client, pairing })
309
+ const handlers = createFleetHandlers({
310
+ node,
311
+ client,
312
+ pairing,
313
+ onPeerHint: async (hint) => {
314
+ try {
315
+ savePeerHint(state, hint)
316
+ } catch (error) {
317
+ logger.warn?.(`[device-fleet] cannot persist peer hint: ${String(error?.message ?? error)}`)
318
+ }
319
+ },
320
+ approvals: approvalsApi,
321
+ rooms,
322
+ })
223
323
  const definitions = createFleetToolDefinitions({ defineTool, handlers })
224
324
  const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
225
325
  for (const definition of wrapped) ctx.tools.register(definition)
226
326
 
227
327
  // Adoption layer 2: one sentence, only when the Fleet has members.
228
- const systemPrompt = ctx.get?.('systemPrompt') ?? ctx.systemPrompt
328
+ const systemPrompt = service('systemPrompt')
229
329
  if (systemPrompt && config.promptSection !== false) {
230
330
  const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
231
331
  ctx.effect(() =>
@@ -268,9 +368,13 @@ export function apply(ctx, config) {
268
368
  })
269
369
 
270
370
  ctx.effect(() => () => {
371
+ inbox.close()
372
+ for (const roomId of rooms.openRooms()) {
373
+ void rooms.close({ roomId, reason: 'plugin unloaded' }).catch(() => {})
374
+ }
271
375
  void node.close()
272
376
  })
273
377
 
274
- ctx.logger?.info?.(`[device-fleet] v${pluginVersion} device ${state.identity.deviceId} (${config.deviceName})`)
378
+ logger.info?.(`[device-fleet] v${pluginVersion} device ${state.identity.deviceId} (${config.deviceName})`)
275
379
  void ready
276
380
  }