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,380 @@
1
+ // Cordis Host plugin entry for the device-fleet bundle.
2
+ //
3
+ // Step 1 wires the network layer, the FleetNode, the direct-call host/client,
4
+ // the six `fleet_*` tools, and the two adoption mechanisms that are cheap to
5
+ // land now (a prompt section and a first-use manual + orchestration nudge).
6
+ // Room, Room Board, delegation and the UI land in later steps.
7
+ import { readFileSync } from 'node:fs'
8
+ import { networkInterfaces } from 'node:os'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { defineTool } from '@deepseek-ai/dsh-tools'
11
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
12
+ import Schema from '@deepseek-ai/schemastery'
13
+ import { FleetNode } from '../net/fleet-node.js'
14
+ import { accountFingerprint } from '../net/identity.js'
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'
20
+ import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
21
+ import { ToolPolicy } from '../host/tool-policy.js'
22
+ import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
23
+ import { accountMatches, createInvite, loadState, savePeerHint, saveRoster, writePendingEnrollment } from '../host/store.js'
24
+
25
+ export const name = 'device-fleet'
26
+
27
+ /** Only the tool registry is mandatory; everything else degrades. */
28
+ export const inject = ['tools']
29
+
30
+ const CAPABILITIES = ['direct-call', 'room', 'delegation']
31
+ const PROMPT_SECTION_ORDER = 9000
32
+
33
+ const FLEET_MANUAL = `# Fleet 使用手册
34
+
35
+ 你是设备联邦(Fleet)中的一个设备成员。其他设备通过 fleet 工具暴露能力,规则如下:
36
+
37
+ - **先看图再动手**:调用任何 fleet 工具前先 \`fleet_list\` 拿到 device id;不认识的工具用 \`fleet_tools\` 查。
38
+ - **直调 vs 协作**:只是借用另一台设备的工具(看文件、取文件、跑命令、取日志)用 \`fleet_read\` / \`fleet_fetch\` / \`fleet_run\` / \`fleet_call\`;需要对方 agent 持续参与、共享任务状态时,才建 Room(后续版本提供)。
39
+ - **离线快速失败**:目标设备离线时直调立即报错,不要等待;改为稍后再试或换设备。
40
+ - **审批在对方设备上**:需要本机审批的调用会等待对方用户;超时/被拒绝时按错误码处理,换方案或请用户处理,不要绕过。
41
+ - **权限由对方决定**:你只能请求,能否执行、能访问哪些目录由目标设备配置决定。`
42
+
43
+ /** Session ids already handed the fleet manual. */
44
+ const manualDelivered = new Set()
45
+
46
+ export const Config = Schema.object({
47
+ deviceName: Schema.string().default('device').description('Display name of this device in the Fleet.'),
48
+ dataDir: Schema.string().description('Override the device-fleet state directory (defaults to $DSH_HOME/device-fleet).'),
49
+ direct: Schema.object({
50
+ host: Schema.string().default('0.0.0.0'),
51
+ port: Schema.number().default(0),
52
+ })
53
+ .default({ host: '0.0.0.0', port: 0 })
54
+ .description('Direct LAN/VPN listener. Set port 0 to let the OS pick one; discovery announces it.'),
55
+ discovery: Schema.boolean().default(true).description('Advertise this device on the local network over UDP multicast.'),
56
+ relay: Schema.object({
57
+ host: Schema.string(),
58
+ port: Schema.number(),
59
+ dataPort: Schema.number(),
60
+ })
61
+ .description('Self-hosted relay (v2) used when a direct connection is not reachable.'),
62
+ execution: Schema.object({
63
+ cwd: Schema.string(),
64
+ agentPreset: Schema.string(),
65
+ shellTool: Schema.string().default(DEFAULT_SHELL_TOOL),
66
+ approvalTimeoutMs: Schema.number().default(60000),
67
+ delegateTimeoutMs: Schema.number().default(180000),
68
+ subagentProvider: Schema.string().default('spawn'),
69
+ })
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.'),
72
+ policy: Schema.object({
73
+ allowedRoots: Schema.array(Schema.string()).default([]),
74
+ capabilities: Schema.any().default({}),
75
+ allowedTools: Schema.array(Schema.string()).default([]),
76
+ approvalTtlMs: Schema.number().default(15 * 60 * 1000),
77
+ })
78
+ .default({ allowedRoots: [], capabilities: {}, allowedTools: [], approvalTtlMs: 15 * 60 * 1000 })
79
+ .description('Direct-call Tool Policy owned by this device (ADR-0004/ADR-0019).'),
80
+ promptSection: Schema.boolean().default(true).description('Render the one-sentence Fleet nudge when the Fleet has members.'),
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.'),
89
+ })
90
+
91
+ function packageVersion() {
92
+ try {
93
+ const file = fileURLToPath(new URL('../../package.json', import.meta.url))
94
+ return JSON.parse(readFileSync(file, 'utf8')).version ?? '0.0.0'
95
+ } catch {
96
+ return '0.0.0'
97
+ }
98
+ }
99
+
100
+ /** Wrap a handler so failures are reported as text instead of tool crashes. */
101
+ function safe(handler) {
102
+ return async (...args) => {
103
+ try {
104
+ return await handler(...args)
105
+ } catch (error) {
106
+ const code = error?.code ?? 'error'
107
+ return { text: `Error: [${String(code)}] ${String(error?.message ?? error)}` }
108
+ }
109
+ }
110
+ }
111
+
112
+ function withManual(definitions) {
113
+ return definitions.map((definition) => ({
114
+ ...definition,
115
+ execute: async (...args) => {
116
+ const result = await definition.execute(...args)
117
+ const exec = args[1]
118
+ const sessionId = exec?.agent?.session?.id
119
+ if (typeof sessionId !== 'string' || manualDelivered.has(sessionId)) return result
120
+ manualDelivered.add(sessionId)
121
+ if (typeof result === 'object' && result !== null && 'text' in result) {
122
+ return { ...result, text: `${String(result.text)}\n\n---\n${FLEET_MANUAL}` }
123
+ }
124
+ return result
125
+ },
126
+ }))
127
+ }
128
+
129
+ export function apply(ctx, config) {
130
+ const pluginVersion = packageVersion()
131
+ const dshVersion = process.env.DSH_CLIENT_VERSION ?? 'unknown'
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
142
+
143
+ let state
144
+ try {
145
+ state = loadState({ dshHome: process.env.DSH_HOME, dataDir: config.dataDir, name: config.deviceName })
146
+ } catch (error) {
147
+ logger.error?.(`[device-fleet] cannot load state: ${String(error?.message ?? error)}`)
148
+ return
149
+ }
150
+
151
+ const node = new FleetNode({
152
+ identity: state.identity,
153
+ fleet: state.fleet,
154
+ name: config.deviceName,
155
+ accountFingerprint: state.enrollment?.accountFingerprint ?? null,
156
+ direct: config.direct ?? { host: '127.0.0.1', port: 0 },
157
+ discovery: config.discovery ? true : false,
158
+ relay: config.relay ?? state.enrollment?.relay ?? null,
159
+ roster: state.roster,
160
+ peerHints: [...(state.enrollment?.endpoints ?? []), ...(state.peerHints ?? [])],
161
+ pluginVersion,
162
+ dshVersion,
163
+ capabilities: CAPABILITIES,
164
+ })
165
+
166
+ const policy = new ToolPolicy(config.policy)
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
+ }
208
+ if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
209
+ const slot = sessionController
210
+ ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
211
+ : null
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
+
228
+ const host = slot
229
+ ? new DirectCallHost({
230
+ slot,
231
+ policy,
232
+ fs,
233
+ deviceId: state.identity.deviceId,
234
+ name: config.deviceName,
235
+ pluginVersion,
236
+ dshVersion,
237
+ capabilities: CAPABILITIES,
238
+ shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
239
+ approvalTimeoutMs: config.execution?.approvalTimeoutMs ?? 60_000,
240
+ delegation,
241
+ requestApproval,
242
+ })
243
+ : null
244
+
245
+ node.onRequest(async (request) => {
246
+ if (!host) throw Object.assign(new Error('this device cannot serve direct calls (no execution slot)'), { code: 'unsupported' })
247
+ return host.handleRequest(request)
248
+ })
249
+ node.on('roster', (roster) => {
250
+ try {
251
+ saveRoster(state, roster)
252
+ } catch (error) {
253
+ logger.warn?.(`[device-fleet] cannot persist roster: ${String(error?.message ?? error)}`)
254
+ }
255
+ })
256
+ node.on('warning', (error) => logger.warn?.(`[device-fleet] ${String(error?.message ?? error)}`))
257
+
258
+ const ready = node.start().catch((error) => {
259
+ logger.error?.(`[device-fleet] cannot start network layer: ${String(error?.message ?? error)}`)
260
+ })
261
+
262
+ // Bind the local account fingerprint when the account service is composed.
263
+ const account = service('deepseekAccount')
264
+ if (account && typeof account.getDeviceIdentity === 'function') {
265
+ void account
266
+ .getDeviceIdentity()
267
+ .then((identity) => {
268
+ if (!identity?.userId) return
269
+ const fingerprint = accountFingerprint(state.fleet.fleetKey, identity.userId)
270
+ if (!accountMatches(state, fingerprint)) {
271
+ logger.error?.('[device-fleet] signed-in account does not match the Fleet invite; connections will be refused')
272
+ node.accountFingerprint = 'mismatch'
273
+ return
274
+ }
275
+ node.accountFingerprint = fingerprint
276
+ })
277
+ .catch(() => {})
278
+ }
279
+
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
+ }
293
+ const pairing = {
294
+ async invite() {
295
+ const fingerprint =
296
+ node.accountFingerprint && node.accountFingerprint !== 'mismatch'
297
+ ? node.accountFingerprint
298
+ : (state.enrollment?.accountFingerprint ?? null)
299
+ return createInvite(state, {
300
+ accountFingerprint: fingerprint,
301
+ relay: config.relay ?? state.enrollment?.relay ?? null,
302
+ endpoints: localEndpoints(),
303
+ })
304
+ },
305
+ async join(code) {
306
+ writePendingEnrollment(state, code)
307
+ },
308
+ }
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
+ })
323
+ const definitions = createFleetToolDefinitions({ defineTool, handlers })
324
+ const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
325
+ for (const definition of wrapped) ctx.tools.register(definition)
326
+
327
+ // Adoption layer 2: one sentence, only when the Fleet has members.
328
+ const systemPrompt = service('systemPrompt')
329
+ if (systemPrompt && config.promptSection !== false) {
330
+ const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
331
+ ctx.effect(() =>
332
+ systemPrompt.section({
333
+ name: 'device-fleet:members',
334
+ order,
335
+ text: () => {
336
+ const members = node.rosterEntries().length
337
+ if (members === 0) return ''
338
+ 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.`
339
+ },
340
+ }),
341
+ )
342
+ }
343
+
344
+ // Adoption layer 4: one-line nudge when the model starts orchestrating.
345
+ ctx.on?.('tools/post-execute', async (exec, _result, next) => {
346
+ const decision = await next()
347
+ const signal = exec?.name
348
+ if (signal !== 'todo_write' && !String(signal).startsWith('subagent') && signal !== 'workflow' && signal !== 'ralph') return decision
349
+ if (node.rosterEntries().length === 0) return decision
350
+ const sessionId = exec?.agent?.session?.id
351
+ if (typeof sessionId !== 'string' || manualDelivered.has(sessionId)) return decision
352
+ manualDelivered.add(sessionId)
353
+ return {
354
+ ...decision,
355
+ additionalContexts: [
356
+ ...(decision.additionalContexts ?? []),
357
+ createUserMessage({
358
+ content: [
359
+ {
360
+ type: 'text',
361
+ text: 'You are organising multi-step work on a device Fleet. If part of it belongs on another machine (builds, logs, files, tests), use fleet_list then fleet_read/fleet_fetch/fleet_run; keep the work on this device when it is local.',
362
+ },
363
+ ],
364
+ source: { kind: 'device-fleet', form: 'instructions' },
365
+ }),
366
+ ],
367
+ }
368
+ })
369
+
370
+ ctx.effect(() => () => {
371
+ inbox.close()
372
+ for (const roomId of rooms.openRooms()) {
373
+ void rooms.close({ roomId, reason: 'plugin unloaded' }).catch(() => {})
374
+ }
375
+ void node.close()
376
+ })
377
+
378
+ logger.info?.(`[device-fleet] v${pluginVersion} device ${state.identity.deviceId} (${config.deviceName})`)
379
+ void ready
380
+ }