dsh-device-fleet 0.1.0-dev.4 → 0.1.0-dev.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.4",
3
+ "version": "0.1.0-dev.6",
4
4
  "description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
5
5
  "type": "module",
6
6
  "main": "src/plugin/index.js",
@@ -0,0 +1,124 @@
1
+ // Cross-device delegation (ADR-0015): the caller asks the target device's
2
+ // agent to do a one-shot task and gets the final assistant output back.
3
+ //
4
+ // On the target device this runs through the official SubagentProvider
5
+ // contract with `delivery: 'caller'`, so the child agent's own model turn and
6
+ // tools run on that device, and the `result` promise resolves with the final
7
+ // content blocks. No Room, no shared history: delegation is a one-shot task.
8
+ const DEFAULT_DELEGATE_TIMEOUT_MS = 180_000
9
+ const DEFAULT_PROVIDER = 'spawn'
10
+ const DEFAULT_LABEL = 'fleet delegation'
11
+
12
+ function textOf(result) {
13
+ const blocks = Array.isArray(result?.output) ? result.output : []
14
+ return blocks
15
+ .map((block) => (block?.type === 'text' ? block.text : `[${String(block?.type ?? 'block')}]`))
16
+ .join('\n')
17
+ .trim()
18
+ }
19
+
20
+ function withTimeout(promise, timeoutMs, onTimeout) {
21
+ let timer
22
+ return Promise.race([
23
+ promise.finally(() => clearTimeout(timer)),
24
+ new Promise((_, reject) => {
25
+ timer = setTimeout(() => reject(onTimeout()), timeoutMs)
26
+ timer.unref?.()
27
+ }),
28
+ ])
29
+ }
30
+
31
+ export class DelegationHost {
32
+ /**
33
+ * @param {object} options
34
+ * @param {object} options.subagents ctx.subagents service
35
+ * @param {object} options.slot ExecutionSlot
36
+ * @param {import('./tool-policy.js').ToolPolicy} options.policy
37
+ * @param {(request: object) => Promise<boolean>} [options.requestApproval]
38
+ * @param {string} [options.deviceId]
39
+ * @param {string} [options.defaultCwd]
40
+ * @param {string} [options.defaultAgentPreset]
41
+ * @param {string} [options.provider]
42
+ * @param {number} [options.timeoutMs]
43
+ */
44
+ constructor({
45
+ subagents,
46
+ slot,
47
+ policy,
48
+ requestApproval,
49
+ deviceId,
50
+ defaultCwd,
51
+ defaultAgentPreset,
52
+ provider = DEFAULT_PROVIDER,
53
+ timeoutMs = DEFAULT_DELEGATE_TIMEOUT_MS,
54
+ }) {
55
+ this.subagents = subagents
56
+ this.slot = slot
57
+ this.policy = policy
58
+ this.requestApproval = requestApproval
59
+ this.deviceId = deviceId
60
+ this.defaultCwd = defaultCwd
61
+ this.defaultAgentPreset = defaultAgentPreset
62
+ this.provider = provider
63
+ this.timeoutMs = timeoutMs
64
+ }
65
+
66
+ async delegate({ fromDeviceId, request = {} }) {
67
+ const task = typeof request.task === 'string' ? request.task.trim() : ''
68
+ if (task === '') throw Object.assign(new Error('delegate needs a non-empty task'), { code: 'bad-request' })
69
+
70
+ const decision = this.policy.decide({ capability: 'delegate', args: {} })
71
+ if (decision.decision === 'deny') {
72
+ throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability: 'delegate' } })
73
+ }
74
+ if (decision.decision === 'ask') {
75
+ const approved =
76
+ typeof this.requestApproval === 'function'
77
+ ? await this.requestApproval({ fromDeviceId, capability: 'delegate', tool: 'delegate', args: { task }, reason: decision.reason })
78
+ : false
79
+ if (approved !== true) throw Object.assign(new Error('local approval was not granted'), { code: 'approval_denied' })
80
+ this.policy.grant(decision.key, { scope: 'once' })
81
+ }
82
+
83
+ const agent = await this.slot.ensureAgent()
84
+ const controller = new AbortController()
85
+ const startedAt = Date.now()
86
+ let activation
87
+ try {
88
+ activation = await this.subagents.startActivation({
89
+ provider: request.provider ?? this.provider,
90
+ label: typeof request.label === 'string' && request.label !== '' ? request.label : DEFAULT_LABEL,
91
+ request: {
92
+ prompt: [{ type: 'text', text: task }],
93
+ parent: agent,
94
+ ...(request.cwd ?? this.defaultCwd ? { cwd: request.cwd ?? this.defaultCwd } : {}),
95
+ ...(request.agentPreset ?? this.defaultAgentPreset
96
+ ? { agentPreset: request.agentPreset ?? this.defaultAgentPreset }
97
+ : {}),
98
+ },
99
+ signal: controller.signal,
100
+ delivery: 'caller',
101
+ })
102
+ const result = await withTimeout(
103
+ activation.result,
104
+ Number.isFinite(request.timeoutMs) ? request.timeoutMs : this.timeoutMs,
105
+ () => {
106
+ controller.abort()
107
+ return Object.assign(new Error('delegation timed out; the child agent was asked to stop'), { code: 'delegate-timeout' })
108
+ },
109
+ )
110
+ return {
111
+ text: textOf(result) || '(delegated agent returned no text)',
112
+ stopReason: result?.stopReason ?? null,
113
+ childId: activation.childId,
114
+ durationMs: Date.now() - startedAt,
115
+ }
116
+ } finally {
117
+ try {
118
+ await activation?.dispose?.()
119
+ } catch {
120
+ /* best effort */
121
+ }
122
+ }
123
+ }
124
+ }
@@ -27,6 +27,7 @@ export class DirectCallHost {
27
27
  * @param {string} [options.dshVersion]
28
28
  * @param {string[]} [options.capabilities]
29
29
  * @param {string} [options.shellTool]
30
+ * @param {object} [options.delegation] DelegationHost for `fleet.delegate`
30
31
  */
31
32
  constructor({
32
33
  slot,
@@ -40,6 +41,7 @@ export class DirectCallHost {
40
41
  capabilities = [],
41
42
  shellTool = DEFAULT_SHELL_TOOL,
42
43
  approvalTimeoutMs = DEFAULT_POLICY_TIMEOUT_MS,
44
+ delegation = null,
43
45
  }) {
44
46
  this.slot = slot
45
47
  this.policy = policy
@@ -52,6 +54,7 @@ export class DirectCallHost {
52
54
  this.capabilities = capabilities
53
55
  this.shellTool = shellTool
54
56
  this.approvalTimeoutMs = approvalTimeoutMs
57
+ this.delegation = delegation
55
58
  }
56
59
 
57
60
  info() {
@@ -75,6 +78,11 @@ export class DirectCallHost {
75
78
  return { deviceId: this.deviceId, tools: await this.slot.listTools(), policy: this.policy.summarize() }
76
79
  case 'fleet.call':
77
80
  return this.handleCall({ fromDeviceId: deviceId, request: params ?? {} })
81
+ case 'fleet.delegate':
82
+ if (!this.delegation) {
83
+ throw Object.assign(new Error('delegation is unavailable on this device'), { code: 'unsupported' })
84
+ }
85
+ return this.delegation.delegate({ fromDeviceId: deviceId, request: params ?? {} })
78
86
  default:
79
87
  throw Object.assign(new Error(`unknown fleet method: ${String(method)}`), { code: 'unknown-method' })
80
88
  }
@@ -178,6 +186,11 @@ export class DirectCallClient {
178
186
  return this.node.request(deviceId, 'fleet.call', { capability, tool, args }, options)
179
187
  }
180
188
 
189
+ /** Ask the target device's agent to do a one-shot task and return its final text. */
190
+ async delegate(deviceId, request, options = {}) {
191
+ return this.node.request(deviceId, 'fleet.delegate', request, options)
192
+ }
193
+
181
194
  /** Format a remote result for a model-facing text tool. */
182
195
  render(result) {
183
196
  if (result?.isError) return `Error: ${result.text || 'remote tool failed'}`
package/src/host/index.js CHANGED
@@ -3,4 +3,5 @@ export * from './store.js'
3
3
  export * from './tool-policy.js'
4
4
  export * from './execution-slot.js'
5
5
  export * from './direct-call.js'
6
+ export * from './delegation.js'
6
7
  export * from './tools.js'
package/src/host/store.js CHANGED
@@ -50,6 +50,7 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
50
50
 
51
51
  const roster = readJson(path.join(dir, 'roster.json'), [])
52
52
  const config = readJson(path.join(dir, 'config.json'), {})
53
+ const peerHints = readJson(path.join(dir, 'peer-hints.json'), [])
53
54
  let enrollment = readJson(path.join(dir, 'enrollment.json'), null)
54
55
  let pendingEnrollment = readJson(path.join(dir, 'enrollment.pending.json'), null)
55
56
 
@@ -75,7 +76,32 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
75
76
  }
76
77
  }
77
78
 
78
- return { dataDir: dir, identity, fleet, roster: Array.isArray(roster) ? roster : [], config, enrollment, pendingEnrollment, createdFleet }
79
+ return {
80
+ dataDir: dir,
81
+ identity,
82
+ fleet,
83
+ roster: Array.isArray(roster) ? roster : [],
84
+ config,
85
+ enrollment,
86
+ pendingEnrollment,
87
+ peerHints: Array.isArray(peerHints) ? peerHints : [],
88
+ createdFleet,
89
+ }
90
+ }
91
+
92
+ /** Persist one manually supplied peer address (from fleet_connect) for reconnects. */
93
+ export function savePeerHint(state, hint) {
94
+ if (!hint || typeof hint.host !== 'string' || !Number.isInteger(hint.port) || hint.port <= 0) return
95
+ const entry = {
96
+ host: hint.host,
97
+ port: hint.port,
98
+ ...(typeof hint.deviceId === 'string' ? { deviceId: hint.deviceId } : {}),
99
+ }
100
+ const hints = Array.isArray(state.peerHints) ? state.peerHints : []
101
+ const exists = hints.some((item) => item.host === entry.host && item.port === entry.port && item.deviceId === entry.deviceId)
102
+ if (!exists) hints.push(entry)
103
+ state.peerHints = hints
104
+ writeJsonAtomic(path.join(state.dataDir, 'peer-hints.json'), hints)
79
105
  }
80
106
 
81
107
  /** Queue an invite for the next plugin load (the running node keeps its fleet). */
@@ -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: [],
@@ -14,6 +14,7 @@ export const DEFAULT_POLICY = Object.freeze({
14
14
  fetch: 'auto',
15
15
  run: 'ask',
16
16
  tool: 'deny',
17
+ delegate: 'ask',
17
18
  },
18
19
  allowedTools: [],
19
20
  approvalTtlMs: 15 * 60 * 1000,
package/src/host/tools.js CHANGED
@@ -22,7 +22,7 @@ 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({ node, client = new DirectCallClient({ node }), pairing = null, onPeerHint = null }) {
26
26
  return {
27
27
  async fleet_status() {
28
28
  if (typeof node.status !== 'function') return { text: 'Error: [unsupported] status is unavailable' }
@@ -34,6 +34,7 @@ export function createFleetHandlers({ node, client = new DirectCallClient({ node
34
34
  try {
35
35
  node.addPeerHint?.({ host, port })
36
36
  const peer = await node.connectAddress(host, port)
37
+ await onPeerHint?.({ host, port })
37
38
  return { text: `connected to ${peer.deviceId} (${peer.name ?? 'device'}) at ${host}:${port}` }
38
39
  } catch (error) {
39
40
  return fail(error)
@@ -109,6 +110,20 @@ export function createFleetHandlers({ node, client = new DirectCallClient({ node
109
110
  }
110
111
  },
111
112
 
113
+ async fleet_delegate({ device, task, label, cwd }) {
114
+ try {
115
+ const result = await client.delegate(
116
+ device,
117
+ { task, ...(label ? { label } : {}), ...(cwd ? { cwd } : {}) },
118
+ { timeoutMs: 200_000 },
119
+ )
120
+ const meta = result?.childId ? `\n[delegated on ${device} · ${String(result.durationMs ?? 0)}ms · child ${String(result.childId)}]` : ''
121
+ return { text: `${String(result?.text ?? '')}${meta}`.trim() }
122
+ } catch (error) {
123
+ return fail(error)
124
+ }
125
+ },
126
+
112
127
  async fleet_read({ device, path, offset, limit }) {
113
128
  try {
114
129
  const args = { file_path: path }
@@ -211,6 +226,19 @@ export function createFleetToolDefinitions({ defineTool, handlers }) {
211
226
  output,
212
227
  execute: (args) => handlers.fleet_call(args),
213
228
  }),
229
+ defineTool({
230
+ name: 'fleet_delegate',
231
+ description:
232
+ '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.',
233
+ parameters: {
234
+ device: commonDevice,
235
+ task: { type: 'string', required: true, description: 'Self-contained task instructions for the target agent.' },
236
+ label: { type: 'string', description: 'Short label shown in the target device\'s subagent list.' },
237
+ cwd: { type: 'string', description: 'Optional working directory on the target device.' },
238
+ },
239
+ output,
240
+ execute: (args) => handlers.fleet_delegate(args),
241
+ }),
214
242
  defineTool({
215
243
  name: 'fleet_read',
216
244
  description:
@@ -281,6 +281,14 @@ export class FleetNode extends EventEmitter {
281
281
  if (current === channel) this.channels.delete(channel.peer.deviceId)
282
282
  this.rejectPendingForChannel(channel, Object.assign(new Error('peer connection closed'), { code: 'peer-offline' }))
283
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
+ }
284
292
  })
285
293
  }
286
294
 
@@ -13,10 +13,11 @@ import Schema from '@deepseek-ai/schemastery'
13
13
  import { FleetNode } from '../net/fleet-node.js'
14
14
  import { accountFingerprint } from '../net/identity.js'
15
15
  import { DirectCallClient, DirectCallHost } from '../host/direct-call.js'
16
+ import { DelegationHost } from '../host/delegation.js'
16
17
  import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
17
18
  import { ToolPolicy } from '../host/tool-policy.js'
18
19
  import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
19
- import { accountMatches, createInvite, loadState, saveRoster, writePendingEnrollment } from '../host/store.js'
20
+ import { accountMatches, createInvite, loadState, savePeerHint, saveRoster, writePendingEnrollment } from '../host/store.js'
20
21
 
21
22
  export const name = 'device-fleet'
22
23
 
@@ -60,9 +61,11 @@ export const Config = Schema.object({
60
61
  agentPreset: Schema.string(),
61
62
  shellTool: Schema.string().default(DEFAULT_SHELL_TOOL),
62
63
  approvalTimeoutMs: Schema.number().default(60000),
64
+ delegateTimeoutMs: Schema.number().default(180000),
65
+ subagentProvider: Schema.string().default('spawn'),
63
66
  })
64
- .default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000 })
65
- .description('Execution Slot: the hidden DSH session used to execute remote tool calls.'),
67
+ .default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000, delegateTimeoutMs: 180000, subagentProvider: 'spawn' })
68
+ .description('Execution Slot and delegation settings for direct calls and one-shot delegation.'),
66
69
  policy: Schema.object({
67
70
  allowedRoots: Schema.array(Schema.string()).default([]),
68
71
  capabilities: Schema.any().default({}),
@@ -144,7 +147,7 @@ export function apply(ctx, config) {
144
147
  discovery: config.discovery ? true : false,
145
148
  relay: config.relay ?? state.enrollment?.relay ?? null,
146
149
  roster: state.roster,
147
- peerHints: state.enrollment?.endpoints ?? [],
150
+ peerHints: [...(state.enrollment?.endpoints ?? []), ...(state.peerHints ?? [])],
148
151
  pluginVersion,
149
152
  dshVersion,
150
153
  capabilities: CAPABILITIES,
@@ -153,11 +156,32 @@ export function apply(ctx, config) {
153
156
  const policy = new ToolPolicy(config.policy)
154
157
  const sessionController = service('sessionController')
155
158
  const fs = service('fs')
159
+ const subagents = service('subagents')
156
160
  if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
157
161
  const slot = sessionController
158
162
  ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
159
163
  : null
160
164
 
165
+ const delegation =
166
+ slot && subagents
167
+ ? new DelegationHost({
168
+ subagents,
169
+ slot,
170
+ policy,
171
+ deviceId: state.identity.deviceId,
172
+ defaultCwd: config.execution?.cwd,
173
+ defaultAgentPreset: config.execution?.agentPreset,
174
+ provider: config.execution?.subagentProvider ?? 'spawn',
175
+ timeoutMs: config.execution?.delegateTimeoutMs ?? 180_000,
176
+ requestApproval: config.autoApproveAsk
177
+ ? async () => true
178
+ : async (request) => {
179
+ logger.info?.(`[device-fleet] delegation approval required from ${request.fromDeviceId} (no approval UI yet)`)
180
+ return false
181
+ },
182
+ })
183
+ : null
184
+
161
185
  const host = slot
162
186
  ? new DirectCallHost({
163
187
  slot,
@@ -170,6 +194,7 @@ export function apply(ctx, config) {
170
194
  capabilities: CAPABILITIES,
171
195
  shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
172
196
  approvalTimeoutMs: config.execution?.approvalTimeoutMs ?? 60_000,
197
+ delegation,
173
198
  requestApproval: config.autoApproveAsk
174
199
  ? async () => true
175
200
  : async (request) => {
@@ -243,7 +268,18 @@ export function apply(ctx, config) {
243
268
  writePendingEnrollment(state, code)
244
269
  },
245
270
  }
246
- const handlers = createFleetHandlers({ node, client, pairing })
271
+ const handlers = createFleetHandlers({
272
+ node,
273
+ client,
274
+ pairing,
275
+ onPeerHint: async (hint) => {
276
+ try {
277
+ savePeerHint(state, hint)
278
+ } catch (error) {
279
+ logger.warn?.(`[device-fleet] cannot persist peer hint: ${String(error?.message ?? error)}`)
280
+ }
281
+ },
282
+ })
247
283
  const definitions = createFleetToolDefinitions({ defineTool, handlers })
248
284
  const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
249
285
  for (const definition of wrapped) ctx.tools.register(definition)