dsh-device-fleet 0.1.0-dev.17 → 0.1.0-dev.19

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
@@ -11,7 +11,7 @@ DeepSeek Harness 的设备联邦插件:把登录同一账号、且都安装本
11
11
  - ✅ **Room 逻辑层(dev.8,单机模拟)**:`room_create` / `room_add_local` / `room_send` / `room_messages` / `room_status` / `room_task_*` / `room_close`;本地真实 agent 参与者、寻址唤醒、Room Board 串行交接;单机仿真验证通过
12
12
  - ✅ **审批面板 UI(dev.12,已验证)**:Host `/device-fleet/approvals` HTTP 数据面 + Client 入口在 Settings 上方、居中审批对话框(允许一次/15 分钟/拒绝);用户点击「允许」后 Host 列表清空,端到端闭环
13
13
  - ✅ **Fleet 控制台(dev.13,Phase A)**:Host `/device-fleet/overview`、`/rooms`、`/rooms/:id`;Room 持久化到 `$DSH_HOME/device-fleet/rooms.json` 并 rehydrate(插件重载不丢房间);Client `sidebar.panellist` + `main` 面板,设备/房间/审批三 Tab
14
- - ⏳ Phase B 配置页(即时生效,无需重启)、Phase C 房间/配对交互、Phase D SSE/通知/i18n/a11y
14
+ - ✅ Phase B 配置页(即时生效,无需重启)、Phase C 房间/配对/审批交互(dev.18)\n- ⏳ Phase D SSE/通知/i18n/a11y
15
15
 
16
16
  ## 安装(开发版)
17
17
 
package/client.js CHANGED
@@ -247,6 +247,17 @@ window.__ModuleLoader__.load({
247
247
  }
248
248
  };
249
249
 
