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

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 (50) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +95 -2
  4. package/cordis.patch.yml +24 -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/dsh-global-task-list-evaluation.md +54 -0
  31. package/docs/notes/network-v1-v2-implementation.md +71 -0
  32. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  33. package/package.json +45 -6
  34. package/src/host/direct-call.js +190 -0
  35. package/src/host/execution-slot.js +82 -0
  36. package/src/host/index.js +6 -0
  37. package/src/host/store.js +128 -0
  38. package/src/host/tool-policy.js +150 -0
  39. package/src/host/tools.js +215 -0
  40. package/src/index.js +3 -0
  41. package/src/net/direct.js +69 -0
  42. package/src/net/discovery.js +105 -0
  43. package/src/net/fleet-node.js +414 -0
  44. package/src/net/identity.js +156 -0
  45. package/src/net/index.js +9 -0
  46. package/src/net/protocol.js +73 -0
  47. package/src/net/relay-client.js +258 -0
  48. package/src/net/relay-server.js +366 -0
  49. package/src/net/secure-channel.js +538 -0
  50. package/src/plugin/index.js +276 -0
@@ -0,0 +1,276 @@
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
+ const logger = ctx.logger ?? console
119
+
120
+ let state
121
+ try {
122
+ state = loadState({ dshHome: process.env.DSH_HOME, dataDir: config.dataDir, name: config.deviceName })
123
+ } catch (error) {
124
+ logger.error?.(`[device-fleet] cannot load state: ${String(error?.message ?? error)}`)
125
+ return
126
+ }
127
+
128
+ const node = new FleetNode({
129
+ identity: state.identity,
130
+ fleet: state.fleet,
131
+ name: config.deviceName,
132
+ accountFingerprint: state.enrollment?.accountFingerprint ?? null,
133
+ direct: config.direct ?? { host: '127.0.0.1', port: 0 },
134
+ discovery: config.discovery ? true : false,
135
+ relay: config.relay ?? state.enrollment?.relay ?? null,
136
+ roster: state.roster,
137
+ pluginVersion,
138
+ dshVersion,
139
+ capabilities: CAPABILITIES,
140
+ })
141
+
142
+ const policy = new ToolPolicy(config.policy)
143
+ const sessionController = ctx.sessionController ?? ctx.get?.('sessionController')
144
+ const fs = ctx.fs ?? ctx.get?.('fs')
145
+ if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
146
+ const slot = sessionController
147
+ ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
148
+ : null
149
+
150
+ const host = slot
151
+ ? new DirectCallHost({
152
+ slot,
153
+ policy,
154
+ fs,
155
+ deviceId: state.identity.deviceId,
156
+ name: config.deviceName,
157
+ pluginVersion,
158
+ dshVersion,
159
+ capabilities: CAPABILITIES,
160
+ shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
161
+ 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
+ },
168
+ })
169
+ : null
170
+
171
+ node.onRequest(async (request) => {
172
+ if (!host) throw Object.assign(new Error('this device cannot serve direct calls (no execution slot)'), { code: 'unsupported' })
173
+ return host.handleRequest(request)
174
+ })
175
+ node.on('roster', (roster) => {
176
+ try {
177
+ saveRoster(state, roster)
178
+ } catch (error) {
179
+ logger.warn?.(`[device-fleet] cannot persist roster: ${String(error?.message ?? error)}`)
180
+ }
181
+ })
182
+ node.on('warning', (error) => logger.warn?.(`[device-fleet] ${String(error?.message ?? error)}`))
183
+
184
+ const ready = node.start().catch((error) => {
185
+ logger.error?.(`[device-fleet] cannot start network layer: ${String(error?.message ?? error)}`)
186
+ })
187
+
188
+ // Bind the local account fingerprint when the account service is composed.
189
+ const account = ctx.get?.('deepseekAccount')
190
+ if (account && typeof account.getDeviceIdentity === 'function') {
191
+ void account
192
+ .getDeviceIdentity()
193
+ .then((identity) => {
194
+ if (!identity?.userId) return
195
+ const fingerprint = accountFingerprint(state.fleet.fleetKey, identity.userId)
196
+ if (!accountMatches(state, fingerprint)) {
197
+ logger.error?.('[device-fleet] signed-in account does not match the Fleet invite; connections will be refused')
198
+ node.accountFingerprint = 'mismatch'
199
+ return
200
+ }
201
+ node.accountFingerprint = fingerprint
202
+ })
203
+ .catch(() => {})
204
+ }
205
+
206
+ const client = new DirectCallClient({ node })
207
+ const pairing = {
208
+ async invite() {
209
+ const fingerprint =
210
+ node.accountFingerprint && node.accountFingerprint !== 'mismatch'
211
+ ? node.accountFingerprint
212
+ : (state.enrollment?.accountFingerprint ?? null)
213
+ return createInvite(state, {
214
+ accountFingerprint: fingerprint,
215
+ relay: config.relay ?? state.enrollment?.relay ?? null,
216
+ })
217
+ },
218
+ async join(code) {
219
+ writePendingEnrollment(state, code)
220
+ },
221
+ }
222
+ const handlers = createFleetHandlers({ node, client, pairing })
223
+ const definitions = createFleetToolDefinitions({ defineTool, handlers })
224
+ const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
225
+ for (const definition of wrapped) ctx.tools.register(definition)
226
+
227
+ // Adoption layer 2: one sentence, only when the Fleet has members.
228
+ const systemPrompt = ctx.get?.('systemPrompt') ?? ctx.systemPrompt
229
+ if (systemPrompt && config.promptSection !== false) {
230
+ const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
231
+ ctx.effect(() =>
232
+ systemPrompt.section({
233
+ name: 'device-fleet:members',
234
+ order,
235
+ text: () => {
236
+ const members = node.rosterEntries().length
237
+ if (members === 0) return ''
238
+ 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.`
239
+ },
240
+ }),
241
+ )
242
+ }
243
+
244
+ // Adoption layer 4: one-line nudge when the model starts orchestrating.
245
+ ctx.on?.('tools/post-execute', async (exec, _result, next) => {
246
+ const decision = await next()
247
+ const signal = exec?.name
248
+ if (signal !== 'todo_write' && !String(signal).startsWith('subagent') && signal !== 'workflow' && signal !== 'ralph') return decision
249
+ if (node.rosterEntries().length === 0) return decision
250
+ const sessionId = exec?.agent?.session?.id
251
+ if (typeof sessionId !== 'string' || manualDelivered.has(sessionId)) return decision
252
+ manualDelivered.add(sessionId)
253
+ return {
254
+ ...decision,
255
+ additionalContexts: [
256
+ ...(decision.additionalContexts ?? []),
257
+ createUserMessage({
258
+ content: [
259
+ {
260
+ type: 'text',
261
+ 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.',
262
+ },
263
+ ],
264
+ source: { kind: 'device-fleet', form: 'instructions' },
265
+ }),
266
+ ],
267
+ }
268
+ })
269
+
270
+ ctx.effect(() => () => {
271
+ void node.close()
272
+ })
273
+
274
+ ctx.logger?.info?.(`[device-fleet] v${pluginVersion} device ${state.identity.deviceId} (${config.deviceName})`)
275
+ void ready
276
+ }