dsh-device-fleet 0.1.0-dev.8 → 0.1.0-dev.9

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/README.md CHANGED
@@ -8,7 +8,8 @@ DeepSeek Harness 的设备联邦插件:把登录同一账号、且都安装本
8
8
  - ✅ **网络层 v2**:自建 Relay(fleetKey 鉴权的信令 + TCP 拼接数据面,不做 NAT 打洞)
9
9
  - ✅ **Host 插件 + 直调 MVP(代码完成、单测通过,待真实 DSH 安装联调)**:`fleet_list` / `fleet_tools` / `fleet_call` + `fleet_read` / `fleet_fetch` / `fleet_run`、Tool Policy、Execution Slot、审批 fail-closed
10
10
  - ✅ **审批收件箱逻辑层(dev.7)**:`fleet_approvals` / `fleet_approve` / `fleet_deny`,ask 档排队、超时 fail-closed、once/ttl 授权、`fleet_simulate_request` 本机演练;Client 审批面板待做
11
- - ⏳ Room / Room Board、委派升级 Room、审批面板/设备页 UI(下一步)
11
+ - ✅ **Room 逻辑层(dev.8,单机模拟)**:`room_create` / `room_add_local` / `room_send` / `room_messages` / `room_status` / `room_task_*` / `room_close`;本地真实 agent 参与者、寻址唤醒、Room Board 串行交接;单机仿真验证通过
12
+ - ⏳ Client 审批面板 / 房间视图、远程参与者传输、插件重载 rehydrate(下一步)
12
13
 
13
14
  ## 安装(开发版)
14
15
 
