dsh-device-fleet 0.0.0-stage → 0.1.0-dev.2

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 (51) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +133 -2
  4. package/cordis.patch.yml +26 -0
  5. package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
  6. package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
  7. package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
  8. package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
  9. package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
  10. package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
  11. package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
  12. package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
  13. package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
  14. package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
  15. package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
  16. package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
  17. package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
  18. package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
  19. package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
  20. package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
  21. package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
  22. package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
  23. package/docs/adr/0019-execution-context-configured-locally.md +24 -0
  24. package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
  25. package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
  26. package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
  27. package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
  28. package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
  29. package/docs/notes/design-decisions-summary.md +49 -0
  30. package/docs/notes/dev-release.md +28 -0
  31. package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
  32. package/docs/notes/network-v1-v2-implementation.md +71 -0
  33. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  34. package/package.json +42 -3
  35. package/src/host/direct-call.js +190 -0
  36. package/src/host/execution-slot.js +82 -0
  37. package/src/host/index.js +6 -0
  38. package/src/host/store.js +128 -0
  39. package/src/host/tool-policy.js +150 -0
  40. package/src/host/tools.js +215 -0
  41. package/src/index.js +3 -0
  42. package/src/net/direct.js +69 -0
  43. package/src/net/discovery.js +105 -0
  44. package/src/net/fleet-node.js +414 -0
  45. package/src/net/identity.js +156 -0
  46. package/src/net/index.js +9 -0
  47. package/src/net/protocol.js +73 -0
  48. package/src/net/relay-client.js +258 -0
  49. package/src/net/relay-server.js +366 -0
  50. package/src/net/secure-channel.js +538 -0
  51. package/src/plugin/index.js +285 -0