250
+ const postJson = async (url, body) => {
251
+ const response = await fetch(url, {
252
+ method: 'POST',
253
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
254
+ body: JSON.stringify(body ?? {}),
255
+ });
256
+ const data = await response.json().catch(() => ({}));
257
+ if (!response.ok) throw new Error(data.error ?? 'HTTP ' + response.status);
258
+ return data;
259
+ };
260
+
250
261
  function FleetIcon({ size = 20, active = false }) {
251
262
  return h(
252
263
  'svg',
@@ -275,6 +286,14 @@ window.__ModuleLoader__.load({
275
286
  const [roomDetail, setRoomDetail] = useState(null);
276
287
  const [roomError, setRoomError] = useState('');
277
288
  const [error, setError] = useState('');
289
+ const [actionStatus, setActionStatus] = useState('');
290
+ const [inviteCode, setInviteCode] = useState('');
291
+ const [connectHost, setConnectHost] = useState('192.168.137.1');
292
+ const [connectPort, setConnectPort] = useState(0);
293
+ const [sendText, setSendText] = useState('');
294
+ const [sendTo, setSendTo] = useState([]);
295
+ const [taskTitle, setTaskTitle] = useState('');
296
+ const [taskOwner, setTaskOwner] = useState('');
278
297
 
279
298
  useEffect(() => {
280
299
  let disposed = false;
@@ -339,6 +358,54 @@ window.__ModuleLoader__.load({
339
358
  setOverview((current) => (current ? { ...current, approvals: (current.approvals ?? []).filter((item) => item.id !== id) } : current));
340
359
  };
341
360
 
361
+ const runAction = async (label, action) => {
362
+ setActionStatus(label + '…');
363
+ try {
364
+ await action();
365
+ setActionStatus(label + '成功');
366
+ } catch (problem) {
367
+ setActionStatus(label + '失败:' + String(problem.message ?? problem));
368
+ }
369
+ };
370
+ const sendRoomMessage = () =>
371
+ runAction('发送消息', async () => {
372
+ if (!selectedRoom) throw new Error('请先选择房间');
373
+ if (sendText.trim() === '') throw new Error('消息不能为空');
374
+ await postJson('/device-fleet/room/send', { roomId: selectedRoom, to: sendTo, text: sendText });
375
+ setSendText('');
376
+ });
377
+ const addRoomTask = () =>
378
+ runAction('添加任务', async () => {
379
+ if (!selectedRoom) throw new Error('请先选择房间');
380
+ if (taskTitle.trim() === '') throw new Error('任务标题不能为空');
381
+ await postJson('/device-fleet/room/task', { roomId: selectedRoom, action: 'add', title: taskTitle, owner: taskOwner });
382
+ setTaskTitle('');
383
+ });
384
+ const updateRoomTask = (id, status) =>
385
+ runAction('更新任务', async () => {
386
+ await postJson('/device-fleet/room/task', { roomId: selectedRoom, action: 'update', id, status });
387
+ });
388
+ const closeCurrentRoom = () =>
389
+ runAction('关闭房间', async () => {
390
+ await postJson('/device-fleet/room/close', { roomId: selectedRoom });
391
+ setSelectedRoom(null);
392
+ });
393
+ const removeRoomMember = (memberId) =>
394
+ runAction('移除成员', async () => {
395
+ await postJson('/device-fleet/room/member/remove', { roomId: selectedRoom, memberId });
396
+ });
397
+ const createInviteCode = () =>
398
+ runAction('生成邀请码', async () => {
399
+ const data = await postJson('/device-fleet/pair/invite');
400
+ setInviteCode(data.code ?? '');
401
+ });
402
+ const connectPeer = () =>
403
+ runAction('连接设备', async () => {
404
+ const port = Number(connectPort) || overview?.direct?.port || 0;
405
+ const data = await postJson('/device-fleet/pair/connect', { host: connectHost, port });
406
+ setActionStatus('已连接 ' + String(data.result?.deviceId ?? ''));
407
+ });
408
+
342
409
  const tabs = [
343
410
  ['devices', '设备'],
344
411
  ['rooms', '房间'],
@@ -349,6 +416,7 @@ window.__ModuleLoader__.load({
349
416
  { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 } },
350
417
  h('strong', { style: { fontSize: 15 } }, '设备联邦'),
351
418
  overview ? pill('本机 ' + (overview.name ?? '') + ' · ' + overview.pluginVersion, 'muted') : null,
419
+ actionStatus ? h('span', { style: { fontSize: 12, opacity: 0.7 } }, actionStatus) : null,
352
420
  h('span', { style: { flex: 1 } }),
353
421
  ...tabs.map(([id, label]) =>
354
422
  h(
@@ -409,6 +477,45 @@ window.__ModuleLoader__.load({
409
477
  ),
410
478
  )
411
479
  : null,
480
+ h('div', { style: cardStyle },
481
+ h('div', { style: sectionTitle }, '配对与连接'),
482
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
483
+ smallButton('生成邀请码', () => void createInviteCode(), 'allow'),
484
+ inviteCode
485
+ ? h('button', {
486
+ type: 'button',
487
+ onClick: () => {
488
+ try {
489
+ void navigator.clipboard?.writeText(inviteCode)
490
+ } catch {
491
+ /* ignore */
492
+ }
493
+ },
494
+ style: { background: 'transparent', border: 'none', color: 'var(--dsw-alias-accent, #2f6fed)', cursor: 'pointer', fontSize: 12 },
495
+ }, '复制')
496
+ : null,
497
+ ),
498
+ inviteCode
499
+ ? h('pre', { style: { fontSize: 11, fontFamily: 'ui-monospace, monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', background: 'var(--dsw-alias-bg-layer-2, #262626)', padding: 8, borderRadius: 6, maxHeight: 140, overflow: 'auto' } }, inviteCode)
500
+ : null,
501
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 10, flexWrap: 'wrap' } },
502
+ h('input', {
503
+ type: 'text',
504
+ value: connectHost,
505
+ onChange: (event) => setConnectHost(event.target.value),
506
+ placeholder: 'host',
507
+ style: { ...controlStyleWidth(180) },
508
+ }),
509
+ h('input', {
510
+ type: 'number',
511
+ value: connectPort || overview?.direct?.port || 0,
512
+ onChange: (event) => setConnectPort(Number(event.target.value || 0)),
513
+ placeholder: 'port',
514
+ style: { ...controlStyleWidth(110) },
515
+ }),
516
+ smallButton('连接', () => void connectPeer(), 'normal'),
517
+ ),
518
+ ),
412
519
  );
413
520
  } else if (tab === 'rooms') {
414
521
  body = h(
@@ -441,58 +548,150 @@ window.__ModuleLoader__.load({
441
548
  ),
442
549
  ),
443
550
  ),
444
- h('div', { style: { flex: '2 1 420px', minWidth: 320 } }, selectedRoom ? (roomError && !roomDetail ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '房间加载失败:' + roomError) : renderRoomDetail(roomDetail)) : h('div', { style: { opacity: 0.65, fontSize: 12 } }, '选择一个房间查看成员、时间线与看板。')),
551
+ h('div', { style: { flex: '2 1 420px', minWidth: 320 } }, selectedRoom ? (roomError && !roomDetail ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '房间加载失败:' + roomError) : renderRoomDetail(roomDetail, {
552
+ sendText,
553
+ setSendText,
554
+ sendTo,
555
+ setSendTo,
556
+ sendRoomMessage,
557
+ taskTitle,
558
+ setTaskTitle,
559
+ taskOwner,
560
+ setTaskOwner,
561
+ addRoomTask,
562
+ updateRoomTask,
563
+ closeCurrentRoom,
564
+ removeRoomMember,
565
+ })) : h('div', { style: { opacity: 0.65, fontSize: 12 } }, '选择一个房间查看成员、时间线与看板。')),
445
566
  );
446
567
  } else {
447
568
  const approvals = overview.approvals ?? [];
569
+ const history = overview.approvalHistory ?? [];
448
570
  body = h(
449
571
  'div',
450
- { style: cardStyle },
451
- h('div', { style: sectionTitle }, '待审批请求(' + approvals.length + ')'),
452
- approvals.length === 0
453
- ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无待审批请求')
454
- : approvals.map((item) =>
455
- h('div', { key: item.id, style: { borderTop: '1px solid var(--dsw-alias-border, #333)', padding: '9px 0' } },
456
- h('div', { style: { fontWeight: 600 } }, (item.capability || 'tool') + (item.tool ? ' / ' + item.tool : '')),
457
- h('div', { style: { opacity: 0.75, fontSize: 12 } }, '来自 ' + item.fromDeviceId + ' · ' + Math.max(0, Math.round(item.expiresInMs / 1000)) + 's 后超时'),
458
- item.reason ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, item.reason) : null,
459
- h('div', { style: { display: 'flex', gap: 6, marginTop: 5, flexWrap: 'wrap' } },
460
- smallButton('允许一次', () => decide(item.id, 'approve'), 'allow'),
461
- smallButton('允许 15 分钟', () => decide(item.id, 'approve', 'ttl'), 'allow'),
462
- smallButton('拒绝', () => decide(item.id, 'deny'), 'deny'),
572
+ null,
573
+ h('div', { style: cardStyle },
574
+ h('div', { style: sectionTitle }, '待审批请求(' + approvals.length + ')'),
575
+ approvals.length === 0
576
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无待审批请求')
577
+ : approvals.map((item) =>
578
+ h('div', { key: item.id, style: { borderTop: '1px solid var(--dsw-alias-border, #333)', padding: '9px 0' } },
579
+ h('div', { style: { fontWeight: 600 } }, (item.capability || 'tool') + (item.tool ? ' / ' + item.tool : '')),
580
+ h('div', { style: { opacity: 0.75, fontSize: 12 } }, '来自 ' + item.fromDeviceId + ' · ' + Math.max(0, Math.round(item.expiresInMs / 1000)) + 's 后超时'),
581
+ item.reason ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, item.reason) : null,
582
+ h('div', { style: { display: 'flex', gap: 6, marginTop: 5, flexWrap: 'wrap' } },
583
+ smallButton('允许一次', () => decide(item.id, 'approve'), 'allow'),
584
+ smallButton('允许 15 分钟', () => decide(item.id, 'approve', 'ttl'), 'allow'),
585
+ smallButton('拒绝', () => decide(item.id, 'deny'), 'deny'),
586
+ ),
463
587
  ),
464
588
  ),
465
- ),
589
+ ),
590
+ h('div', { style: cardStyle },
591
+ h('div', { style: sectionTitle }, '审批历史(最近 ' + history.length + ' 条)'),
592
+ history.length === 0
593
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无历史')
594
+ : history.slice(0, 20).map((item) =>
595
+ h('div', { key: item.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0', fontSize: 12, borderTop: '1px solid var(--dsw-alias-border, #333)' } },
596
+ pill(item.outcome, item.outcome === 'approved' ? 'ok' : 'muted'),
597
+ h('span', { style: { flex: 1 } }, (item.capability || 'tool') + (item.tool ? ' / ' + item.tool : '') + ' · ' + item.fromDeviceId),
598
+ h('span', { style: { opacity: 0.55 } }, fmtTime(item.resolvedAt)),
599
+ ),
600
+ ),
601
+ ),
466
602
  );
467
603
  }
468
604
 
469
605
  return h('div', { style: { padding: '14px 16px', height: '100%', overflow: 'auto', boxSizing: 'border-box' } }, header, body);
470
606
  }
471
607
 
472
- function renderRoomDetail(detail) {
608
+ function renderRoomDetail(detail, ctl) {
473
609
  if (!detail) return h('div', { style: { opacity: 0.65, fontSize: 12 } }, '加载房间…');
610
+ const memberIds = detail.members.map((member) => member.memberId);
611
+ const recipientIds = Array.isArray(ctl.sendTo) && ctl.sendTo.length > 0 ? ctl.sendTo : memberIds.filter((id) => id.startsWith('host:'));
474
612
  return h(
475
613
  'div',
476
614
  null,
615
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 } },
616
+ h('div', { style: { ...sectionTitle, margin: 0, flex: 1 } }, '房间 ' + detail.roomId.slice(-8)),
617
+ smallButton('关闭房间', () => void ctl.closeCurrentRoom(), 'deny'),
618
+ ),
477
619
  h('div', { style: cardStyle },
478
620
  h('div', { style: sectionTitle }, '成员'),
479
621
  ...detail.members.map((member) =>
480
- h('div', { key: member.memberId, style: { padding: '3px 0', fontSize: 12 } },
622
+ h('div', { key: member.memberId, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '3px 0', fontSize: 12 } },
481
623
  pill(member.role, member.role === 'host' ? 'ok' : 'muted'),
482
- member.name + ' · ' + member.memberId,
624
+ h('span', { style: { flex: 1 } }, member.name + ' · ' + member.memberId),
625
+ member.role === 'host' ? null : smallButton('移除', () => void ctl.removeRoomMember(member.memberId), 'deny'),
626
+ ),
627
+ ),
628
+ ),
629
+ h('div', { style: cardStyle },
630
+ h('div', { style: sectionTitle }, '发消息(以用户身份)'),
631
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px 14px', marginBottom: 6 } },
632
+ ...detail.members.map((member) =>
633
+ h('label', { key: member.memberId, style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 } },
634
+ h('input', {
635
+ type: 'checkbox',
636
+ checked: recipientIds.includes(member.memberId),
637
+ onChange: (event) => {
638
+ ctl.setSendTo((current) => {
639
+ const base = current.length > 0 ? current : recipientIds
640
+ return event.target.checked ? [...new Set([...base, member.memberId])] : base.filter((id) => id !== member.memberId)
641
+ })
642
+ },
643
+ }),
644
+ member.name,
645
+ ),
483
646
  ),
484
647
  ),
648
+ h('textarea', {
649
+ value: ctl.sendText,
650
+ onChange: (event) => ctl.setSendText(event.target.value),
651
+ rows: 2,
652
+ placeholder: '发送给房间成员(默认为 host)',
653
+ style: { width: '100%', boxSizing: 'border-box', padding: '6px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l1, #333)', background: 'var(--dsw-alias-bg-layer-2, #262626)', color: 'inherit', fontSize: 12, resize: 'vertical', fontFamily: 'inherit' },
654
+ }),
655
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 } },
656
+ h('span', { style: { flex: 1 } }),
657
+ smallButton('发送', () => void ctl.sendRoomMessage(), 'allow'),
658
+ ),
485
659
  ),