package/client.js ADDED
@@ -0,0 +1,167 @@
1
+ // Fleet approval panel (Client half).
2
+ // A floating button under shell.overlay polls the Host route registered by
3
+ // src/host/approval-web.js. Decisions go through ApprovalInbox on the Host.
4
+ window.__ModuleLoader__.load({
5
+ id: 'dsh-device-fleet',
6
+ factory(require) {
7
+ const React = require('react');
8
+ const h = React.createElement;
9
+ const { useEffect, useState } = React;
10
+
11
+ const POLL_MS = 2500;
12
+ const panelStyle = {
13
+ position: 'fixed',
14
+ right: 16,
15
+ bottom: 72,
16
+ width: 360,
17
+ maxHeight: '65vh',
18
+ overflow: 'auto',
19
+ zIndex: 900,
20
+ background: 'var(--dsw-alias-surface-raised, #1f1f1f)',
21
+ color: 'var(--dsw-alias-text, #e8e8e8)',
22
+ border: '1px solid var(--dsw-alias-border, #3a3a3a)',
23
+ borderRadius: 10,
24
+ boxShadow: '0 12px 32px rgba(0,0,0,.35)',
25
+ padding: 12,
26
+ font: '13px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif',
27
+ };
28
+ const buttonStyle = {
29
+ position: 'fixed',
30
+ right: 16,
31
+ bottom: 16,
32
+ zIndex: 900,
33
+ display: 'flex',
34
+ alignItems: 'center',
35
+ gap: 8,
36
+ padding: '8px 12px',
37
+ borderRadius: 999,
38
+ border: '1px solid var(--dsw-alias-border, #3a3a3a)',
39
+ background: 'var(--dsw-alias-surface-raised, #1f1f1f)',
40
+ color: 'var(--dsw-alias-text, #e8e8e8)',
41
+ cursor: 'pointer',
42
+ boxShadow: '0 6px 18px rgba(0,0,0,.3)',
43
+ font: '13px system-ui, -apple-system, "Segoe UI", sans-serif',
44
+ };
45
+
46
+ function ActionButton({ children, onClick, tone = 'normal', disabled = false }) {
47
+ const colors = {
48
+ normal: { bg: 'var(--dsw-alias-surface, transparent)', border: 'var(--dsw-alias-border, #3a3a3a)' },
49
+ allow: { bg: 'var(--dsw-alias-accent, #2f6fed)', border: 'var(--dsw-alias-accent, #2f6fed)' },
50
+ deny: { bg: 'transparent', border: 'var(--dsw-alias-border, #3a3a3a)' },
51
+ };
52
+ const theme = colors[tone] ?? colors.normal;
53
+ return h(
54
+ 'button',
55
+ {
56
+ type: 'button',
57
+ disabled,
58
+ onClick,
59
+ style: {
60
+ padding: '3px 9px',
61
+ borderRadius: 6,
62
+ border: '1px solid ' + theme.border,
63
+ background: theme.bg,
64
+ color: 'var(--dsw-alias-text, #e8e8e8)',
65
+ cursor: disabled ? 'default' : 'pointer',
66
+ opacity: disabled ? 0.55 : 1,
67
+ fontSize: 12,
68
+ },
69
+ },
70
+ children,
71
+ );
72
+ }
73
+
74
+ function ApprovalPanel() {
75
+ const [items, setItems] = useState([]);
76
+ const [open, setOpen] = useState(false);
77
+ const [busy, setBusy] = useState({});
78
+
79
+ useEffect(() => {
80
+ let disposed = false;
81
+ let timer;
82
+ const poll = async () => {
83
+ try {
84
+ const response = await fetch('/device-fleet/approvals', { headers: { accept: 'application/json' } });
85
+ if (!response.ok) throw new Error('HTTP ' + response.status);
86
+ const data = await response.json();
87
+ if (!disposed) setItems(Array.isArray(data.approvals) ? data.approvals : []);
88
+ } catch {
89
+ if (!disposed) setItems([]);
90
+ }
91
+ if (!disposed) timer = setTimeout(poll, POLL_MS);
92
+ };
93
+ poll();
94
+ return () => {
95
+ disposed = true;
96
+ clearTimeout(timer);
97
+ };
98
+ }, []);
99
+
100
+ const decide = async (id, action, scope) => {
101
+ setBusy((current) => ({ ...current, [id]: true }));
102
+ try {
103
+ const response = await fetch('/device-fleet/approvals/decide', {
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/json' },
106
+ body: JSON.stringify({ id, action, ...(scope ? { scope } : {}) }),
107
+ });
108
+ if (response.ok) setItems((current) => current.filter((item) => item.id !== id));
109
+ } finally {
110
+ setBusy((current) => ({ ...current, [id]: false }));
111
+ }
112
+ };
113
+
114
+ const label = items.length > 0 ? 'Fleet 审批 (' + items.length + ')' : 'Fleet 审批';
115
+ return h(
116
+ 'div',
117
+ null,
118
+ open
119
+ ? h(
120
+ 'div',
121
+ { style: panelStyle, role: 'dialog', 'aria-label': 'Fleet 待审批请求' },
122
+ h(
123
+ 'div',
124
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 } },
125
+ h('strong', null, '待审批请求'),
126
+ h('button', { type: 'button', onClick: () => setOpen(false), style: { ...buttonStyle, position: 'static', boxShadow: 'none', padding: '3px 9px', fontSize: 12 } }, '收起'),
127
+ ),
128
+ items.length === 0
129
+ ? h('div', { style: { opacity: 0.7 } }, '暂无待审批请求')
130
+ : items.map((item) =>
131
+ h(
132
+ 'div',
133
+ { key: item.id, style: { borderTop: '1px solid var(--dsw-alias-border, #333)', padding: '8px 0' } },
134
+ h('div', { style: { fontWeight: 600 } }, (item.capability || 'tool') + (item.tool ? ' / ' + item.tool : '')),
135
+ h('div', { style: { opacity: 0.75, fontSize: 12 } }, '来自 ' + item.fromDeviceId + ' · ' + Math.max(0, Math.round(item.expiresInMs / 1000)) + 's 后超时'),
136
+ item.reason ? h('div', { style: { opacity: 0.75, fontSize: 12, marginTop: 2 } }, item.reason) : null,
137
+ h('div', { style: { fontFamily: 'ui-monospace, monospace', fontSize: 11, margin: '4px 0', opacity: 0.8, wordBreak: 'break-all' } }, item.argsSummary),
138
+ h(
139
+ 'div',
140
+ { style: { display: 'flex', gap: 6, marginTop: 4 } },
141
+ h(ActionButton, { tone: 'allow', onClick: () => decide(item.id, 'approve') }, busy[item.id] ? '处理中…' : '允许一次'),
142
+ h(ActionButton, { tone: 'allow', onClick: () => decide(item.id, 'approve', 'ttl') }, '允许 15 分钟'),
143
+ h(ActionButton, { tone: 'deny', onClick: () => decide(item.id, 'deny') }, '拒绝'),
144
+ ),
145
+ ),
146
+ ),
147
+ )
148
+ : null,
149
+ h(
150
+ 'button',
151
+ { type: 'button', style: { ...buttonStyle, borderColor: items.length > 0 ? 'var(--dsw-alias-accent, #2f6fed)' : undefined }, onClick: () => setOpen((value) => !value) },
152
+ h('span', { 'aria-hidden': true }, '🛰️'),
153
+ h('span', null, label),
154
+ ),
155
+ );
156
+ }
157
+
158
+ return {
159
+ inject: ['slots'],
160
+ apply(ctx) {
161
+ ctx.slots.inject('shell.overlay', () =>
162
+ ctx.slots.register({ name: 'shell.overlay', id: 'fleet-approvals', order: 10 }, ApprovalPanel),
163
+ );
164
+ },
165
+ };
166
+ },
167
+ });
@@ -39,15 +39,23 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
39
39
  - 邀请里新增 `endpoints`(创建者的局域网/热点 IPv4 + direct 端口),加入方重启后自动向这些地址回连,绕开组播发现失败;