@@ -0,0 +1,285 @@
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 { fileURLToPath } from 'node:url'
9
+ import { defineTool } from '@deepseek-ai/dsh-tools'
10
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
11
+ import Schema from '@deepseek-ai/schemastery'
12
+ import { FleetNode } from '../net/fleet-node.js'
13
+ import { accountFingerprint } from '../net/identity.js'
14
+ import { DirectCallClient, DirectCallHost } from '../host/direct-call.js'
15
+ import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
16
+ import { ToolPolicy } from '../host/tool-policy.js'
17
+ import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
18
+ import { accountMatches, createInvite, loadState, saveRoster, writePendingEnrollment } from '../host/store.js'
19
+
20
+ export const name = 'device-fleet'
21
+
22
+ /** Only the tool registry is mandatory; everything else degrades. */
23
+ export const inject = ['tools']
24
+
25
+ const CAPABILITIES = ['direct-call', 'room', 'delegation']
26
+ const PROMPT_SECTION_ORDER = 9000
27
+
28
+ const FLEET_MANUAL = `# Fleet 使用手册
29
+
30
+ 你是设备联邦(Fleet)中的一个设备成员。其他设备通过 fleet 工具暴露能力,规则如下:
31
+
32
+ - **先看图再动手**:调用任何 fleet 工具前先 \`fleet_list\` 拿到 device id;不认识的工具用 \`fleet_tools\` 查。
33
+ - **直调 vs 协作**:只是借用另一台设备的工具(看文件、取文件、跑命令、取日志)用 \`fleet_read\` / \`fleet_fetch\` / \`fleet_run\` / \`fleet_call\`;需要对方 agent 持续参与、共享任务状态时,才建 Room(后续版本提供)。
34
+ - **离线快速失败**:目标设备离线时直调立即报错,不要等待;改为稍后再试或换设备。
35
+ - **审批在对方设备上**:需要本机审批的调用会等待对方用户;超时/被拒绝时按错误码处理,换方案或请用户处理,不要绕过。
36
+ - **权限由对方决定**:你只能请求,能否执行、能访问哪些目录由目标设备配置决定。`
37
+
38
+ /** Session ids already handed the fleet manual. */
39
+ const manualDelivered = new Set()
40
+
41
+ export const Config = Schema.object({
42
+ deviceName: Schema.string().default('device').description('Display name of this device in the Fleet.'),
43
+ dataDir: Schema.string().description('Override the device-fleet state directory (defaults to $DSH_HOME/device-fleet).'),
44
+ direct: Schema.object({
45
+ host: Schema.string().default('0.0.0.0'),
46
+ port: Schema.number().default(0),
47
+ })
48
+ .default({ host: '0.0.0.0', port: 0 })
49
+ .description('Direct LAN/VPN listener. Set port 0 to let the OS pick one; discovery announces it.'),
50
+ discovery: Schema.boolean().default(true).description('Advertise this device on the local network over UDP multicast.'),
51
+ relay: Schema.object({
52
+ host: Schema.string(),
53
+ port: Schema.number(),
54
+ dataPort: Schema.number(),
55
+ })
56
+ .description('Self-hosted relay (v2) used when a direct connection is not reachable.'),
57
+ execution: Schema.object({
58
+ cwd: Schema.string(),
59
+ agentPreset: Schema.string(),
60
+ shellTool: Schema.string().default(DEFAULT_SHELL_TOOL),
61
+ approvalTimeoutMs: Schema.number().default(60000),
62
+ })
63
+ .default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000 })
64
+ .description('Execution Slot: the hidden DSH session used to execute remote tool calls.'),
65
+ policy: Schema.object({
66
+ allowedRoots: Schema.array(Schema.string()).default([]),
67
+ capabilities: Schema.any().default({}),
68
+ allowedTools: Schema.array(Schema.string()).default([]),
69
+ approvalTtlMs: Schema.number().default(15 * 60 * 1000),
70
+ })
71
+ .default({ allowedRoots: [], capabilities: {}, allowedTools: [], approvalTtlMs: 15 * 60 * 1000 })
72
+ .description('Direct-call Tool Policy owned by this device (ADR-0004/ADR-0019).'),
73
+ promptSection: Schema.boolean().default(true).description('Render the one-sentence Fleet nudge when the Fleet has members.'),
74
+ autoApproveAsk: Schema.boolean().default(false).description('Testing only: auto-approve every local approval request.'),
75
+ })
76
+
77
+ function packageVersion() {
78
+ try {
79
+ const file = fileURLToPath(new URL('../../package.json', import.meta.url))
80
+ return JSON.parse(readFileSync(file, 'utf8')).version ?? '0.0.0'
81
+ } catch {
82
+ return '0.0.0'
83
+ }
84
+ }
85
+
86
+ /** Wrap a handler so failures are reported as text instead of tool crashes. */
87
+ function safe(handler) {
88
+ return async (...args) => {
89
+ try {
90
+ return await handler(...args)
91
+ } catch (error) {
92
+ const code = error?.code ?? 'error'
93
+ return { text: `Error: [${String(code)}] ${String(error?.message ?? error)}` }
94
+ }
95
+ }
96
+ }
97
+
98
+ function withManual(definitions) {
99
+ return definitions.map((definition) => ({
100
+ ...definition,
101
+ execute: async (...args) => {
102
+ const result = await definition.execute(...args)
103
+ const exec = args[1]
104
+ const sessionId = exec?.agent?.session?.id
105
+ if (typeof sessionId !== 'string' || manualDelivered.has(sessionId)) return result
106
+ manualDelivered.add(sessionId)
107
+ if (typeof result === 'object' && result !== null && 'text' in result) {
108
+ return { ...result, text: `${String(result.text)}\n\n---\n${FLEET_MANUAL}` }
109
+ }
110
+ return result
111
+ },
112
+ }))
113
+ }
114
+
115
+ export function apply(ctx, config) {
116
+ const pluginVersion = packageVersion()
117
+ const dshVersion = process.env.DSH_CLIENT_VERSION ?? 'unknown'
118
+ // Optional services: Cordis throws on direct property access for services the
119
+ // plugin did not inject, so every optional service goes through get().
120
+ const service = (name) => {
121
+ try {
122
+ return ctx.get?.(name)
123
+ } catch {
124
+ return undefined
125
+ }
126
+ }
127
+ const logger = service('logger') ?? console
128
+
129
+ let state
130
+ try {
131
+ state = loadState({ dshHome: process.env.DSH_HOME, dataDir: config.dataDir, name: config.deviceName })
132
+ } catch (error) {
133
+ logger.error?.(`[device-fleet] cannot load state: ${String(error?.message ?? error)}`)
134
+ return
135
+ }
136
+
137
+ const node = new FleetNode({
138
+ identity: state.identity,
139
+ fleet: state.fleet,
140
+ name: config.deviceName,
141
+ accountFingerprint: state.enrollment?.accountFingerprint ?? null,
142
+ direct: config.direct ?? { host: '127.0.0.1', port: 0 },
143
+ discovery: config.discovery ? true : false,
144
+ relay: config.relay ?? state.enrollment?.relay ?? null,
145
+ roster: state.roster,
146
+ pluginVersion,
147
+ dshVersion,
148
+ capabilities: CAPABILITIES,
149
+ })
150
+
151
+ const policy = new ToolPolicy(config.policy)
152
+ const sessionController = service('sessionController')
153
+ const fs = service('fs')
154
+ if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
155
+ const slot = sessionController
156
+ ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
157
+ : null
158
+
159
+ const host = slot
160
+ ? new DirectCallHost({
161
+ slot,
162
+ policy,
163
+ fs,
164
+ deviceId: state.identity.deviceId,
165
+ name: config.deviceName,
166
+ pluginVersion,
167
+ dshVersion,
168
+ capabilities: CAPABILITIES,
169
+ shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
170
+ approvalTimeoutMs: config.execution?.approvalTimeoutMs ?? 60_000,
171
+ requestApproval: config.autoApproveAsk
172
+ ? async () => true
173
+ : async (request) => {
174
+ logger.info?.(`[device-fleet] approval required from ${request.fromDeviceId} for ${request.capability}/${String(request.tool)} (no approval UI yet)`)
175
+ return false
176
+ },
177
+ })
178
+ : null
179
+
180
+ node.onRequest(async (request) => {
181
+ if (!host) throw Object.assign(new Error('this device cannot serve direct calls (no execution slot)'), { code: 'unsupported' })
182
+ return host.handleRequest(request)
183
+ })
184
+ node.on('roster', (roster) => {
185
+ try {
186
+ saveRoster(state, roster)
187
+ } catch (error) {
188
+ logger.warn?.(`[device-fleet] cannot persist roster: ${String(error?.message ?? error)}`)
189
+ }
190
+ })
191
+ node.on('warning', (error) => logger.warn?.(`[device-fleet] ${String(error?.message ?? error)}`))
192
+
193
+ const ready = node.start().catch((error) => {
194
+ logger.error?.(`[device-fleet] cannot start network layer: ${String(error?.message ?? error)}`)
195
+ })
196
+
197
+ // Bind the local account fingerprint when the account service is composed.
198
+ const account = service('deepseekAccount')
199
+ if (account && typeof account.getDeviceIdentity === 'function') {
200
+ void account
201
+ .getDeviceIdentity()
202
+ .then((identity) => {
203
+ if (!identity?.userId) return
204
+ const fingerprint = accountFingerprint(state.fleet.fleetKey, identity.userId)
205
+ if (!accountMatches(state, fingerprint)) {
206
+ logger.error?.('[device-fleet] signed-in account does not match the Fleet invite; connections will be refused')
207
+ node.accountFingerprint = 'mismatch'
208
+ return
209
+ }
210
+ node.accountFingerprint = fingerprint
211
+ })
212
+ .catch(() => {})
213
+ }
214
+
215
+ const client = new DirectCallClient({ node })
216
+ const pairing = {
217
+ async invite() {
218
+ const fingerprint =
219
+ node.accountFingerprint && node.accountFingerprint !== 'mismatch'
220
+ ? node.accountFingerprint
221
+ : (state.enrollment?.accountFingerprint ?? null)
222
+ return createInvite(state, {
223
+ accountFingerprint: fingerprint,
224
+ relay: config.relay ?? state.enrollment?.relay ?? null,
225
+ })
226
+ },
227
+ async join(code) {
228
+ writePendingEnrollment(state, code)
229
+ },
230
+ }
231
+ const handlers = createFleetHandlers({ node, client, pairing })
232
+ const definitions = createFleetToolDefinitions({ defineTool, handlers })
233
+ const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
234
+ for (const definition of wrapped) ctx.tools.register(definition)
235
+
236
+ // Adoption layer 2: one sentence, only when the Fleet has members.
237
+ const systemPrompt = service('systemPrompt')
238
+ if (systemPrompt && config.promptSection !== false) {
239
+ const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
240
+ ctx.effect(() =>
241
+ systemPrompt.section({
242
+ name: 'device-fleet:members',
243
+ order,
244
+ text: () => {
245
+ const members = node.rosterEntries().length
246
+ if (members === 0) return ''
247
+ 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.`
248
+ },
249
+ }),
250
+ )
251
+ }
252
+
253
+ // Adoption layer 4: one-line nudge when the model starts orchestrating.
254
+ ctx.on?.('tools/post-execute', async (exec, _result, next) => {
255
+ const decision = await next()
256
+ const signal = exec?.name
257
+ if (signal !== 'todo_write' && !String(signal).startsWith('subagent') && signal !== 'workflow' && signal !== 'ralph') return decision
258
+ if (node.rosterEntries().length === 0) return decision
259
+ const sessionId = exec?.agent?.session?.id
260
+ if (typeof sessionId !== 'string' || manualDelivered.has(sessionId)) return decision
261
+ manualDelivered.add(sessionId)
262
+ return {
263
+ ...decision,
264
+ additionalContexts: [
265
+ ...(decision.additionalContexts ?? []),
266
+ createUserMessage({
267
+ content: [
268
+ {
269
+ type: 'text',
270
+ 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.',
271
+ },
272
+ ],
273
+ source: { kind: 'device-fleet', form: 'instructions' },
274
+ }),
275
+ ],
276
+ }
277
+ })
278
+
279
+ ctx.effect(() => () => {
280
+ void node.close()
281
+ })
282
+
283
+ logger.info?.(`[device-fleet] v${pluginVersion} device ${state.identity.deviceId} (${config.deviceName})`)
284
+ void ready
285
+ }