486
660
  h('div', { style: cardStyle },
487
661
  h('div', { style: sectionTitle }, '看板(' + detail.tasks.length + ')'),
488
662
  detail.tasks.length === 0
489
663
  ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无任务')
490
664
  : detail.tasks.map((task) =>
491
- h('div', { key: task.id, style: { padding: '4px 0', fontSize: 12 } },
665
+ h('div', { key: task.id, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', fontSize: 12 } },
492
666
  pill(task.status, task.status === 'done' ? 'ok' : 'muted'),
493
- task.title + ' · owner ' + task.owner + (task.dependsOn.length ? ' · deps ' + task.dependsOn.length : ''),
667
+ h('span', { style: { flex: 1 } }, task.title + ' · owner ' + task.owner + (task.dependsOn.length ? ' · deps ' + task.dependsOn.length : '')),
668
+ h('select', {
669
+ value: task.status,
670
+ onChange: (event) => void ctl.updateRoomTask(task.id, event.target.value),
671
+ style: { height: 24, borderRadius: 5, border: '1px solid var(--dsw-alias-border-l1, #333)', background: 'var(--dsw-alias-bg-layer-2, #262626)', color: 'inherit', fontSize: 11 },
672
+ },
673
+ ...['pending', 'running', 'done', 'blocked'].map((status) => h('option', { key: status, value: status }, status)),
674
+ ),
494
675
  ),
495
676
  ),
677
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' } },
678
+ h('input', {
679
+ type: 'text',
680
+ value: ctl.taskTitle,
681
+ onChange: (event) => ctl.setTaskTitle(event.target.value),
682
+ placeholder: '新任务标题',
683
+ style: { ...controlStyleWidth(180), height: 26, fontSize: 12 },
684
+ }),
685
+ h('select', {
686
+ value: ctl.taskOwner,
687
+ onChange: (event) => ctl.setTaskOwner(event.target.value),
688
+ style: { ...controlStyleWidth(180), height: 26, fontSize: 12 },
689
+ },
690
+ h('option', { value: '' }, '选择 owner'),
691
+ ...detail.members.map((member) => h('option', { key: member.memberId, value: member.memberId }, member.name)),
692
+ ),
693
+ smallButton('添加任务', () => void ctl.addRoomTask(), 'allow'),
694
+ ),
496
695
  ),
497
696
  h('div', { style: cardStyle },
498
697
  h('div', { style: sectionTitle }, '时间线'),
@@ -531,6 +730,7 @@ window.__ModuleLoader__.load({
531
730
  fontSize: 13,
532
731
  outline: 'none',
533
732
  }
733
+ const controlStyleWidth = (width) => ({ ...controlStyle, width })
534
734
  const selectStyle = {
535
735
  ...controlStyle,
536
736
  appearance: 'none',
@@ -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.16(稳定化)
42
+ ## 0.1.0-dev.18(稳定化)
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.16(审批收件箱,逻辑层)
48
+ ## 0.1.0-dev.18(审批收件箱,逻辑层)
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.16(Room 逻辑层,本机模拟)
54
+ ## 0.1.0-dev.18(Room 逻辑层,本机模拟)
55
55
 
56
56
  - `RoomService`:当前会话升级为 Room Host;本地模拟参与者(真实 DSH session/agent);
57
57
  - 寻址消息:`room_send` 只唤醒被点名成员,Host 会话写权威 `room/message` 事件;
@@ -59,35 +59,48 @@ 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.16(审批面板 UI,第一版)
62
+ ## 0.1.0-dev.18(审批面板 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
67
  - Client 视觉验证需要刷新页面;当前无浏览器控制,仅验证了 bundle 语法与 Host 路由注册。
68
- ## 0.1.0-dev.16(审批入口不遮挡)
68
+ ## 0.1.0-dev.18(审批入口不遮挡)
69
69
 
70
70
  - 触发按钮从右下角浮动改为 `sidebar.footer.action`(设置旁边),不再遮挡任务列表/内容面板;
71
71
  - 审批 inbox 改为点击后居中的模态对话框(带遮罩,Esc/点遮罩关闭);
72
72
  - 轮询在 Client apply 中启动并由 ctx.effect 清理;待审批数量作为按钮徽标;
73
73
  - 已实测:面板点击「允许」后 Host `fleet_approvals` 清空。
74
- ## 0.1.0-dev.16(Phase B:运行时配置 + 配置页)
74
+ ## 0.1.0-dev.18(Phase B:运行时配置 + 配置页)
75
75
 
76
76
  - `RuntimeConfig`:profile patch 为 base,UI 修改写入 `$DSH_HOME/device-fleet/config-overrides.json`,apply 时合并并持久化;
77
77
  - 保存即生效:policy/execution/approvals/deviceName/promptSection/autoApproveAsk 直接更新运行时对象;direct/discovery/relay 通过 `FleetNode.reconfigure()` 原地重启网络监听(不重启插件/DSH);`dataDir` 只读;
78
78
  - Host:`GET /device-fleet/config`、`POST /device-fleet/config`(校验/清洗后应用);
79
79
  - Client:注册 `plugins.row.config`(key `dsh-device-fleet#device-fleet`)与 `plugins.bundle.config`(key `dsh-device-fleet`),配置页含设备/网络、执行上下文、Tool Policy、审批与提示四组表单;
80
80
  - 单测 18/18、网络 9/9。
81
- ## 0.1.0-dev.16(配置页 DSH 化改版)
81
+ ## 0.1.0-dev.18(配置页 DSH 化改版)
82
82
 
83
83
  - Host 新增 `GET /device-fleet/options`:本机地址、可用 preset 列表、shell 工具、subagent providers、工作目录候选;
84
84
  - 配置保存前会 ensure 工作目录存在(Device Fleet 工作区自动创建);
85
85
  - 配置页重做:行式布局(label 左/控件右)、DSH token 配色、分组标题、去掉多余说明文字与 dataDir 展示;
86
86
  - 下拉化:直连地址、局域网发现、Relay 模式、工作目录(工作区/默认/DSH 主目录/自定义)、Agent Preset、Shell、委派 Provider、7 项能力权限、开关项;
87
87
  - 仍保留文本输入:设备名称、端口、超时、根目录/工具列表、自定义工作目录与 Relay 地址。
88
- ## 0.1.0-dev.16(房间详情与配置页修复)
88
+ ## 0.1.0-dev.18(房间详情与配置页修复)
89
89
 
90
90
  - 房间详情接口从 prefix 路由改为精确路由 `GET /device-fleet/room?id=...`,修复控制台一直「加载房间…」;客户端加载失败时显示错误文案;
91
91
  - 配置页宽行改为「标签在上、控件在下」,修复根目录等标签竖排换行;
92
92
  - 保存按钮改为固定品牌蓝底白字,修复按钮看起来空白;
93
- - 「允许的原始工具」改为「免审批即用的工具」:从 Host options 拉取本机真实工具清单,以复选框选择(不再手输工具名)。
93
+ - 「允许的原始工具」改为「免审批即用的工具」:从 Host options 拉取本机真实工具清单,以复选框选择(不再手输工具名)。
94
+ ## 0.1.0-dev.18(原始工具遵循 DSH 审批)
95
+
96
+ - 移除「免审批即用的工具」白名单;raw tool 调用不再由插件 allowlist 决定;
97
+ - `policy.capabilities.tool` 默认 `auto`:完全走 DSH 工具流水线(target Session 的 pre-execute policy + approval seam);`deny` 禁用 raw tools,`ask` 在 DSH 前加一层本机审批;
98
+ - 新增 `approval/request` 监听:Execution Slot 里 DSH 需要审批的工具调用会转到本设备审批收件箱(面板可处理),不需要审批的工具直接执行;
99
+ - 旧配置里 `tool: deny` + 空 allowedTools 自动迁移为 `auto`;
100
+ - 配置页移除工具复选框列表,「原始工具(遵循 DSH 审批)」保留 自动/询问/拒绝 下拉。
101
+ ## 0.1.0-dev.18(Phase C:交互动作)
102
+
103
+ - Room 用户动作:以用户身份发消息(选择收件人)、看板任务添加/状态更新、关闭房间、移除成员;
104
+ - 设备动作:生成/复制邀请码、手动地址连接(host+port);
105
+ - 审批历史:ApprovalInbox 记录最近 50 条已决请求(approved/denied/expired/closed),Console 审批 Tab 展示历史;
106
+ - 新增 Host 动作路由:`/room/send`、`/room/task`、`/room/close`、`/room/member/remove`、`/pair/invite`、`/pair/connect`、`/approvals/history`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.17",
3
+ "version": "0.1.0-dev.19",
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",
@@ -20,14 +20,38 @@ function summarizeArgs(args) {
20
20
  }
21
21
 
22
22
  export class ApprovalInbox extends EventEmitter {
23
- constructor({ ttlMs = 60_000, maxPending = 50, now = Date.now } = {}) {
23
+ constructor({ ttlMs = 60_000, maxPending = 50, historyLimit = 50, now = Date.now } = {}) {
24
24
  super()
25
25
  this.ttlMs = ttlMs
26
26
  this.maxPending = maxPending
27
+ this.historyLimit = historyLimit
27
28
  this.now = now
28
29
  this.closed = false
29
30
  /** @type {Map<string, any>} */
30
31
  this.pending = new Map()
32
+ /** @type {any[]} */
33
+ this.historyEntries = []
34
+ }
35
+
36
+ record(entry, outcome, extra = {}) {
37
+ this.historyEntries.unshift({
38
+ id: entry.id,
39
+ fromDeviceId: entry.fromDeviceId,
40
+ capability: entry.capability,
41
+ tool: entry.tool,
42
+ reason: entry.reason,
43
+ argsSummary: entry.argsSummary,
44
+ createdAt: entry.createdAt,
45
+ resolvedAt: this.now(),
46
+ outcome,
47
+ ...extra,
48
+ })
49
+ if (this.historyEntries.length > this.historyLimit) this.historyEntries.length = this.historyLimit
50
+ }
51
+
52
+ /** Recently resolved requests (newest first). */
53
+ history() {
54
+ return this.historyEntries.map((entry) => ({ ...entry }))
31
55
  }
32
56
 
33
57
  /**
@@ -89,6 +113,7 @@ export class ApprovalInbox extends EventEmitter {
89
113
  if (!entry) throw Object.assign(new Error(`unknown approval request: ${String(id)}`), { code: 'unknown-request' })
90
114
  const chosen = APPROVAL_SCOPES.includes(scope) ? scope : 'once'
91
115
  entry.resolve({ approved: true, scope: chosen })
116
+ this.record(entry, 'approved', { scope: chosen })
92
117
  this.emit('approved', { ...this.summary(entry), scope: chosen })
93
118
  return { ...this.summary(entry), scope: chosen }
94
119
  }
@@ -97,6 +122,7 @@ export class ApprovalInbox extends EventEmitter {
97
122
  const entry = this.settle(id, 'denied')
98
123
  if (!entry) throw Object.assign(new Error(`unknown approval request: ${String(id)}`), { code: 'unknown-request' })
99
124
  entry.resolve({ approved: false, reason })
125
+ this.record(entry, 'denied', { reason })
100
126
  this.emit('denied', { ...this.summary(entry), reason })
101
127
  return { ...this.summary(entry), reason }
102
128
  }
@@ -108,6 +134,7 @@ export class ApprovalInbox extends EventEmitter {
108
134
  this.pending.delete(id)
109
135
  clearTimeout(entry.timer)
110
136
  entry.resolve({ approved: false, reason: 'approval-timeout' })
137
+ this.record(entry, 'expired')
111
138
  this.emit('expired', this.summary(entry))
112
139
  }
113
140
 
@@ -128,6 +155,7 @@ export class ApprovalInbox extends EventEmitter {
128
155
  clearTimeout(entry.timer)
129
156
  entry.status = 'closed'
130
157
  entry.resolve({ approved: false, reason: 'inbox-closed' })
158
+ this.record(entry, 'closed')
131
159
  }
132
160
  this.pending.clear()
133
161
  this.emit('closed')
@@ -1,5 +1,7 @@
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.
1
+ // Read/action HTTP surface for the Fleet console: device overview, room list and
2
+ // detail, room user actions, pairing actions, and approval history.
3
+ const MAX_BODY_BYTES = 128 * 1024
4
+
3
5
  function json(res, status, value) {
4
6
  res.statusCode = status
5
7
  res.setHeader('content-type', 'application/json; charset=utf-8')
@@ -7,75 +9,118 @@ function json(res, status, value) {
7
9
  res.end(JSON.stringify(value))
8
10
  }
9
11
 
10
- function pathOf(request) {
12
+ async function readJsonBody(req) {
13
+ const chunks = []
14
+ let size = 0
15
+ for await (const chunk of req) {
16
+ size += chunk.length
17
+ if (size > MAX_BODY_BYTES) throw Object.assign(new Error('request body is too large'), { code: 'body-too-large' })
18
+ chunks.push(chunk)
19
+ }
20
+ if (chunks.length === 0) return {}
11
21
  try {
12
- return new URL(request.url ?? '/', 'http://local').pathname
22
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
13
23
  } catch {
14
- return '/'
24
+ throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
15
25
  }
16
26
  }
17
27
 
28
+ function statusFor(error) {
29
+ if (error?.code === 'unknown-room') return 404
30
+ return ['bad-request', 'bad-json', 'body-too-large', 'readonly', 'unknown-member', 'unknown-task'].includes(error?.code)
31
+ ? 400
32
+ : 500
33
+ }
34
+
35
+ function actionRoute(webServer, path, method, run) {
36
+ return webServer.register({
37
+ kind: 'exact',
38
+ path,
39
+ handler: async (req, res) => {
40
+ if (req.method !== method) return json(res, 405, { error: 'method not allowed' })
41
+ try {
42
+ const body = method === 'POST' ? await readJsonBody(req) : {}
43
+ json(res, 200, (await run(body, req)) ?? { ok: true })
44
+ } catch (error) {
45
+ json(res, statusFor(error), { error: String(error?.message ?? error), code: error?.code ?? 'error' })
46
+ }
47
+ },
48
+ })
49
+ }
50
+
18
51
  /**
19
52
  * @param {object} options
20
53
  * @param {object} options.webServer ctx.webServer
21
54
  * @param {object} options.inbox ApprovalInbox
22
55
  * @param {object} options.rooms RoomService
23
56
  * @param {() => object} options.getOverview
24
- * @returns {() => void} disposer
57
+ * @param {object} [options.actions] UI actions (room send/task/close, pairing)
25
58
  */
26
- export function registerUiRoutes({ webServer, inbox, rooms, getOverview, logger = null }) {
59
+ export function registerUiRoutes({ webServer, inbox, rooms, getOverview, actions = null, logger = null }) {
27
60
  if (!webServer?.register || !rooms) return () => {}
28
61
  const disposers = []
29
62
  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
- },
63
+ actionRoute(webServer, '/device-fleet/overview', 'GET', async () => ({
64
+ ...getOverview(),
65
+ approvals: inbox?.list?.() ?? [],
66
+ approvalHistory: inbox?.history?.() ?? [],
67
+ })),
68
+ )
69
+ disposers.push(
70
+ actionRoute(webServer, '/device-fleet/rooms', 'GET', async () => ({ rooms: rooms.list() })),
71
+ )
72
+ disposers.push(
73
+ actionRoute(webServer, '/device-fleet/room', 'GET', async (_body, req) => {
74
+ let roomId = ''
75
+ try {
76
+ roomId = new URL(req.url ?? '/', 'http://local').searchParams.get('id') ?? ''
77
+ } catch {
78
+ roomId = ''
79
+ }
80
+ if (roomId === '') throw Object.assign(new Error('room id is required'), { code: 'bad-request' })
81
+ return { room: rooms.detail(roomId, { limit: 100 }) }
82
+ }),
83
+ )
84
+ disposers.push(
85
+ actionRoute(webServer, '/device-fleet/room/send', 'POST', async (body) => {
86
+ if (!actions?.sendRoomMessage) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
87
+ return { ok: true, result: await actions.sendRoomMessage(body ?? {}) }
88
+ }),
89
+ )
90
+ disposers.push(
91
+ actionRoute(webServer, '/device-fleet/room/task', 'POST', async (body) => {
92
+ if (!actions?.roomTask) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
93
+ return { ok: true, result: await actions.roomTask(body ?? {}) }
94
+ }),
95
+ )
96
+ disposers.push(
97
+ actionRoute(webServer, '/device-fleet/room/close', 'POST', async (body) => {
98
+ if (!actions?.closeRoom) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
99
+ return { ok: true, result: await actions.closeRoom(body ?? {}) }
100
+ }),
101
+ )
102
+ disposers.push(
103
+ actionRoute(webServer, '/device-fleet/room/member/remove', 'POST', async (body) => {
104
+ if (!actions?.removeMember) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
105
+ return { ok: true, result: await actions.removeMember(body ?? {}) }
42
106
  }),
43
107
  )
44
108
  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
- },
109
+ actionRoute(webServer, '/device-fleet/pair/invite', 'POST', async () => {
110
+ if (!actions?.createInvite) throw Object.assign(new Error('pairing actions are unavailable'), { code: 'unsupported' })
111
+ return { ok: true, code: await actions.createInvite() }
56
112
  }),
57
113
  )
58
114
  disposers.push(
59
- webServer.register({
60
- kind: 'exact',
61
- path: '/device-fleet/room',
62
- handler: async (req, res) => {
63
- if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
64
- let roomId = ''
65
- try {
66
- roomId = new URL(req.url ?? '/', 'http://local').searchParams.get('id') ?? ''
67
- } catch {
68
- roomId = ''
69
- }
70
- if (roomId === '') return json(res, 400, { error: 'room id is required' })
71
- try {
72
- json(res, 200, { room: rooms.detail(roomId, { limit: 100 }) })
73
- } catch (error) {
74
- json(res, error?.code === 'unknown-room' ? 404 : 500, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
75
- }
76
- },
115
+ actionRoute(webServer, '/device-fleet/pair/connect', 'POST', async (body) => {
116
+ if (!actions?.connectAddress) throw Object.assign(new Error('pairing actions are unavailable'), { code: 'unsupported' })
117
+ return { ok: true, result: await actions.connectAddress(body ?? {}) }
77
118
  }),
78
119
  )
120
+ disposers.push(
121
+ actionRoute(webServer, '/device-fleet/approvals/history', 'GET', async () => ({ history: inbox?.history?.() ?? [] })),
122
+ )
123
+ if (logger) logger.debug?.('[device-fleet] UI action routes registered')
79
124
  return () => {
80
125
  for (const dispose of disposers) dispose()
81
126
  }
@@ -278,6 +278,7 @@ export function apply(ctx, profileConfig) {
278
278
  },
279
279
  }
280
280
  }
281
+ let pairingRef = null
281
282
  if (webServer) {
282
283
  ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
283
284
  ctx.effect(() =>
@@ -299,16 +300,55 @@ export function apply(ctx, profileConfig) {
299
300
  logger,
300
301
  }),
301
302
  )
303
+ const uiActions = {
304
+ async sendRoomMessage({ roomId, to, text } = {}) {
305
+ if (typeof roomId !== 'string' || roomId === '') throw Object.assign(new Error('roomId is required'), { code: 'bad-request' })
306
+ const memberIds = rooms.status(roomId).members.map((member) => member.memberId)
307
+ const recipients = Array.isArray(to) && to.length > 0 ? to : memberIds.filter((id) => id.startsWith('host:'))
308
+ const result = await rooms.send({ roomId, from: { kind: 'user' }, to: recipients, text })
309
+ return { roomId, ...result }
310
+ },
311
+ async roomTask({ roomId, action, ...rest } = {}) {
312
+ if (typeof roomId !== 'string' || roomId === '') throw Object.assign(new Error('roomId is required'), { code: 'bad-request' })
313
+ if (action === 'add') {
314
+ return rooms.addTask({ roomId, title: rest.title, owner: rest.owner, dependsOn: rest.dependsOn ?? [], description: rest.description ?? '' })
315
+ }
316
+ if (action === 'update') {
317
+ return rooms.updateTask({ roomId, id: rest.id, status: rest.status, note: rest.note })
318
+ }
319
+ throw Object.assign(new Error('action must be add or update'), { code: 'bad-request' })
320
+ },
321
+ closeRoom: ({ roomId, reason } = {}) => rooms.close({ roomId, reason: reason ?? 'closed by user' }),
322
+ removeMember: ({ roomId, memberId, reason } = {}) => rooms.removeMember({ roomId, memberId, reason: reason ?? 'removed by user' }),
323
+ createInvite: async () => {
324
+ if (!pairingRef?.invite) throw Object.assign(new Error('pairing is unavailable'), { code: 'unsupported' })
325
+ return pairingRef.invite()
326
+ },
327
+ connectAddress: async ({ host, port } = {}) => {
328
+ if (typeof host !== 'string' || !Number.isInteger(port) || port <= 0) {
329
+ throw Object.assign(new Error('host and port are required'), { code: 'bad-request' })
330
+ }
331
+ const peer = await node.connectAddress(host, port)
332
+ try {
333
+ savePeerHint(state, { host, port })
334
+ } catch {
335
+ /* best effort */
336
+ }
337
+ return { deviceId: peer.deviceId, name: peer.name ?? 'device' }
338
+ },
339
+ }
302
340
  ctx.effect(() =>
303
341
  registerUiRoutes({
304
342
  webServer,
305
343
  inbox,
306
344
  rooms,
307
345
  logger,
346
+ actions: uiActions,
308
347
  getOverview: () => ({
309
348
  ...(node.status?.() ?? {}),
310
349
  rooms: rooms.list(),
311
350
  approvals: inbox.list(),
351
+ approvalHistory: inbox.history?.() ?? [],
312
352
  }),
313
353
  }),
314
354
  )
@@ -441,6 +481,7 @@ export function apply(ctx, profileConfig) {
441
481
  writePendingEnrollment(state, code)
442
482
  },
443
483
  }
484
+ pairingRef = pairing
444
485
  const handlers = createFleetHandlers({
445
486
  node,
446
487
  client,