40
40
  - 新增 `fleet_status`(deviceId/直连端口/发现/连接/roster/hints)与 `fleet_connect(host, port)`(手动直连,成功后会从握手学到对端 deviceId);
41
41
  - `fleet_list` 仍是本地视图;配对成功后双方 roster 会自动固定对端公钥。
42
- ## 0.1.0-dev.7(稳定化)
42
+ ## 0.1.0-dev.8(稳定化)
43
43
 
44
44
  - 直连端口可固定(profile `direct.port`),插件重载不再换端口;
45
45
  - 连接断开后自动重试已知地址(不再只在启动时试一次);
46
46
  - `fleet_connect` 手动地址会持久化到 `peer-hints.json`,重启后继续回连;
47
47
  - 新增 `fleet_delegate`(dev.5):目标设备用 `SubagentProvider` + `delivery:'caller'` 跑 agent 并返回最终结果。
48
- ## 0.1.0-dev.7(审批收件箱,逻辑层)
48
+ ## 0.1.0-dev.8(审批收件箱,逻辑层)
49
49
 
50
50
  - 新增 `ApprovalInbox`:ask 档请求排队、超时 fail-closed、approve(once/ttl)/deny、关闭时全部失败关闭;
51
51
  - 直调与委派的 ask 档改为等待本机收件箱结果(不再一律拒绝);
52
52
  - 新工具:`fleet_approvals` / `fleet_approve` / `fleet_deny`;`fleet_simulate_request`(仅 `approvals.simulation: true`)用于本机无第二台设备时演练流程;
