dsh-device-fleet 0.1.0-dev.11 → 0.1.0-dev.13

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
@@ -9,7 +9,7 @@ DeepSeek Harness 的设备联邦插件:把登录同一账号、且都安装本
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
11
  - ✅ **Room 逻辑层(dev.8,单机模拟)**:`room_create` / `room_add_local` / `room_send` / `room_messages` / `room_status` / `room_task_*` / `room_close`;本地真实 agent 参与者、寻址唤醒、Room Board 串行交接;单机仿真验证通过
12
- - ✅ **审批面板 UI(dev.10)**:Host `/device-fleet/approvals` HTTP 数据面 + Client `shell.overlay` 浮动审批按钮/面板(允许一次/15 分钟/拒绝);单测 14/14;刷新页面后可见
12
+ - ✅ **审批面板 UI(dev.12,已验证)**:Host `/device-fleet/approvals` HTTP 数据面 + Client 入口在 Settings 上方、居中审批对话框(允许一次/15 分钟/拒绝);用户点击「允许」后 Host 列表清空,端到端闭环
13
13
  - ⏳ 远程参与者传输、房间视图、插件重载 rehydrate、SSE 推送(下一步)
14
14
 
15
15
  ## 安装(开发版)
package/client.js CHANGED
@@ -148,8 +148,8 @@ window.__ModuleLoader__.load({
148
148
 
149
149
  function Panel() {
150
150
  const current = useStore();
151
- if (!current.open) return null;
152
151
  useEffect(() => {
152
+ if (!current.open) return undefined;
153
153
  const onKey = (event) => {
154
154
  if (event.key === 'Escape') {
155
155
  store.open = false;
@@ -158,7 +158,8 @@ window.__ModuleLoader__.load({
158
158
  };
159
159
  window.addEventListener('keydown', onKey);
160
160
  return () => window.removeEventListener('keydown', onKey);
161
- }, []);
161
+ }, [current.open]);
162
+ if (!current.open) return null;
162
163
  return h(
163
164
  'div',
164
165
  {
@@ -212,6 +213,294 @@ window.__ModuleLoader__.load({
212
213
  );
213
214
  }
214
215
 
216
+ const cardStyle = {
217
+ border: '1px solid var(--dsw-alias-border, #3a3a3a)',
218
+ borderRadius: 10,
219
+ padding: 12,
220
+ background: 'var(--dsw-alias-surface-raised, #1f1f1f)',
221
+ marginBottom: 10,
222
+ };
223
+ const sectionTitle = { fontSize: 12, opacity: 0.65, textTransform: 'uppercase', letterSpacing: '.04em', marginBottom: 6 };
224
+ const pill = (text, tone) =>
225
+ h(
226
+ 'span',
227
+ {
228
+ style: {
229
+ display: 'inline-block',
230
+ padding: '1px 7px',
231
+ borderRadius: 999,
232
+ fontSize: 11,
233
+ border: '1px solid ' + (tone === 'ok' ? 'var(--dsw-alias-accent, #2f6fed)' : 'var(--dsw-alias-border, #3a3a3a)'),
234
+ color: tone === 'ok' ? 'var(--dsw-alias-accent, #2f6fed)' : 'inherit',
235
+ opacity: tone === 'muted' ? 0.7 : 1,
236
+ marginRight: 6,
237
+ },
238
+ },
239
+ text,
240
+ );
241
+
242
+ const fmtTime = (value) => {
243
+ try {
244
+ return new Date(value).toLocaleTimeString();
245
+ } catch {
246
+ return '';
247
+ }
248
+ };
249
+
250
+ function FleetIcon({ size = 20, active = false }) {
251
+ return h(
252
+ 'svg',
253
+ {
254
+ width: size,
255
+ height: size,
256
+ viewBox: '0 0 24 24',
257
+ fill: 'none',
258
+ stroke: 'currentColor',
259
+ strokeWidth: 1.8,
260
+ strokeLinecap: 'round',
261
+ 'aria-hidden': true,
262
+ style: { opacity: active ? 1 : 0.75 },
263
+ },
264
+ h('circle', { cx: 12, cy: 12, r: 8.5 }),
265
+ h('ellipse', { cx: 12, cy: 12, rx: 3.6, ry: 8.5 }),
266
+ h('path', { d: 'M3.8 12h16.4' }),
267
+ );
268
+ }
269
+
270
+ function FleetConsole() {
271
+ const [tab, setTab] = useState('devices');
272
+ const [overview, setOverview] = useState(null);
273
+ const [rooms, setRooms] = useState([]);
274
+ const [selectedRoom, setSelectedRoom] = useState(null);
275
+ const [roomDetail, setRoomDetail] = useState(null);
276
+ const [error, setError] = useState('');
277
+
278
+ useEffect(() => {
279
+ let disposed = false;
280
+ const tick = async () => {
281
+ try {
282
+ const response = await fetch('/device-fleet/overview', { headers: { accept: 'application/json' } });
283
+ if (!response.ok) throw new Error('HTTP ' + response.status);
284
+ const data = await response.json();
285
+ if (!disposed) {
286
+ setOverview(data);
287
+ setRooms(Array.isArray(data.rooms) ? data.rooms : []);
288
+ setError('');
289
+ }
290
+ } catch (problem) {
291
+ if (!disposed) setError(String(problem.message ?? problem));
292
+ }
293
+ };
294
+ tick();
295
+ const timer = setInterval(tick, 3000);
296
+ return () => {
297
+ disposed = true;
298
+ clearInterval(timer);
299
+ };
300
+ }, []);
301
+
302
+ useEffect(() => {
303
+ if (!selectedRoom) {
304
+ setRoomDetail(null);
305
+ return undefined;
306
+ }
307
+ let disposed = false;
308
+ const load = async () => {
309
+ try {
310
+ const response = await fetch('/device-fleet/rooms/' + encodeURIComponent(selectedRoom), { headers: { accept: 'application/json' } });
311
+ if (!response.ok) throw new Error('HTTP ' + response.status);
312
+ const data = await response.json();
313
+ if (!disposed) setRoomDetail(data.room ?? null);
314
+ } catch {
315
+ if (!disposed) setRoomDetail(null);
316
+ }
317
+ };
318
+ load();
319
+ const timer = setInterval(load, 3000);
320
+ return () => {
321
+ disposed = true;
322
+ clearInterval(timer);
323
+ };
324
+ }, [selectedRoom]);
325
+
326
+ const decide = async (id, action, scope) => {
327
+ await fetch('/device-fleet/approvals/decide', {
328
+ method: 'POST',
329
+ headers: { 'content-type': 'application/json' },
330
+ body: JSON.stringify({ id, action, ...(scope ? { scope } : {}) }),
331
+ });
332
+ setOverview((current) => (current ? { ...current, approvals: (current.approvals ?? []).filter((item) => item.id !== id) } : current));
333
+ };
334
+
335
+ const tabs = [
336
+ ['devices', '设备'],
337
+ ['rooms', '房间'],
338
+ ['approvals', '审批'],
339
+ ];
340
+ const header = h(
341
+ 'div',
342
+ { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 } },
343
+ h('strong', { style: { fontSize: 15 } }, '设备联邦'),
344
+ overview ? pill('本机 ' + (overview.name ?? '') + ' · ' + overview.pluginVersion, 'muted') : null,
345
+ h('span', { style: { flex: 1 } }),
346
+ ...tabs.map(([id, label]) =>
347
+ h(
348
+ 'button',
349
+ {
350
+ key: id,
351
+ type: 'button',
352
+ onClick: () => setTab(id),
353
+ style: {
354
+ padding: '4px 10px',
355
+ borderRadius: 999,
356
+ border: '1px solid ' + (tab === id ? 'var(--dsw-alias-accent, #2f6fed)' : 'var(--dsw-alias-border, #3a3a3a)'),
357
+ background: tab === id ? 'var(--dsw-alias-accent, #2f6fed)' : 'transparent',
358
+ color: 'inherit',
359
+ cursor: 'pointer',
360
+ fontSize: 12,
361
+ },
362
+ },
363
+ label + (id === 'approvals' && (overview?.approvals?.length ?? 0) > 0 ? ' ' + overview.approvals.length : ''),
364
+ ),
365
+ ),
366
+ );
367
+
368
+ if (!overview) {
369
+ return h('div', { style: { padding: 16 } }, header, h('div', { style: { opacity: 0.7 } }, error ? '加载失败:' + error : '加载中…'));
370
+ }
371
+
372
+ let body = null;
373
+ if (tab === 'devices') {
374
+ const connected = new Set(Array.isArray(overview.connected) ? overview.connected : []);
375
+ body = h(
376
+ 'div',
377
+ null,
378
+ h('div', { style: cardStyle },
379
+ h('div', { style: sectionTitle }, '本机'),
380
+ h('div', null, overview.name + ' · ' + overview.deviceId),
381
+ h('div', { style: { opacity: 0.75, fontSize: 12, marginTop: 4 } },
382
+ '直连 ' + (overview.direct ? overview.direct.host + ':' + overview.direct.port : '未监听') +
383
+ ' · relay ' + (overview.relay?.online ? '在线' : '离线') +
384
+ ' · 发现 ' + ((overview.discovery?.peers ?? []).length) + ' 个'),
385
+ ),
386
+ h('div', { style: cardStyle },
387
+ h('div', { style: sectionTitle }, 'Fleet 成员(' + (overview.roster?.length ?? 0) + ' 台已配对)'),
388
+ (overview.roster ?? []).length === 0
389
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '还没有配对设备;在任一台设备上生成邀请码完成配对。')
390
+ : (overview.roster ?? []).map((member) =>
391
+ h('div', { key: member.deviceId, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
392
+ h('span', { style: { flex: 1 } }, member.name + ' · ' + member.deviceId.slice(0, 8) + '…'),
393
+ connected.has(member.deviceId) ? pill('在线', 'ok') : pill('离线', 'muted'),
394
+ ),
395
+ ),
396
+ ),
397
+ (overview.peerHints ?? []).length > 0
398
+ ? h('div', { style: cardStyle },
399
+ h('div', { style: sectionTitle }, '回连地址'),
400
+ ...overview.peerHints.map((hint, index) =>
401
+ h('div', { key: index, style: { fontSize: 12, fontFamily: 'ui-monospace, monospace', opacity: 0.8 } }, hint.host + ':' + hint.port),
402
+ ),
403
+ )
404
+ : null,
405
+ );
406
+ } else if (tab === 'rooms') {
407
+ body = h(
408
+ 'div',
409
+ { style: { display: 'flex', gap: 14, alignItems: 'flex-start', flexWrap: 'wrap' } },
410
+ h('div', { style: { flex: '1 1 260px', minWidth: 240 } },
411
+ h('div', { style: sectionTitle }, '房间(' + rooms.length + ')'),
412
+ rooms.length === 0
413
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '还没有房间。让 agent 在当前会话执行 room_create 升级为 Room Host。')
414
+ : rooms.map((room) =>
415
+ h(
416
+ 'button',
417
+ {
418
+ key: room.roomId,
419
+ type: 'button',
420
+ onClick: () => setSelectedRoom(room.roomId === selectedRoom ? null : room.roomId),
421
+ style: {
422
+ ...cardStyle,
423
+ display: 'block',
424
+ width: '100%',
425
+ textAlign: 'left',
426
+ cursor: 'pointer',
427
+ borderColor: selectedRoom === room.roomId ? 'var(--dsw-alias-accent, #2f6fed)' : undefined,
428
+ marginBottom: 8,
429
+ },
430
+ },
431
+ h('div', { style: { fontWeight: 600 } }, 'Room ' + room.roomId.slice(-8)),
432
+ h('div', { style: { fontSize: 12, opacity: 0.7, marginTop: 3 } },
433
+ room.members.length + ' 名成员 · ' + room.messageCount + ' 条消息 · ' + room.tasks + ' 个任务'),
434
+ ),
435
+ ),
436
+ ),
437
+ h('div', { style: { flex: '2 1 420px', minWidth: 320 } }, selectedRoom ? renderRoomDetail(roomDetail) : h('div', { style: { opacity: 0.65, fontSize: 12 } }, '选择一个房间查看成员、时间线与看板。')),
438
+ );
439
+ } else {
440
+ const approvals = overview.approvals ?? [];
441
+ body = h(
442
+ 'div',
443
+ { style: cardStyle },
444
+ h('div', { style: sectionTitle }, '待审批请求(' + approvals.length + ')'),
445
+ approvals.length === 0
446
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无待审批请求')
447
+ : approvals.map((item) =>
448
+ h('div', { key: item.id, style: { borderTop: '1px solid var(--dsw-alias-border, #333)', padding: '9px 0' } },
449
+ h('div', { style: { fontWeight: 600 } }, (item.capability || 'tool') + (item.tool ? ' / ' + item.tool : '')),
450
+ h('div', { style: { opacity: 0.75, fontSize: 12 } }, '来自 ' + item.fromDeviceId + ' · ' + Math.max(0, Math.round(item.expiresInMs / 1000)) + 's 后超时'),
451
+ item.reason ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, item.reason) : null,
452
+ h('div', { style: { display: 'flex', gap: 6, marginTop: 5, flexWrap: 'wrap' } },
453
+ smallButton('允许一次', () => decide(item.id, 'approve'), 'allow'),
454
+ smallButton('允许 15 分钟', () => decide(item.id, 'approve', 'ttl'), 'allow'),
455
+ smallButton('拒绝', () => decide(item.id, 'deny'), 'deny'),
456
+ ),
457
+ ),
458
+ ),
459
+ );
460
+ }
461
+
462
+ return h('div', { style: { padding: '14px 16px', height: '100%', overflow: 'auto', boxSizing: 'border-box' } }, header, body);
463
+ }
464
+
465
+ function renderRoomDetail(detail) {
466
+ if (!detail) return h('div', { style: { opacity: 0.65, fontSize: 12 } }, '加载房间…');
467
+ return h(
468
+ 'div',
469
+ null,
470
+ h('div', { style: cardStyle },
471
+ h('div', { style: sectionTitle }, '成员'),
472
+ ...detail.members.map((member) =>
473
+ h('div', { key: member.memberId, style: { padding: '3px 0', fontSize: 12 } },
474
+ pill(member.role, member.role === 'host' ? 'ok' : 'muted'),
475
+ member.name + ' · ' + member.memberId,
476
+ ),
477
+ ),
478
+ ),
479
+ h('div', { style: cardStyle },
480
+ h('div', { style: sectionTitle }, '看板(' + detail.tasks.length + ')'),
481
+ detail.tasks.length === 0
482
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无任务')
483
+ : detail.tasks.map((task) =>
484
+ h('div', { key: task.id, style: { padding: '4px 0', fontSize: 12 } },
485
+ pill(task.status, task.status === 'done' ? 'ok' : 'muted'),
486
+ task.title + ' · owner ' + task.owner + (task.dependsOn.length ? ' · deps ' + task.dependsOn.length : ''),
487
+ ),
488
+ ),
489
+ ),
490
+ h('div', { style: cardStyle },
491
+ h('div', { style: sectionTitle }, '时间线'),
492
+ detail.messages.length === 0
493
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无消息')
494
+ : detail.messages.map((message) =>
495
+ h('div', { key: message.id, style: { padding: '5px 0', borderTop: '1px solid var(--dsw-alias-border, #333)' } },
496
+ h('div', { style: { fontSize: 11, opacity: 0.6 } }, '[' + fmtTime(message.createdAt) + '] ' + message.fromLabel + ' → ' + message.to.join(', ')),
497
+ h('div', { style: { whiteSpace: 'pre-wrap', marginTop: 2 } }, message.text),
498
+ ),
499
+ ),
500
+ ),
501
+ );
502
+ }
503
+
215
504
  return {
216
505
  inject: ['slots'],
217
506
  apply(ctx) {
@@ -219,9 +508,15 @@ window.__ModuleLoader__.load({
219
508
  ctx.slots.inject('sidebar.footer.action', () =>
220
509
  ctx.slots.register({ name: 'sidebar.footer.action', id: 'fleet-approvals', order: 5, label: 'Fleet 审批' }, Trigger),
221
510
  );
222
- ctx.slots.inject('shell.overlay', () =>
223
- ctx.slots.register({ name: 'shell.overlay', id: 'fleet-approvals-panel', order: 10 }, Panel),
511
+ // shell.overlay is a root slot that exists from boot (the dev.10 build
512
+ // rendered from a direct registration); register directly so the dialog
513
+ // mounts even if the lazy inject callback never fires.
514
+ ctx.slots.register({ name: 'shell.overlay', id: 'fleet-approvals-panel', order: 10 }, Panel);
515
+ // Global Fleet console: sidebar icon + matching main panel.
516
+ ctx.slots.inject('sidebar.panellist', () =>
517
+ ctx.slots.register({ name: 'sidebar.panellist', id: 'fleet', order: 20, label: '设备联邦' }, FleetIcon),
224
518
  );
519
+ ctx.slots.register({ name: 'main', key: 'fleet' }, FleetConsole);
225
520
  },
226
521
  };
227
522
  },
@@ -1,38 +1,38 @@
1
- # 审批面板 UI(dev.9 / dev.10)
1
+ # 审批面板 UI(dev.9 → dev.12)
2
2
 
3
3
  ## Host 数据面
4
4
 
5
5
  `src/host/approval-web.js`:
6
6
 
7
- - `GET /device-fleet/approvals` → `{ approvals: [...] }`(来自 ApprovalInbox.list);
7
+ - `GET /device-fleet/approvals` → `{ approvals: [...] }`(ApprovalInbox.list);
8
8
  - `POST /device-fleet/approvals/decide`,body `{ id, action: "approve"|"deny", scope?, reason? }` → 决策走 ApprovalInbox;
9
- - 仅注册在 DSH web 载体的本地监听上;决策仍受 ApprovalInbox 的超时/关闭/未知 id 约束;
10
- - 纯逻辑 `createApprovalApi` 与路由注册分离,便于单测;路由在插件卸载时 dispose。
9
+ - 路由注册在插件 apply 中、卸载时 dispose;纯逻辑 `createApprovalApi` 与路由分离便于单测。
11
10
 
12
11
  ## Client 半
13
12
 
14
- `client.js`(闭包工厂,`window.__ModuleLoader__.load`,id = `dsh-device-fleet`):
13
+ `client.js`(闭包工厂,id = `dsh-device-fleet`):
15
14
 
16
- - 注册 `shell.overlay`(root)列表项 `fleet-approvals`,右下角浮动按钮「Fleet 审批 (n)」;
17
- - 2.5s 轮询 `/device-fleet/approvals`,有请求时按钮高亮;点开面板显示来源设备/能力/工具/参数摘要/剩余超时;
18
- - 按钮:允许一次 / 允许 15 分钟(ttl)/ 拒绝,POST 到 decide 后从列表移除;
19
- - 使用 `--dsw-alias-*` token(带 fallback),不覆盖宿主根、不读其他插件 DOM。
15
+ - **入口**:`sidebar.footer.action`(Settings 上方),显示 `🛰️ 审批`,有待审批时带数字并高亮;不再占用 `shell.overlay` 右下角,避免与任务列表 UI 重叠;
16
+ - **面板**:`shell.overlay` 直接注册的居中模态对话框(遮罩、Esc/点遮罩/收起关闭);
17
+ - 2.5s 轮询 `/device-fleet/approvals`;按钮「允许一次 / 允许 15 分钟(ttl) / 拒绝」POST 到 decide;
18
+ - 共享模块级 store + `ctx.effect` 启动/清理轮询;使用 `--dsw-alias-*` token(带 fallback)。
20
19
 
21
- `package.json` 增加 `dsh.client = { platform: web, immediately: true, inject: ["@deepseek-ai/dsh-client-runtime"] }` 与 `./client` 导出、`files` 包含 client.js。
20
+ ## 修复记录
22
21
 
23
- ## 验证
22
+ - dev.10:右下角浮动按钮可用,但会遮挡任务列表(用户截图确认);
23
+ - dev.11:入口迁到 sidebar footer;发现点击无反应;
24
+ - dev.12:修复 `Panel` 的条件 Hook 顺序(关闭时提前 return,打开后才调 `useEffect`,导致 React 渲染报错);同时把 `shell.overlay` 改回直接注册(dev.10 已验证可用路径)。
24
25
 
25
- - 单测:`approval HTTP API lists and decides requests, and registers two routes`(列表/决策/路由注册/GET handler);
26
- - Host 单测总计 **14/14** 通过,网络层 9/9;
27
- - Host 路由注册:插件 dev.9/10 激活成功(无重复路由错误);
28
- - Client 视觉:**未验证**(无浏览器控制;需要刷新页面加载 client bundle),`node --check client.js` 通过。
26
+ ## 端到端验证(已完成)
29
27
 
30
- ## 使用
31
-
32
- 刷新当前 Web 页面(F5)后,右下角应出现「Fleet 审批」浮动按钮。用 `fleet_simulate_request` 造一条请求即可看到面板内容;点击允许/拒绝后 Host 端 `fleet_approvals` 同步清空。
28
+ 1. 刷新页面后 Settings 上方出现「🛰️ 审批」;
29
+ 2. 点击后居中对话框正常打开并显示待审批条目(用户确认);
30
+ 3. `fleet_simulate_request` 造请求 `dae59ce4-…`(run/pwsh,simulated-device-B);
31
+ 4. 用户在面板点击「允许一次」;
32
+ 5. Host `fleet_approvals` 返回 `(no pending approvals)` → **UI → HTTP → ApprovalInbox 写回链路闭环**。
33
33
 
34
34
  ## 限制
35
35
 
36
- - 面板轮询而非推送,规模大时应换 SSE/事件;
37
- - 路由未额外鉴权(依赖 web 载体自身的本地/会话边界);
38
- - 面板不含房间视图(下一版可加 Room 时间线/看板页)。
36
+ - 轮询而非 SSE 推送;
37
+ - 路由未额外鉴权(依赖 web 载体本地/会话边界);
38
+ - 面板暂无房间视图(时间线/看板页下一步)。
@@ -39,19 +39,19 @@ 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.10(稳定化)
42
+ ## 0.1.0-dev.12(稳定化)
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.10(审批收件箱,逻辑层)
48
+ ## 0.1.0-dev.12(审批收件箱,逻辑层)
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
53
  - 审批面板(Client UI)与通知尚未实现,当前通过工具/后续 UI 应答。
54
- ## 0.1.0-dev.10(Room 逻辑层,本机模拟)
54
+ ## 0.1.0-dev.12(Room 逻辑层,本机模拟)
55
55
 
56
56
  - `RoomService`:当前会话升级为 Room Host;本地模拟参与者(真实 DSH session/agent);
57
57
  - 寻址消息:`room_send` 只唤醒被点名成员,Host 会话写权威 `room/message` 事件;
@@ -59,9 +59,15 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
59
59
  - Room Board:`room_task_add/update/list`,`dependsOn` 串行交接(前置完成时唤醒下一个 owner),无依赖任务并行不受影响;
60
60
  - 工具:`room_create / room_add_local / room_remove_member / room_send / room_messages / room_status / room_task_* / room_close`;
61
61
  - 远程参与者传输与 Client UI 未做(下一步)。
62
- ## 0.1.0-dev.10(审批面板 UI,第一版)
62
+ ## 0.1.0-dev.12(审批面板 UI,第一版)
63
63
 
64
64
  - Host 新增本地 HTTP 数据面:GET `/device-fleet/approvals`、POST `/device-fleet/approvals/decide`(仅本机 web 载体,决策仍走 ApprovalInbox);
65
65
  - Client half:`client.js` 闭包工厂,注册 `shell.overlay` 浮动「Fleet 审批」按钮 + 面板,轮询待审批列表,支持允许一次 / 允许 15 分钟 / 拒绝;
66
66
  - package.json 增加 `dsh.client` 与 `./client` 导出;`files` 增加 client.js;
67
- - Client 视觉验证需要刷新页面;当前无浏览器控制,仅验证了 bundle 语法与 Host 路由注册。
67
+ - Client 视觉验证需要刷新页面;当前无浏览器控制,仅验证了 bundle 语法与 Host 路由注册。
68
+ ## 0.1.0-dev.12(审批入口不遮挡)
69
+
70
+ - 触发按钮从右下角浮动改为 `sidebar.footer.action`(设置旁边),不再遮挡任务列表/内容面板;
71
+ - 审批 inbox 改为点击后居中的模态对话框(带遮罩,Esc/点遮罩关闭);
72
+ - 轮询在 Client apply 中启动并由 ctx.effect 清理;待审批数量作为按钮徽标;
73
+ - 已实测:面板点击「允许」后 Host `fleet_approvals` 清空。
@@ -0,0 +1,93 @@
1
+ # UI 呈现缺口审计(2026-11-09)
2
+
3
+ ## 现状盘点
4
+
5
+ 当前插件已实现的 UI 只有**审批面板**(dev.12):
6
+
7
+ - 入口:`sidebar.footer.action` 的「🛰️ 审批」;
8
+ - 内容:`shell.overlay` 居中对话框,待审批列表 + 允许一次/15 分钟/拒绝;
9
+ - 数据:Host `/device-fleet/approvals`、`/device-fleet/approvals/decide`。
10
+
11
+ 其余**全部**依靠模型工具文本输出或 profile YAML 配置,没有任何可视化:
12
+
13
+ | 设计要素 | 当前呈现方式 | 缺口 |
14
+ |---|---|---|
15
+ | 设备列表 / 在线状态 / 直连地址 / relay / 发现 | `fleet_status` 文本 | 无设备页,无法看设备拓扑与连接路径 |
16
+ | 配对:邀请码、加入、手动地址、设备移除 | `fleet_invite`/`fleet_join`/`fleet_connect` 工具 | 无配对向导,邀请码需在聊天里传 |
17
+ | 插件配置(deviceName/direct/discovery/relay/execution/policy/approvals/prompt) | profile `cordis.patch.yml` | 完全无 UI,改配置要手改 YAML + 重启 |
18
+ | Room 列表 / 房间状态 | `room_status`、`room_messages` 文本 | 无房间列表/视图 |
19
+ | Room 成员(设备 × agent、host/local/remote) | `room_status` JSON | 无成员可视化,看不出"不同设备不同 agent" |
20
+ | Room 时间线(用户可见完整对话) | `room_messages` 文本 | 无时间线 UI,无消息流式更新 |
21
+ | Room 发消息 / 点名 | `room_send` 工具 | 无输入框/@成员操作 |
22
+ | Room Board(任务、owner、状态、依赖、交接) | `room_task_*` 文本 | 无看板视图 |
23
+ | 房间策略(invite/join/maxMembers/boardWrite) | 创建参数 | 无查看/编辑 |
24
+ | 审批历史 / TTL 授权管理 | 收件箱列表(仅 pending) | 无历史、无授权撤销 |
25
+ | 委派(fleet_delegate) | 工具结果文本 | 无历史/运行状态/中断入口 |
26
+ | 直调审计(谁调了哪台设备什么工具) | 会话轨迹里 | 无 Fleet 维度审计页 |
27
+ | 首次配对引导 | 无 | 新设备没有任何向导 |
28
+ | 错误/诊断 | `fleet_status` + 日志 | 无连接诊断页 |
29
+ | 通知 | 无 | 新审批/新消息没有系统通知 |
30
+ | 国际化 | 中文硬编码 | 未接 Client locale 服务 |
31
+ | 多窗口/实时 | 审批 2.5s 轮询 | Room/设备状态无推送,多窗口不同步 |
32
+
33
+ ## 关键基础设施缺口(UI 之前必须先补)
34
+
35
+ 1. **Host 读模型 API 不足**:目前只有 approvals 两条路由;需要
36
+ - `GET /device-fleet/overview`:本机身份/版本/直连/relay/discovery/roster/connected/peerHints;
37
+ - `GET /device-fleet/rooms`、`GET /device-fleet/rooms/:id`:成员、策略、transcript、tasks;
38
+ - `GET /device-fleet/events`(SSE)或长轮询:状态变化推送。
39
+ 2. **配置写路径缺失**:Cordis config 来自 profile patch,运行时不可写。需要确定:
40
+ - 方案 A(推荐):`$DSH_HOME/device-fleet/config-overrides.json` + apply 时合并,UI 写该文件;保存后提示"重载插件生效"(或调用 Host 重新加载);
41
+ - 方案 B:直接改写 profile `cordis.patch.yml`(风险高:破坏注释/结构,需备份)。
42
+ 3. **Room 状态无持久化/rehydrate**:RoomService 是内存 Map,插件重载/DSH 重启后房间消失。UI 之前应先做基于 host session 事件的 rehydrate,否则"房间视图"打开是空的。
43
+ 4. **UI 操作的身份与授权**:UI 里的"发消息/改任务/关房"是以**用户**身份还是 host agent 身份?需要 Room 服务暴露 user 动作并校验房主/成员权限。
44
+ 5. **Slot/设计系统**:已确认可用的官方扩展点:
45
+ - `sidebar.panellist`(全局面板图标)+ `main`(keyed 主面板)→ Fleet 控制台;
46
+ - `settings.section`(设置页)或 `plugins.bundle.config`(插件配置页,key = 包名)→ 配置 UI;
47
+ - `shell.overlay` → 对话框/房间详情;
48
+ - `sidebar.footer.action` → 快捷入口(审批已用)。
49
+ 6. **多设备语义呈现**:房间成员需要 "设备(名称/在线/连接路径)× agent(session/角色)" 两级标签;当前 member 只有 `local:session-…`/`host:…` 字符串,远程成员还没有实体,需要先接远程参与者传输。
50
+
51
+ ## 建议的信息架构
52
+
53
+ ```
54
+ 侧栏
55
+ ├─ 全局面板图标「Fleet」 (sidebar.panellist: id=fleet)
56
+ │ └─ main keyed "fleet" 主面板
57
+ │ ├─ 设备 Tab:本机状态、roster、在线/离线、直连/relay、配对按钮、诊断
58
+ │ ├─ 房间 Tab:房间列表 → 房间详情(成员树、时间线、看板、策略)
59
+ │ └─ 审批 Tab:待审批 + 历史 + 授权管理
60
+ ├─ Settings 上方「🛰️ 审批」快捷入口(已实现)
61
+ └─ Settings → 插件 → Device Fleet 配置页
62
+ (plugins.bundle.config key "dsh-device-fleet" 或 plugins.row.config "dsh-device-fleet#device-fleet")
63
+ 分块:设备/网络 · 执行上下文 · 远端权限(Tool Policy) · 本地审批 · 高级
64
+ ```
65
+
66
+ ## 分阶段实施计划
67
+
68
+ **Phase A:Fleet 控制台(只读)**
69
+ - Host `/overview`、`/rooms`、`/rooms/:id` API;
70
+ - `sidebar.panellist` + `main` 主面板,三个 Tab:设备 / 房间 / 审批(待审批 + 只读时间线/看板);
71
+ - 先解决 Room rehydrate,否则只读视图无内容。
72
+
73
+ **Phase B:配置 UI**
74
+ - `plugins.bundle.config` 注册配置页;
75
+ - 字段分组 + 校验 + 当前值显示;
76
+ - 保存走 overrides 文件(方案 A),保存后提示重载插件;policy 的 roots/tools 用列表编辑器。
77
+
78
+ **Phase C:交互动作**
79
+ - 房间:以用户身份发消息(点名选择成员)、看板任务增改/交接、关房、邀请/移除成员(权限校验);
80
+ - 设备:配对向导(邀请码生成/扫码式复制、手动地址连接、移除设备);
81
+ - 审批:历史列表 + ttl 授权撤销。
82
+
83
+ **Phase D:体验与可靠性**
84
+ - SSE/事件推送替代轮询,多窗口同步;
85
+ - 通知(新审批/新房间消息);
86
+ - Client locale(zh/en),a11y,空状态/引导;
87
+ - 插件重载 rehydrate、房间日志持久补发(ADR-0018)。
88
+
89
+ ## 需要你确认的决策
90
+
91
+ 1. 配置写路径:选 **overrides 文件(推荐)** 还是直接改 profile YAML?
92
+ 2. 房间 UI 动作的身份:以 **用户** 身份(推荐,符合 ADR-0007 用户发言与设备解耦)还是 host agent 身份?
93
+ 3. 先做 Phase A(控制台只读)还是先做 Phase B(配置页)?我建议按 A → B → C → D。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.11",
3
+ "version": "0.1.0-dev.13",
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",
package/src/host/index.js CHANGED
@@ -6,5 +6,6 @@ export * from './direct-call.js'
6
6
  export * from './delegation.js'
7
7
  export * from './approval-inbox.js'
8
8
  export * from './approval-web.js'
9
+ export * from './ui-web.js'
9
10
  export * from './room-service.js'
10
11
  export * from './tools.js'
@@ -4,8 +4,11 @@
4
4
  // addressed message, so addressing/wake/visibility and board handoff can be
5
5
  // exercised before the remote transport and the client UI exist.
6
6
  import { randomUUID } from 'node:crypto'
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
8
+ import { dirname } from 'node:path'
7
9
 
8
10
  const MAX_MESSAGE_CHARS = 20_000
11
+ const STATE_VERSION = 1
9
12
 
10
13
  const DEFAULT_POLICY = Object.freeze({
11
14
  invite: 'members',
@@ -29,13 +32,85 @@ export class RoomService {
29
32
  * @param {object} options.sessionController ctx.sessionController
30
33
  * @param {(input: object) => object} [options.createUserMessage] dsh-llm helper
31
34
  * @param {(now?: number) => number} [options.now]
35
+ * @param {string} [options.stateFile] JSON file used to rehydrate rooms after a reload
32
36
  */
33
- constructor({ sessionController, createUserMessage = null, now = Date.now } = {}) {
37
+ constructor({ sessionController, createUserMessage = null, now = Date.now, stateFile = null } = {}) {
34
38
  this.sessionController = sessionController
35
39
  this.createUserMessage = createUserMessage
36
40
  this.now = now
41
+ this.stateFile = stateFile
37
42
  /** @type {Map<string, any>} */
38
43
  this.rooms = new Map()
44
+ if (stateFile) this.load()
45
+ }
46
+
47
+ /** Rehydrate active rooms from the persisted snapshot (agents resolve lazily). */
48
+ load() {
49
+ if (!this.stateFile || !existsSync(this.stateFile)) return
50
+ try {
51
+ const data = JSON.parse(readFileSync(this.stateFile, 'utf8'))
52
+ if (!data || data.version !== STATE_VERSION || !Array.isArray(data.rooms)) return
53
+ for (const stored of data.rooms) {
54
+ if (!stored || typeof stored.roomId !== 'string' || !Array.isArray(stored.members)) continue
55
+ const members = new Map()
56
+ for (const member of stored.members) {
57
+ if (!member || typeof member.memberId !== 'string') continue
58
+ members.set(member.memberId, { ...member, agent: undefined })
59
+ }
60
+ if (!members.has(hostMemberId(stored.roomId))) continue
61
+ this.rooms.set(stored.roomId, {
62
+ roomId: stored.roomId,
63
+ createdAt: Number.isFinite(stored.createdAt) ? stored.createdAt : this.now(),
64
+ hostSessionId: typeof stored.hostSessionId === 'string' ? stored.hostSessionId : stored.roomId,
65
+ policy: { ...DEFAULT_POLICY, ...(stored.policy ?? {}) },
66
+ members,
67
+ messages: Array.isArray(stored.messages) ? stored.messages : [],
68
+ tasks: new Map((Array.isArray(stored.tasks) ? stored.tasks : []).map((task) => [task.id, task])),
69
+ closed: false,
70
+ })
71
+ }
72
+ } catch {
73
+ /* a corrupt snapshot is ignored; the host session log stays authoritative */
74
+ }
75
+ }
76
+
77
+ persist() {
78
+ if (!this.stateFile) return
79
+ try {
80
+ const snapshot = {
81
+ version: STATE_VERSION,
82
+ savedAt: this.now(),
83
+ rooms: [...this.rooms.values()].map((room) => ({
84
+ roomId: room.roomId,
85
+ createdAt: room.createdAt,
86
+ hostSessionId: room.hostSessionId,
87
+ policy: room.policy,
88
+ members: [...room.members.values()].map(({ agent, ...rest }) => rest),
89
+ messages: room.messages,
90
+ tasks: [...room.tasks.values()],
91
+ })),
92
+ }
93
+ mkdirSync(dirname(this.stateFile), { recursive: true })
94
+ const temporary = `${this.stateFile}.tmp`
95
+ writeFileSync(temporary, JSON.stringify(snapshot, null, 2), 'utf8')
96
+ renameSync(temporary, this.stateFile)
97
+ } catch {
98
+ /* persistence is best effort; the session log remains authoritative */
99
+ }
100
+ }
101
+
102
+ /** Resolve a live agent for a persisted member on demand. */
103
+ async ensureAgent(member) {
104
+ if (member?.agent) return true
105
+ if (!member?.sessionId || !this.sessionController?.resolveAgent) return false
106
+ try {
107
+ const resolved = await this.sessionController.resolveAgent(member.sessionId)
108
+ if (!resolved?.agent) return false
109
+ member.agent = resolved.agent
110
+ return true
111
+ } catch {
112
+ return false
113
+ }
39
114
  }
40
115
 
41
116
  async upgradeHost({ agent, policy = {} } = {}) {
@@ -169,7 +244,9 @@ export class RoomService {
169
244
 
170
245
  /** Wake one local participant by injecting the addressed message. */
171
246
  async wake(room, member, message) {
172
- if (!member?.agent || typeof member.agent.followup !== 'function' || typeof this.createUserMessage !== 'function') return false
247
+ if (!member || typeof this.createUserMessage !== 'function') return false
248
+ if (!(await this.ensureAgent(member))) return false
249
+ if (typeof member.agent?.followup !== 'function') return false
173
250
  const senderLabel = message.from.kind === 'user' ? 'user' : (message.from.name ?? 'agent')
174
251
  const text = [
175
252
  `[Fleet Room ${room.roomId}] ${senderLabel} → you:`,
@@ -189,7 +266,14 @@ export class RoomService {
189
266
  async append(room, type, data) {
190
267
  const host = room.members.get(hostMemberId(room.hostSessionId))
191
268
  const session = host?.agent?.session
192
- if (session?.append) await session.append(type, data)
269
+ if (session?.append) {
270
+ try {
271
+ await session.append(type, data)
272
+ } catch {
273
+ /* the snapshot below still records the room state */
274
+ }
275
+ }
276
+ this.persist()
193
277
  }
194
278
 
195
279
  // --------------------------------------------------------------- room tasks
@@ -303,6 +387,21 @@ export class RoomService {
303
387
  return this.summary(this.requireRoom(roomId))
304
388
  }
305
389
 
390
+ /** All active rooms (UI list). */
391
+ list() {
392
+ return [...this.rooms.values()].map((room) => this.summary(room))
393
+ }
394
+
395
+ /** One room plus its transcript and board (UI detail). */
396
+ detail(roomId, { limit = 100 } = {}) {
397
+ const room = this.requireRoom(roomId)
398
+ return {
399
+ ...this.summary(room),
400
+ messages: this.messages({ roomId, limit }),
401
+ tasks: this.listTasks(roomId),
402
+ }
403
+ }
404
+
306
405
  /** All open rooms (for cleanup and diagnostics). */
307
406
  openRooms() {
308
407
  return [...this.rooms.keys()]
@@ -313,6 +412,7 @@ export class RoomService {
313
412
  room.closed = true
314
413
  await this.append(room, 'room/closed', { roomId: room.roomId, reason, closedAt: this.now() })
315
414
  this.rooms.delete(room.roomId)
415
+ this.persist()
316
416
  return { roomId: room.roomId, closed: true, reason }
317
417
  }
318
418
 
@@ -0,0 +1,78 @@
1
+ // Read-model HTTP surface for the Fleet console (dev.13): device overview,
2
+ // room list and room detail. The approval routes stay in approval-web.js.
3
+ function json(res, status, value) {
4
+ res.statusCode = status
5
+ res.setHeader('content-type', 'application/json; charset=utf-8')
6
+ res.setHeader('cache-control', 'no-store')
7
+ res.end(JSON.stringify(value))
8
+ }
9
+
10
+ function pathOf(request) {
11
+ try {
12
+ return new URL(request.url ?? '/', 'http://local').pathname
13
+ } catch {
14
+ return '/'
15
+ }
16
+ }
17
+
18
+ /**
19
+ * @param {object} options
20
+ * @param {object} options.webServer ctx.webServer
21
+ * @param {object} options.inbox ApprovalInbox
22
+ * @param {object} options.rooms RoomService
23
+ * @param {() => object} options.getOverview
24
+ * @returns {() => void} disposer
25
+ */
26
+ export function registerUiRoutes({ webServer, inbox, rooms, getOverview, logger = null }) {
27
+ if (!webServer?.register || !rooms) return () => {}
28
+ const disposers = []
29
+ disposers.push(
30
+ webServer.register({
31
+ kind: 'exact',
32
+ path: '/device-fleet/overview',
33
+ handler: async (req, res) => {
34
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
35
+ try {
36
+ json(res, 200, { ...getOverview(), approvals: inbox?.list?.() ?? [] })
37
+ } catch (error) {
38
+ logger?.warn?.(`[device-fleet] overview failed: ${String(error?.message ?? error)}`)
39
+ json(res, 500, { error: 'internal error' })
40
+ }
41
+ },
42
+ }),
43
+ )
44
+ disposers.push(
45
+ webServer.register({
46
+ kind: 'exact',
47
+ path: '/device-fleet/rooms',
48
+ handler: async (req, res) => {
49
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
50
+ try {
51
+ json(res, 200, { rooms: rooms.list() })
52
+ } catch (error) {
53
+ json(res, 500, { error: String(error?.message ?? error) })
54
+ }
55
+ },
56
+ }),
57
+ )
58
+ disposers.push(
59
+ webServer.register({
60
+ kind: 'prefix',
61
+ path: '/device-fleet/rooms/',
62
+ handler: async (req, res) => {
63
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
64
+ const path = pathOf(req)
65
+ const roomId = decodeURIComponent(path.slice('/device-fleet/rooms/'.length))
66
+ if (roomId === '') return json(res, 400, { error: 'room id is required' })
67
+ try {
68
+ json(res, 200, { room: rooms.detail(roomId, { limit: 100 }) })
69
+ } catch (error) {
70
+ json(res, error?.code === 'unknown-room' ? 404 : 500, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
71
+ }
72
+ },
73
+ }),
74
+ )
75
+ return () => {
76
+ for (const dispose of disposers) dispose()
77
+ }
78
+ }
@@ -6,6 +6,7 @@
6
6
  // Room, Room Board, delegation and the UI land in later steps.
7
7
  import { readFileSync } from 'node:fs'
8
8
  import { networkInterfaces } from 'node:os'
9
+ import { join } from 'node:path'
9
10
  import { fileURLToPath } from 'node:url'
10
11
  import { defineTool } from '@deepseek-ai/dsh-tools'
11
12
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
@@ -17,6 +18,7 @@ import { DelegationHost } from '../host/delegation.js'
17
18
  import { ApprovalInbox } from '../host/approval-inbox.js'
18
19
  import { RoomService } from '../host/room-service.js'
19
20
  import { registerApprovalRoutes } from '../host/approval-web.js'
21
+ import { registerUiRoutes } from '../host/ui-web.js'
20
22
  import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
21
23
  import { ToolPolicy } from '../host/tool-policy.js'
22
24
  import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
@@ -197,13 +199,30 @@ export function apply(ctx, config) {
197
199
  ? async () => ({ approved: true, scope: 'once' })
198
200
  : async (request) => inbox.request(request)
199
201
 
200
- const rooms = new RoomService({ sessionController, createUserMessage })
202
+ const rooms = new RoomService({
203
+ sessionController,
204
+ createUserMessage,
205
+ stateFile: join(state.dataDir, 'rooms.json'),
206
+ })
201
207
 
202
208
  // Client approval panel data plane (dev.9). Optional service: host-only
203
209
  // deployments simply do not expose the panel routes.
204
210
  const webServer = service('webServer')
205
211
  if (webServer) {
206
212
  ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
213
+ ctx.effect(() =>
214
+ registerUiRoutes({
215
+ webServer,
216
+ inbox,
217
+ rooms,
218
+ logger,
219
+ getOverview: () => ({
220
+ ...(node.status?.() ?? {}),
221
+ rooms: rooms.list(),
222
+ approvals: inbox.list(),
223
+ }),
224
+ }),
225
+ )
207
226
  }
208
227
  if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
209
228
  const slot = sessionController
@@ -369,9 +388,8 @@ export function apply(ctx, config) {
369
388
 
370
389
  ctx.effect(() => () => {
371
390
  inbox.close()
372
- for (const roomId of rooms.openRooms()) {
373
- void rooms.close({ roomId, reason: 'plugin unloaded' }).catch(() => {})
374
- }
391
+ // Rooms are intentionally left open (and persisted) so a plugin reload can
392
+ // rehydrate them instead of destroying a running collaboration.
375
393
  void node.close()
376
394
  })
377
395