53
- - 审批面板(Client UI)与通知尚未实现,当前通过工具/后续 UI 应答。
53
+ - 审批面板(Client UI)与通知尚未实现,当前通过工具/后续 UI 应答。
54
+ ## 0.1.0-dev.8(Room 逻辑层,本机模拟)
55
+
56
+ - `RoomService`:当前会话升级为 Room Host;本地模拟参与者(真实 DSH session/agent);
57
+ - 寻址消息:`room_send` 只唤醒被点名成员,Host 会话写权威 `room/message` 事件;
58
+ - 可见性:`room_messages` 提供完整 transcript;未点名成员不运行;
59
+ - Room Board:`room_task_add/update/list`,`dependsOn` 串行交接(前置完成时唤醒下一个 owner),无依赖任务并行不受影响;
60
+ - 工具:`room_create / room_add_local / room_remove_member / room_send / room_messages / room_status / room_task_* / room_close`;
61
+ - 远程参与者传输与 Client UI 未做(下一步)。
@@ -0,0 +1,31 @@
1
+ # Room 逻辑层实测(2026-11-09,dev.8,单机模拟参与者)
2
+
3
+ 在当前这台 DSH 内,用两个**真实本地 session/agent** 充当 Room 参与者,验证寻址唤醒与看板串行交接,不依赖第二台设备。
4
+
5
+ ## 流程与结果
6
+
7
+ 1. `room_create` → roomId = 当前会话 `session-269260c9-…`,host 成员 `host:session-269260c9-…`。
8
+ 2. `room_add_local(name="worker-a", cwd=E:/Dsh_test)` → 真实 session `session-d063026e-…`。
9
+ 3. `room_send(to=[worker-a], text="读取 hello-from-A.txt 并用 room_send 回复 host")` → `woke worker-a`;
10
+ - worker-a 的 agent 真实跑了一轮模型 + 本机 `read`,并在 3.4s 内调用 `room_send` 回传 `hello from device A (fleet test)`;
11
+ - transcript 两条消息,完整可见。
12
+ 4. `room_add_local(name="worker-b")`;看板:
13
+ - taskA `读取 hello-from-A.txt` owner=worker-a;
14
+ - taskB `汇总读取结果` owner=worker-b,`dependsOn=[taskA]`;
15
+ - taskC `并行:独立检查` owner=worker-a,无依赖。
16
+ 5. `room_task_update(taskA, done)` → 返回 `handed off to worker-b`;worker-b 被点名唤醒,30s 内完成汇总并 `room_send` 给 host(内容含前序结果、交叉校验、并行任务仍 pending 的说明),同时把自己 taskB 置为 running。
17
+ 6. `room_task_list`:taskA done、taskB running、taskC pending(并行任务未被串行交接影响)。
18
+ 7. `room_close`。
19
+
20
+ ## 结论
21
+
22
+ - Room 生命周期、成员管理、寻址消息、未点名不唤醒、host 权威日志:单机逻辑通过;
23
+ - Room Board `dependsOn` 串行交接 / 并行任务不阻塞:真实 agent 验证通过;
24
+ - 参与者使用的都是真实 DSH agent(各自 session),不是 mock。
25
+
26
+ ## 限制(下一步)
27
+
28
+ - 远程参与者传输(把 local participant 换成远端设备成员)尚未接入;
29
+ - Client 面板/房间视图未做(用户无法在 GUI 直接看 room 时间线/看板);
30
+ - 房间状态在插件内存 + host session 事件里,插件重载后需要 rehydrate(未实现);
31
+ - 房间策略仅存储,尚未强制(invite/join/maxMembers 中 maxMembers 已生效)。
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.8",
3
+ "version": "0.1.0-dev.9",
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",
7
7
  "exports": {
8
8
  ".": "./src/plugin/index.js",
9
+ "./client": "./client.js",
9
10
  "./net": "./src/net/index.js",
10
11
  "./host": "./src/host/index.js",
11
12
  "./package.json": "./package.json"
@@ -16,6 +17,13 @@
16
17
  "dsh": {
17
18
  "bundle": {
18
19
  "patch": "./cordis.patch.yml"
20
+ },
21
+ "client": {
22
+ "platform": "web",
23
+ "immediately": true,
24
+ "inject": [
25
+ "@deepseek-ai/dsh-client-runtime"
26
+ ]
19
27
  }
20
28
  },
21
29
  "peerDependencies": {
@@ -31,6 +39,7 @@
31
39
  },
32
40
  "files": [
33
41
  "src",
42
+ "client.js",
34
43
  "cordis.patch.yml",
35
44
  "README.md",
36
45
  "CONTEXT.md",
@@ -0,0 +1,95 @@
1
+ // Local approval HTTP surface for the approval panel (dev.9). The page is
2
+ // served by the DSH web carrier, so browser fetch carries the session cookie.
3
+ // All decisions go through ApprovalInbox; nothing here bypasses Tool Policy.
4
+ const MAX_BODY_BYTES = 64 * 1024
5
+
6
+ function json(res, status, value) {
7
+ const text = JSON.stringify(value)
8
+ res.statusCode = status
9
+ res.setHeader('content-type', 'application/json; charset=utf-8')
10
+ res.setHeader('cache-control', 'no-store')
11
+ res.end(text)
12
+ }
13
+
14
+ async function readJsonBody(req) {
15
+ const chunks = []
16
+ let size = 0
17
+ for await (const chunk of req) {
18
+ size += chunk.length
19
+ if (size > MAX_BODY_BYTES) throw Object.assign(new Error('request body is too large'), { code: 'body-too-large' })
20
+ chunks.push(chunk)
21
+ }
22
+ if (chunks.length === 0) return {}
23
+ try {
24
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
25
+ } catch {
26
+ throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
27
+ }
28
+ }
29
+
30
+ /** HTTP-free decision helper shared by the routes and unit tests. */
31
+ export function createApprovalApi({ inbox }) {
32
+ return {
33
+ list() {
34
+ return { approvals: inbox.list() }
35
+ },
36
+ decide(body = {}) {
37
+ const { id, action } = body
38
+ if (typeof id !== 'string' || id === '') {
39
+ throw Object.assign(new Error('id is required'), { code: 'bad-request' })
40
+ }
41
+ if (action === 'approve') {
42
+ const scope = body.scope === 'ttl' ? 'ttl' : 'once'
43
+ return { ok: true, result: inbox.approve(id, { scope }) }
44
+ }
45
+ if (action === 'deny') {
46
+ return { ok: true, result: inbox.deny(id, typeof body.reason === 'string' ? body.reason : 'denied by local user') }
47
+ }
48
+ throw Object.assign(new Error('action must be approve or deny'), { code: 'bad-request' })
49
+ },
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Register the approval routes on the DSH web carrier.
55
+ * @returns {() => void} disposer
56
+ */
57
+ export function registerApprovalRoutes({ webServer, inbox, logger = null }) {
58
+ if (!webServer?.register || !inbox) return () => {}
59
+ const api = createApprovalApi({ inbox })
60
+ const disposers = []
61
+ disposers.push(
62
+ webServer.register({
63
+ kind: 'exact',
64
+ path: '/device-fleet/approvals',
65
+ handler: async (req, res) => {
66
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
67
+ try {
68
+ json(res, 200, api.list())
69
+ } catch (error) {
70
+ logger?.warn?.(`[device-fleet] approval list failed: ${String(error?.message ?? error)}`)
71
+ json(res, 500, { error: 'internal error' })
72
+ }
73
+ },
74
+ }),
75
+ )
76
+ disposers.push(
77
+ webServer.register({
78
+ kind: 'exact',
79
+ path: '/device-fleet/approvals/decide',
80
+ handler: async (req, res) => {
81
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
82
+ try {
83
+ const body = await readJsonBody(req)
84
+ json(res, 200, api.decide(body))
85
+ } catch (error) {
86
+ const status = ['bad-request', 'bad-json', 'body-too-large', 'unknown-request'].includes(error?.code) ? 400 : 500
87
+ json(res, status, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
88
+ }
89
+ },
90
+ }),
91
+ )
92
+ return () => {
93
+ for (const dispose of disposers) dispose()
94
+ }
95
+ }
package/src/host/index.js CHANGED
@@ -5,5 +5,6 @@ export * from './execution-slot.js'
5
5
  export * from './direct-call.js'
6
6
  export * from './delegation.js'
7
7
  export * from './approval-inbox.js'
8
+ export * from './approval-web.js'
8
9
  export * from './room-service.js'
9
10
  export * from './tools.js'
@@ -16,6 +16,7 @@ import { DirectCallClient, DirectCallHost } from '../host/direct-call.js'
16
16
  import { DelegationHost } from '../host/delegation.js'
17
17
  import { ApprovalInbox } from '../host/approval-inbox.js'
18
18
  import { RoomService } from '../host/room-service.js'
19
+ import { registerApprovalRoutes } from '../host/approval-web.js'
19
20
  import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
20
21
  import { ToolPolicy } from '../host/tool-policy.js'
21
22
  import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
@@ -197,6 +198,13 @@ export function apply(ctx, config) {
197
198
  : async (request) => inbox.request(request)
198
199
 
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
+ }
200
208
  if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
201
209
  const slot = sessionController
202
210
  ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })