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

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
@@ -10,7 +10,8 @@ DeepSeek Harness 的设备联邦插件:把登录同一账号、且都安装本
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
12
  - ✅ **审批面板 UI(dev.12,已验证)**:Host `/device-fleet/approvals` HTTP 数据面 + Client 入口在 Settings 上方、居中审批对话框(允许一次/15 分钟/拒绝);用户点击「允许」后 Host 列表清空,端到端闭环
13
- - ⏳ 远程参与者传输、房间视图、插件重载 rehydrate、SSE 推送(下一步)
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
15
 
15
16
  ## 安装(开发版)
16
17
 
package/client.js CHANGED
@@ -501,6 +501,274 @@ window.__ModuleLoader__.load({
501
501
  );
502
502
  }
503
503
 
504
+ const inputStyle = {
505
+ width: '100%',
506
+ boxSizing: 'border-box',
507
+ padding: '5px 8px',
508
+ borderRadius: 6,
509
+ border: '1px solid var(--dsw-alias-border, #3a3a3a)',
510
+ background: 'transparent',
511
+ color: 'inherit',
512
+ fontSize: 13,
513
+ };
514
+ const labelStyle = { display: 'block', margin: '4px 0 10px' };
515
+ const labelTextStyle = { fontSize: 12, opacity: 0.72, marginBottom: 3 };
516
+
517
+ function Field({ label, hint, children }) {
518
+ return h('label', { style: labelStyle }, h('div', { style: labelTextStyle }, label), children, hint ? h('div', { style: { fontSize: 11, opacity: 0.55, marginTop: 2 } }, hint) : null);
519
+ }
520
+
521
+ function TextField({ label, value, onChange, hint, type = 'text' }) {
522
+ return h(Field, { label, hint }, h('input', { type, value: value ?? '', onChange: (event) => onChange(event.target.value), style: inputStyle }));
523
+ }
524
+
525
+ function NumberField({ label, value, onChange, hint }) {
526
+ return h(Field, { label, hint }, h('input', { type: 'number', value: value ?? 0, onChange: (event) => onChange(Number(event.target.value || 0)), style: inputStyle }));
527
+ }
528
+
529
+ function ToggleField({ label, checked, onChange }) {
530
+ return h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 10px', fontSize: 13 } },
531
+ h('input', { type: 'checkbox', checked: checked === true, onChange: (event) => onChange(event.target.checked) }),
532
+ h('span', null, label),
533
+ );
534
+ }
535
+
536
+ function SelectField({ label, value, options, onChange }) {
537
+ return h(Field, { label }, h('select', { value, onChange: (event) => onChange(event.target.value), style: inputStyle },
538
+ ...options.map((option) => h('option', { key: option, value: option }, option)),
539
+ ));
540
+ }
541
+
542
+ function configToDraft(config = {}) {
543
+ const execution = config.execution ?? {};
544
+ const policy = config.policy ?? {};
545
+ const approvals = config.approvals ?? {};
546
+ const direct = config.direct ?? {};
547
+ const relay = config.relay ?? {};
548
+ return {
549
+ deviceName: config.deviceName ?? '',
550
+ directHost: direct.host ?? '0.0.0.0',
551
+ directPort: direct.port ?? 0,
552
+ discovery: config.discovery !== false,
553
+ relayHost: relay.host ?? '',
554
+ relayPort: relay.port ?? 0,
555
+ relayDataPort: relay.dataPort ?? 0,
556
+ execCwd: execution.cwd ?? '',
557
+ execPreset: execution.agentPreset ?? '',
558
+ execShellTool: execution.shellTool ?? '',
559
+ execApprovalTimeoutMs: execution.approvalTimeoutMs ?? 60000,
560
+ execDelegateTimeoutMs: execution.delegateTimeoutMs ?? 180000,
561
+ execSubagentProvider: execution.subagentProvider ?? 'spawn',
562
+ rootsText: (policy.allowedRoots ?? []).join('\n'),
563
+ toolsText: (policy.allowedTools ?? []).join('\n'),
564
+ capRead: policy.capabilities?.read ?? 'auto',
565
+ capList: policy.capabilities?.list ?? 'auto',
566
+ capGrep: policy.capabilities?.grep ?? 'auto',
567
+ capFetch: policy.capabilities?.fetch ?? 'auto',
568
+ capRun: policy.capabilities?.run ?? 'ask',
569
+ capTool: policy.capabilities?.tool ?? 'deny',
570
+ capDelegate: policy.capabilities?.delegate ?? 'ask',
571
+ policyApprovalTtlMs: policy.approvalTtlMs ?? 900000,
572
+ approvalsTtlMs: approvals.ttlMs ?? 60000,
573
+ approvalsMaxPending: approvals.maxPending ?? 50,
574
+ autoApproveAsk: config.autoApproveAsk === true,
575
+ promptSection: config.promptSection !== false,
576
+ simulation: approvals.simulation === true,
577
+ dataDir: config.dataDir ?? '',
578
+ };
579
+ }
580
+
581
+ function draftToPatch(draft) {
582
+ return {
583
+ deviceName: draft.deviceName,
584
+ direct: { host: draft.directHost, port: draft.directPort },
585
+ discovery: draft.discovery,
586
+ relay: draft.relayHost && draft.relayPort > 0
587
+ ? { host: draft.relayHost, port: draft.relayPort, dataPort: draft.relayDataPort }
588
+ : null,
589
+ execution: {
590
+ cwd: draft.execCwd,
591
+ agentPreset: draft.execPreset,
592
+ shellTool: draft.execShellTool,
593
+ approvalTimeoutMs: draft.execApprovalTimeoutMs,
594
+ delegateTimeoutMs: draft.execDelegateTimeoutMs,
595
+ subagentProvider: draft.execSubagentProvider,
596
+ },
597
+ policy: {
598
+ allowedRoots: draft.rootsText.split('\n').map((item) => item.trim()).filter(Boolean),
599
+ allowedTools: draft.toolsText.split('\n').map((item) => item.trim()).filter(Boolean),
600
+ capabilities: {
601
+ read: draft.capRead,
602
+ list: draft.capList,
603
+ grep: draft.capGrep,
604
+ fetch: draft.capFetch,
605
+ run: draft.capRun,
606
+ tool: draft.capTool,
607
+ delegate: draft.capDelegate,
608
+ },
609
+ approvalTtlMs: draft.policyApprovalTtlMs,
610
+ },
611
+ approvals: {
612
+ ttlMs: draft.approvalsTtlMs,
613
+ maxPending: draft.approvalsMaxPending,
614
+ simulation: draft.simulation,
615
+ },
616
+ autoApproveAsk: draft.autoApproveAsk,
617
+ promptSection: draft.promptSection,
618
+ };
619
+ }
620
+
621
+ function FleetConfig() {
622
+ const [draft, setDraft] = useState(null);
623
+ const [status, setStatus] = useState('');
624
+ const [busy, setBusy] = useState(false);
625
+
626
+ const load = async () => {
627
+ try {
628
+ const response = await fetch('/device-fleet/config', { headers: { accept: 'application/json' } });
629
+ if (!response.ok) throw new Error('HTTP ' + response.status);
630
+ const data = await response.json();
631
+ setDraft(configToDraft(data.config));
632
+ } catch (problem) {
633
+ setStatus('加载配置失败:' + String(problem.message ?? problem));
634
+ }
635
+ };
636
+
637
+ useEffect(() => {
638
+ void load();
639
+ }, []);
640
+
641
+ if (!draft) return h('div', { style: { padding: 8, opacity: 0.7 } }, status || '加载中…');
642
+
643
+ const set = (key, value) => setDraft((current) => ({ ...current, [key]: value }));
644
+ const save = async () => {
645
+ setBusy(true);
646
+ setStatus('');
647
+ try {
648
+ const response = await fetch('/device-fleet/config', {
649
+ method: 'POST',
650
+ headers: { 'content-type': 'application/json' },
651
+ body: JSON.stringify(draftToPatch(draft)),
652
+ });
653
+ const data = await response.json();
654
+ if (!response.ok) throw new Error(data.error ?? 'HTTP ' + response.status);
655
+ setDraft(configToDraft(data.config));
656
+ setStatus('已应用(无需重启插件或 DSH)');
657
+ } catch (problem) {
658
+ setStatus('保存失败:' + String(problem.message ?? problem));
659
+ } finally {
660
+ setBusy(false);
661
+ }
662
+ };
663
+
664
+ const sectionCard = (title, children, hint) =>
665
+ h('div', { style: { ...cardStyle, padding: 14 } },
666
+ h('div', { style: sectionTitle }, title),
667
+ hint ? h('div', { style: { fontSize: 11, opacity: 0.55, marginBottom: 8 } }, hint) : null,
668
+ children,
669
+ );
670
+
671
+ const capabilityFields = [
672
+ ['capRead', 'read 读取'],
673
+ ['capList', 'list 列目录'],
674
+ ['capGrep', 'grep 搜索'],
675
+ ['capFetch', 'fetch 取文件'],
676
+ ['capRun', 'run 执行命令'],
677
+ ['capTool', 'tool 原始工具'],
678
+ ['capDelegate', 'delegate 委派'],
679
+ ];
680
+ const twoCol = (left, right) => h('div', { style: { display: 'flex', gap: 12 } }, h('div', { style: { flex: 1 } }, left), h('div', { style: { flex: 1 } }, right));
681
+
682
+ return h('div', { style: { padding: '2px 2px 24px', maxWidth: 760 } },
683
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10, flexWrap: 'wrap' } },
684
+ h('strong', null, 'Device Fleet 配置'),
685
+ h('span', { style: { flex: 1 } }),
686
+ status ? h('span', { style: { fontSize: 12, opacity: 0.8 } }, status) : null,
687
+ h('button', {
688
+ type: 'button',
689
+ disabled: busy,
690
+ onClick: () => void save(),
691
+ style: {
692
+ padding: '6px 14px',
693
+ borderRadius: 7,
694
+ border: '1px solid var(--dsw-alias-accent, #2f6fed)',
695
+ background: 'var(--dsw-alias-accent, #2f6fed)',
696
+ color: '#fff',
697
+ cursor: busy ? 'default' : 'pointer',
698
+ opacity: busy ? 0.6 : 1,
699
+ },
700
+ }, busy ? '保存中…' : '保存并立即生效'),
701
+ ),
702
+ h('div', { style: { fontSize: 12, opacity: 0.6, marginBottom: 12 } }, '保存后立即对运行中的插件生效,不需要重启插件或 DSH。dataDir 为只读(启动路径)。'),
703
+ sectionCard('设备与网络',
704
+ h('div', null,
705
+ h(TextField, { label: '设备名称', value: draft.deviceName, onChange: (value) => set('deviceName', value) }),
706
+ twoCol(
707
+ h(TextField, { label: '直连绑定地址', value: draft.directHost, onChange: (value) => set('directHost', value) }),
708
+ h(NumberField, { label: '直连端口(0=随机)', value: draft.directPort, onChange: (value) => set('directPort', value) }),
709
+ ),
710
+ h(ToggleField, { label: '局域网发现(UDP 组播)', checked: draft.discovery, onChange: (value) => set('discovery', value) }),
711
+ h('div', { style: { ...labelTextStyle, marginTop: 10 } }, '自建 Relay(v2,可选)'),
712
+ twoCol(
713
+ h(TextField, { label: 'Relay host', value: draft.relayHost, onChange: (value) => set('relayHost', value) }),
714
+ h(NumberField, { label: 'Relay control 端口', value: draft.relayPort, onChange: (value) => set('relayPort', value) }),
715
+ ),
716
+ h(NumberField, { label: 'Relay data 端口', value: draft.relayDataPort, onChange: (value) => set('relayDataPort', value) }),
717
+ )),
718
+ sectionCard('执行上下文(远端调用/委派在本机执行时使用)',
719
+ h('div', null,
720
+ twoCol(
721
+ h(TextField, { label: '工作目录 cwd', value: draft.execCwd, onChange: (value) => set('execCwd', value) }),
722
+ h(TextField, { label: 'Agent Preset', value: draft.execPreset, onChange: (value) => set('execPreset', value) }),
723
+ ),
724
+ twoCol(
725
+ h(TextField, { label: 'shell 工具名', value: draft.execShellTool, onChange: (value) => set('execShellTool', value) }),
726
+ h(TextField, { label: '委派 subagent provider', value: draft.execSubagentProvider, onChange: (value) => set('execSubagentProvider', value) }),
727
+ ),
728
+ twoCol(
729
+ h(NumberField, { label: '审批超时 (ms)', value: draft.execApprovalTimeoutMs, onChange: (value) => set('execApprovalTimeoutMs', value) }),
730
+ h(NumberField, { label: '委派超时 (ms)', value: draft.execDelegateTimeoutMs, onChange: (value) => set('execDelegateTimeoutMs', value) }),
731
+ ),
732
+ )),
733
+ sectionCard('远端权限(Tool Policy:别人能对你做什么)',
734
+ h('div', null,
735
+ h(Field, { label: '允许访问的根目录(每行一个)' },
736
+ h('textarea', {
737
+ value: draft.rootsText,
738
+ onChange: (event) => set('rootsText', event.target.value),
739
+ rows: 3,
740
+ style: { ...inputStyle, fontFamily: 'ui-monospace, monospace', resize: 'vertical' },
741
+ }),
742
+ ),
743
+ h(Field, { label: '允许的原始工具(每行一个,配合 tool 能力)' },
744
+ h('textarea', {
745
+ value: draft.toolsText,
746
+ onChange: (event) => set('toolsText', event.target.value),
747
+ rows: 3,
748
+ style: { ...inputStyle, fontFamily: 'ui-monospace, monospace', resize: 'vertical' },
749
+ }),
750
+ ),
751
+ h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 12px' } },
752
+ ...capabilityFields.map(([key, label]) =>
753
+ h(SelectField, { key, label, value: draft[key], options: ['auto', 'ask', 'deny'], onChange: (value) => set(key, value) }),
754
+ ),
755
+ ),
756
+ h(NumberField, { label: '一次授权的 TTL (ms)', value: draft.policyApprovalTtlMs, onChange: (value) => set('policyApprovalTtlMs', value) }),
757
+ )),
758
+ sectionCard('本地审批与提示',
759
+ h('div', null,
760
+ twoCol(
761
+ h(NumberField, { label: '审批请求超时 (ms)', value: draft.approvalsTtlMs, onChange: (value) => set('approvalsTtlMs', value) }),
762
+ h(NumberField, { label: '最多排队请求数', value: draft.approvalsMaxPending, onChange: (value) => set('approvalsMaxPending', value) }),
763
+ ),
764
+ h(ToggleField, { label: '自动批准所有审批请求(仅测试)', checked: draft.autoApproveAsk, onChange: (value) => set('autoApproveAsk', value) }),
765
+ h(ToggleField, { label: '系统提示词中的 Fleet 提示', checked: draft.promptSection, onChange: (value) => set('promptSection', value) }),
766
+ h(ToggleField, { label: '允许 fleet_simulate_request(测试用)', checked: draft.simulation, onChange: (value) => set('simulation', value) }),
767
+ h(TextField, { label: '状态目录 dataDir(只读,需重启)', value: draft.dataDir, onChange: () => {} }),
768
+ )),
769
+ );
770
+ }
771
+
504
772
  return {
505
773
  inject: ['slots'],
506
774
  apply(ctx) {
@@ -517,6 +785,13 @@ window.__ModuleLoader__.load({
517
785
  ctx.slots.register({ name: 'sidebar.panellist', id: 'fleet', order: 20, label: '设备联邦' }, FleetIcon),
518
786
  );
519
787
  ctx.slots.register({ name: 'main', key: 'fleet' }, FleetConsole);
788
+ // Configuration page: Settings → Plugins → Device Fleet (row + bundle).
789
+ ctx.slots.inject('plugins.row.config', () =>
790
+ ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-device-fleet#device-fleet' }, FleetConfig),
791
+ );
792
+ ctx.slots.inject('plugins.bundle.config', () =>
793
+ ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-device-fleet' }, FleetConfig),
794
+ );
520
795
  },
521
796
  };
522
797
  },
@@ -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.12(稳定化)
42
+ ## 0.1.0-dev.13(稳定化)
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.12(审批收件箱,逻辑层)
48
+ ## 0.1.0-dev.13(审批收件箱,逻辑层)
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.12(Room 逻辑层,本机模拟)
54
+ ## 0.1.0-dev.13(Room 逻辑层,本机模拟)
55
55
 
56
56
  - `RoomService`:当前会话升级为 Room Host;本地模拟参与者(真实 DSH session/agent);
57
57
  - 寻址消息:`room_send` 只唤醒被点名成员,Host 会话写权威 `room/message` 事件;
@@ -59,13 +59,13 @@ 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.12(审批面板 UI,第一版)
62
+ ## 0.1.0-dev.13(审批面板 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.12(审批入口不遮挡)
68
+ ## 0.1.0-dev.13(审批入口不遮挡)
69
69
 
70
70
  - 触发按钮从右下角浮动改为 `sidebar.footer.action`(设置旁边),不再遮挡任务列表/内容面板;
71
71
  - 审批 inbox 改为点击后居中的模态对话框(带遮罩,Esc/点遮罩关闭);
@@ -0,0 +1,44 @@
1
+ # Fleet 控制台 UI(Phase A,dev.13)
2
+
3
+ ## 用户决策(2026-11-09)
4
+
5
+ 1. **配置保存必须即时生效**:不重启插件、不重启 DSH。→ Phase B 采用"运行时配置 + 持久化 overrides"设计(下一阶段实现)。
6
+ 2. 房间 UI 动作身份:以**用户**身份(ADR-0007)。
7
+ 3. 实施顺序:A → B → C → D。
8
+
9
+ ## 本阶段实现
10
+
11
+ ### Host 读模型
12
+
13
+ `src/host/ui-web.js`:
14
+
15
+ - `GET /device-fleet/overview`:`FleetNode.status()` + rooms 摘要 + approvals 列表;
16
+ - `GET /device-fleet/rooms`:房间摘要列表;
17
+ - `GET /device-fleet/rooms/:id`:房间详情(成员、策略、transcript、看板任务)。
18
+
19
+ ### Room 持久化 / rehydrate
20
+
21
+ `RoomService` 新增 `stateFile`(`$DSH_HOME/device-fleet/rooms.json`):
22
+
23
+ - 每次 append(创建/成员/消息/任务/关闭)后原子写快照;
24
+ - 启动时 `load()` 重建 rooms/members/messages/tasks;
25
+ - 成员 agent 不序列化,唤醒时通过 `sessionController.resolveAgent(sessionId)` 懒解析;
26
+ - 插件卸载不再关闭房间(避免重载即销毁)。
27
+
28
+ **验证**:创建 room + worker-c + 任务 + 1 条消息 → 插件重载 → `room_status` 仍显示 2 名成员、1 条消息、1 个任务;`room_task_list` 保留任务。
29
+
30
+ ### Client
31
+
32
+ `client.js` 新增:
33
+
34
+ - `sidebar.panellist` 图标(id `fleet`,label `设备联邦`,圆形网格图标);
35
+ - `main` keyed 面板 `fleet` = Fleet 控制台,三个 Tab:
36
+ - **设备**:本机名称/id、直连地址、relay、发现数量;Fleet 成员列表(在线/离线);回连地址;
37
+ - **房间**:房间列表(成员数/消息数/任务数),选中后展示成员、看板、时间线;
38
+ - **审批**:待审批列表 + 允许一次/15 分钟/拒绝(与快捷面板共用 Host API)。
39
+
40
+ ## 未完成(Phase B/C/D)
41
+
42
+ - 配置页与即时生效(Phase B);
43
+ - 房间交互:以用户身份发消息、看板操作、邀请/移除成员(Phase C);
44
+ - 配对向导、设备操作、审批历史/授权撤销、SSE、通知、i18n、a11y(Phase C/D)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.13",
3
+ "version": "0.1.0-dev.14",
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",
@@ -0,0 +1,61 @@
1
+ // Runtime config HTTP surface (dev.14): read the effective config and apply a
2
+ // patch live. The UI posts here; no plugin or DSH restart is involved.
3
+ const MAX_BODY_BYTES = 256 * 1024
4
+
5
+ function json(res, status, value) {
6
+ res.statusCode = status
7
+ res.setHeader('content-type', 'application/json; charset=utf-8')
8
+ res.setHeader('cache-control', 'no-store')
9
+ res.end(JSON.stringify(value))
10
+ }
11
+
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 {}
21
+ try {
22
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
23
+ } catch {
24
+ throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
25
+ }
26
+ }
27
+
28
+ export function registerConfigRoutes({ webServer, runtime, logger = null }) {
29
+ if (!webServer?.register || !runtime) return () => {}
30
+ const disposers = []
31
+ disposers.push(
32
+ webServer.register({
33
+ kind: 'exact',
34
+ path: '/device-fleet/config',
35
+ handler: async (req, res) => {
36
+ if (req.method === 'GET') {
37
+ try {
38
+ json(res, 200, runtime.snapshot())
39
+ } catch (error) {
40
+ json(res, 500, { error: String(error?.message ?? error) })
41
+ }
42
+ return
43
+ }
44
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
45
+ try {
46
+ const body = await readJsonBody(req)
47
+ const patch = body?.patch ?? body
48
+ const config = await runtime.update(patch)
49
+ json(res, 200, { ok: true, config, appliedAt: Date.now() })
50
+ } catch (error) {
51
+ const status = ['bad-request', 'bad-json', 'body-too-large', 'readonly'].includes(error?.code) ? 400 : 500
52
+ if (status === 500) logger?.warn?.(`[device-fleet] config update failed: ${String(error?.message ?? error)}`)
53
+ json(res, status, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
54
+ }
55
+ },
56
+ }),
57
+ )
58
+ return () => {
59
+ for (const dispose of disposers) dispose()
60
+ }
61
+ }
@@ -18,6 +18,14 @@ export class ExecutionSlot {
18
18
  this.initializing = null
19
19
  }
20
20
 
21
+ /** Replace the slot's creation config; the next call lazily recreates the agent. */
22
+ setConfig(next = {}) {
23
+ this.config = { ...this.config, ...(next ?? {}) }
24
+ this.agent = null
25
+ this.sessionId = null
26
+ this.initializing = null
27
+ }
28
+
21
29
  async ensureAgent() {
22
30
  if (this.agent) return this.agent
23
31
  if (this.initializing) return this.initializing
package/src/host/index.js CHANGED
@@ -7,5 +7,7 @@ export * from './delegation.js'
7
7
  export * from './approval-inbox.js'
8
8
  export * from './approval-web.js'
9
9
  export * from './ui-web.js'
10
+ export * from './config-web.js'
11
+ export * from './runtime-config.js'
10
12
  export * from './room-service.js'
11
13
  export * from './tools.js'
@@ -0,0 +1,192 @@
1
+ // Runtime configuration (ADR-0025): the profile patch stays the base layer,
2
+ // while `$DSH_HOME/device-fleet/config-overrides.json` holds UI edits. Applying
3
+ // a patch merges it, persists it and runs the registered live-apply handlers so
4
+ // no plugin/DSH restart is required.
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
6
+ import { dirname } from 'node:path'
7
+
8
+ const READONLY_FIELDS = ['dataDir']
9
+ const CAPABILITY_MODES = ['auto', 'ask', 'deny']
10
+
11
+ function isPlainObject(value) {
12
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
13
+ }
14
+
15
+ export function deepMerge(base, patch) {
16
+ if (!isPlainObject(patch)) return patch
17
+ const result = isPlainObject(base) ? { ...base } : {}
18
+ for (const [key, value] of Object.entries(patch)) {
19
+ if (value === undefined) continue
20
+ if (Array.isArray(value)) result[key] = value.map((item) => (isPlainObject(item) ? deepMerge({}, item) : item))
21
+ else if (isPlainObject(value)) result[key] = deepMerge(result[key], value)
22
+ else result[key] = value
23
+ }
24
+ return result
25
+ }
26
+
27
+ function readJson(file) {
28
+ if (!file || !existsSync(file)) return null
29
+ try {
30
+ return JSON.parse(readFileSync(file, 'utf8'))
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ function requirePositiveInteger(value, name, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
37
+ if (!Number.isInteger(value) || value < min || value > max) {
38
+ throw Object.assign(new Error(`${name} must be an integer between ${min} and ${max}`), { code: 'bad-request' })
39
+ }
40
+ return value
41
+ }
42
+
43
+ function stringList(value, name) {
44
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
45
+ throw Object.assign(new Error(`${name} must be an array of strings`), { code: 'bad-request' })
46
+ }
47
+ return value.map((item) => item.trim()).filter((item) => item !== '')
48
+ }
49
+
50
+ function optionalString(value, name) {
51
+ if (value === undefined || value === null) return ''
52
+ if (typeof value !== 'string') throw Object.assign(new Error(`${name} must be a string`), { code: 'bad-request' })
53
+ return value.trim()
54
+ }
55
+
56
+ /**
57
+ * Validate and clean a UI patch. Only known fields survive; unknown keys are
58
+ * dropped instead of leaking arbitrary objects into the effective config.
59
+ */
60
+ export function sanitizeConfigPatch(patch) {
61
+ if (!isPlainObject(patch)) throw Object.assign(new Error('config patch must be an object'), { code: 'bad-request' })
62
+ const clean = {}
63
+ if (patch.deviceName !== undefined) {
64
+ const name = optionalString(patch.deviceName, 'deviceName')
65
+ if (name !== '') clean.deviceName = name.slice(0, 80)
66
+ }
67
+ if (patch.promptSection !== undefined) clean.promptSection = patch.promptSection === true
68
+ if (patch.autoApproveAsk !== undefined) clean.autoApproveAsk = patch.autoApproveAsk === true
69
+ if (patch.discovery !== undefined) clean.discovery = patch.discovery === true
70
+ if (patch.dataDir !== undefined) {
71
+ throw Object.assign(new Error('dataDir cannot be changed at runtime'), { code: 'readonly' })
72
+ }
73
+ if (patch.direct !== undefined) {
74
+ if (!isPlainObject(patch.direct)) throw Object.assign(new Error('direct must be an object'), { code: 'bad-request' })
75
+ clean.direct = {
76
+ host: optionalString(patch.direct.host, 'direct.host') || '0.0.0.0',
77
+ port: requirePositiveInteger(Number(patch.direct.port ?? 0), 'direct.port', { min: 0, max: 65535 }),
78
+ }
79
+ }
80
+ if (patch.relay !== undefined) {
81
+ if (patch.relay === null) clean.relay = null
82
+ else if (isPlainObject(patch.relay)) {
83
+ clean.relay = {
84
+ host: optionalString(patch.relay.host, 'relay.host'),
85
+ port: requirePositiveInteger(Number(patch.relay.port ?? 0), 'relay.port', { min: 0, max: 65535 }),
86
+ dataPort: requirePositiveInteger(Number(patch.relay.dataPort ?? 0), 'relay.dataPort', { min: 0, max: 65535 }),
87
+ reconnect: patch.relay.reconnect !== false,
88
+ }
89
+ if (clean.relay.host === '' || clean.relay.port === 0) clean.relay = null
90
+ } else {
91
+ throw Object.assign(new Error('relay must be an object or null'), { code: 'bad-request' })
92
+ }
93
+ }
94
+ if (patch.execution !== undefined) {
95
+ if (!isPlainObject(patch.execution)) throw Object.assign(new Error('execution must be an object'), { code: 'bad-request' })
96
+ const execution = {}
97
+ if (patch.execution.cwd !== undefined) execution.cwd = optionalString(patch.execution.cwd, 'execution.cwd')
98
+ if (patch.execution.agentPreset !== undefined) execution.agentPreset = optionalString(patch.execution.agentPreset, 'execution.agentPreset')
99
+ if (patch.execution.shellTool !== undefined) execution.shellTool = optionalString(patch.execution.shellTool, 'execution.shellTool')
100
+ if (patch.execution.subagentProvider !== undefined) execution.subagentProvider = optionalString(patch.execution.subagentProvider, 'execution.subagentProvider')
101
+ if (patch.execution.approvalTimeoutMs !== undefined) {
102
+ execution.approvalTimeoutMs = requirePositiveInteger(Number(patch.execution.approvalTimeoutMs), 'execution.approvalTimeoutMs', { min: 1000 })
103
+ }
104
+ if (patch.execution.delegateTimeoutMs !== undefined) {
105
+ execution.delegateTimeoutMs = requirePositiveInteger(Number(patch.execution.delegateTimeoutMs), 'execution.delegateTimeoutMs', { min: 1000 })
106
+ }
107
+ clean.execution = execution
108
+ }
109
+ if (patch.policy !== undefined) {
110
+ if (!isPlainObject(patch.policy)) throw Object.assign(new Error('policy must be an object'), { code: 'bad-request' })
111
+ const policy = {}
112
+ if (patch.policy.allowedRoots !== undefined) policy.allowedRoots = stringList(patch.policy.allowedRoots, 'policy.allowedRoots')
113
+ if (patch.policy.allowedTools !== undefined) policy.allowedTools = stringList(patch.policy.allowedTools, 'policy.allowedTools')
114
+ if (patch.policy.approvalTtlMs !== undefined) {
115
+ policy.approvalTtlMs = requirePositiveInteger(Number(patch.policy.approvalTtlMs), 'policy.approvalTtlMs', { min: 1000 })
116
+ }
117
+ if (patch.policy.capabilities !== undefined) {
118
+ if (!isPlainObject(patch.policy.capabilities)) throw Object.assign(new Error('policy.capabilities must be an object'), { code: 'bad-request' })
119
+ const capabilities = {}
120
+ for (const [key, value] of Object.entries(patch.policy.capabilities)) {
121
+ if (!CAPABILITY_MODES.includes(value)) {
122
+ throw Object.assign(new Error(`policy.capabilities.${key} must be auto|ask|deny`), { code: 'bad-request' })
123
+ }
124
+ capabilities[key] = value
125
+ }
126
+ policy.capabilities = capabilities
127
+ }
128
+ clean.policy = policy
129
+ }
130
+ if (patch.approvals !== undefined) {
131
+ if (!isPlainObject(patch.approvals)) throw Object.assign(new Error('approvals must be an object'), { code: 'bad-request' })
132
+ const approvals = {}
133
+ if (patch.approvals.ttlMs !== undefined) approvals.ttlMs = requirePositiveInteger(Number(patch.approvals.ttlMs), 'approvals.ttlMs', { min: 1000 })
134
+ if (patch.approvals.maxPending !== undefined) approvals.maxPending = requirePositiveInteger(Number(patch.approvals.maxPending), 'approvals.maxPending', { min: 1 })
135
+ if (patch.approvals.simulation !== undefined) approvals.simulation = patch.approvals.simulation === true
136
+ clean.approvals = approvals
137
+ }
138
+ return clean
139
+ }
140
+
141
+ export class RuntimeConfig {
142
+ /**
143
+ * @param {object} options
144
+ * @param {object} options.base profile (Cordis) config
145
+ * @param {string} [options.overridesFile] JSON file edited from the UI
146
+ */
147
+ constructor({ base = {}, overridesFile = null } = {}) {
148
+ this.base = base
149
+ this.overridesFile = overridesFile
150
+ this.overrides = readJson(overridesFile) ?? {}
151
+ this.current = deepMerge(this.base, this.overrides)
152
+ this.applyHandlers = []
153
+ this.updating = Promise.resolve()
154
+ }
155
+
156
+ onChange(handler) {
157
+ this.applyHandlers.push(handler)
158
+ }
159
+
160
+ persist() {
161
+ if (!this.overridesFile) return
162
+ try {
163
+ mkdirSync(dirname(this.overridesFile), { recursive: true })
164
+ const temporary = `${this.overridesFile}.tmp`
165
+ writeFileSync(temporary, JSON.stringify(this.overrides, null, 2), 'utf8')
166
+ renameSync(temporary, this.overridesFile)
167
+ } catch {
168
+ /* overrides are best effort; the in-memory config is still applied */
169
+ }
170
+ }
171
+
172
+ /** Merge, persist and live-apply one patch. Returns the effective config. */
173
+ update(patch) {
174
+ const clean = sanitizeConfigPatch(patch)
175
+ return this.updating.then(async () => {
176
+ const previous = this.current
177
+ this.overrides = deepMerge(this.overrides, clean)
178
+ this.current = deepMerge(this.base, this.overrides)
179
+ this.persist()
180
+ for (const handler of this.applyHandlers) await handler(this.current, previous)
181
+ return this.current
182
+ })
183
+ }
184
+
185
+ snapshot() {
186
+ return {
187
+ config: this.current,
188
+ overrides: this.overrides,
189
+ readonly: READONLY_FIELDS,
190
+ }
191
+ }
192
+ }
@@ -489,6 +489,28 @@ export class FleetNode extends EventEmitter {
489
489
  return [...this.channels.keys()]
490
490
  }
491
491
 
492
+ /** Apply live network changes without recreating the node or its identity. */
493
+ async reconfigure({ name, direct, discovery, relay } = {}) {
494
+ if (this.closed) return
495
+ if (typeof name === 'string' && name !== '') this.name = name
496
+ const networkChanged = direct !== undefined || discovery !== undefined || relay !== undefined
497
+ if (!networkChanged) return
498
+ const wasStarted = this.started
499
+ await this.close()
500
+ this.closed = false
501
+ this.started = false
502
+ this.hintAttempt = 0
503
+ this.discovered.clear()
504
+ if (direct !== undefined) this.directConfig = direct
505
+ if (discovery !== undefined) this.discoveryConfig = discovery
506
+ if (relay !== undefined) this.relayConfig = relay
507
+ this.directServer = null
508
+ this.discovery = null
509
+ this.relayClient = null
510
+ this.directAddress = null
511
+ if (wasStarted) await this.start()
512
+ }
513
+
492
514
  async close() {
493
515
  this.closed = true
494
516
  clearTimeout(this.hintTimer)
@@ -19,6 +19,8 @@ import { ApprovalInbox } from '../host/approval-inbox.js'
19
19
  import { RoomService } from '../host/room-service.js'
20
20
  import { registerApprovalRoutes } from '../host/approval-web.js'
21
21
  import { registerUiRoutes } from '../host/ui-web.js'
22
+ import { registerConfigRoutes } from '../host/config-web.js'
23
+ import { RuntimeConfig } from '../host/runtime-config.js'
22
24
  import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
23
25
  import { ToolPolicy } from '../host/tool-policy.js'
24
26
  import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
@@ -128,7 +130,7 @@ function withManual(definitions) {
128
130
  }))
129
131
  }
130
132
 
131
- export function apply(ctx, config) {
133
+ export function apply(ctx, profileConfig) {
132
134
  const pluginVersion = packageVersion()
133
135
  const dshVersion = process.env.DSH_CLIENT_VERSION ?? 'unknown'
134
136
  // Optional services: Cordis throws on direct property access for services the
@@ -144,12 +146,20 @@ export function apply(ctx, config) {
144
146
 
145
147
  let state
146
148
  try {
147
- state = loadState({ dshHome: process.env.DSH_HOME, dataDir: config.dataDir, name: config.deviceName })
149
+ state = loadState({ dshHome: process.env.DSH_HOME, dataDir: profileConfig?.dataDir, name: profileConfig?.deviceName })
148
150
  } catch (error) {
149
151
  logger.error?.(`[device-fleet] cannot load state: ${String(error?.message ?? error)}`)
150
152
  return
151
153
  }
152
154
 
155
+ // Runtime config: profile patch is the base, UI edits are persisted in the
156
+ // state dir and applied live (no plugin/DSH restart).
157
+ const runtime = new RuntimeConfig({
158
+ base: profileConfig ?? {},
159
+ overridesFile: join(state.dataDir, 'config-overrides.json'),
160
+ })
161
+ const config = runtime.current
162
+
153
163
  const node = new FleetNode({
154
164
  identity: state.identity,
155
165
  fleet: state.fleet,
@@ -165,7 +175,7 @@ export function apply(ctx, config) {
165
175
  capabilities: CAPABILITIES,
166
176
  })
167
177
 
168
- const policy = new ToolPolicy(config.policy)
178
+ let policy = new ToolPolicy(config.policy)
169
179
  const sessionController = service('sessionController')
170
180
  const fs = service('fs')
171
181
  const subagents = service('subagents')
@@ -178,26 +188,26 @@ export function apply(ctx, config) {
178
188
  list: () => inbox.list(),
179
189
  approve: (id, options) => inbox.approve(id, options),
180
190
  deny: (id, reason) => inbox.deny(id, reason),
181
- simulate: config.approvals?.simulation
182
- ? async ({ fromDeviceId, capability, tool, reason }) => {
183
- const promise = inbox.request({
184
- fromDeviceId: fromDeviceId ?? 'simulated-device',
185
- capability: capability ?? 'run',
186
- tool: tool ?? null,
187
- reason: reason ?? 'simulated approval request',
188
- args: {},
189
- })
190
- const summary = inbox.list().at(-1)
191
- // Keep the promise from becoming an unhandled rejection when the
192
- // request expires without an answer; the demo only inspects the inbox.
193
- promise.catch(() => {})
194
- return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
195
- }
196
- : null,
191
+ simulate: async ({ fromDeviceId, capability, tool, reason }) => {
192
+ if (runtime.current.approvals?.simulation !== true) {
193
+ throw Object.assign(new Error('approval simulation is disabled'), { code: 'unsupported' })
194
+ }
195
+ const promise = inbox.request({
196
+ fromDeviceId: fromDeviceId ?? 'simulated-device',
197
+ capability: capability ?? 'run',
198
+ tool: tool ?? null,
199
+ reason: reason ?? 'simulated approval request',
200
+ args: {},
201
+ })
202
+ const summary = inbox.list().at(-1)
203
+ // Keep the promise from becoming an unhandled rejection when the
204
+ // request expires without an answer; the demo only inspects the inbox.
205
+ promise.catch(() => {})
206
+ return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
207
+ },
197
208
  }
198
- const requestApproval = config.autoApproveAsk
199
- ? async () => ({ approved: true, scope: 'once' })
200
- : async (request) => inbox.request(request)
209
+ const requestApproval = async (request) =>
210
+ runtime.current.autoApproveAsk === true ? { approved: true, scope: 'once' } : inbox.request(request)
201
211
 
202
212
  const rooms = new RoomService({
203
213
  sessionController,
@@ -210,6 +220,7 @@ export function apply(ctx, config) {
210
220
  const webServer = service('webServer')
211
221
  if (webServer) {
212
222
  ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
223
+ ctx.effect(() => registerConfigRoutes({ webServer, runtime, logger }))
213
224
  ctx.effect(() =>
214
225
  registerUiRoutes({
215
226
  webServer,
@@ -317,7 +328,7 @@ export function apply(ctx, config) {
317
328
  : (state.enrollment?.accountFingerprint ?? null)
318
329
  return createInvite(state, {
319
330
  accountFingerprint: fingerprint,
320
- relay: config.relay ?? state.enrollment?.relay ?? null,
331
+ relay: runtime.current.relay ?? state.enrollment?.relay ?? null,
321
332
  endpoints: localEndpoints(),
322
333
  })
323
334
  },
@@ -345,13 +356,14 @@ export function apply(ctx, config) {
345
356
 
346
357
  // Adoption layer 2: one sentence, only when the Fleet has members.
347
358
  const systemPrompt = service('systemPrompt')
348
- if (systemPrompt && config.promptSection !== false) {
359
+ if (systemPrompt) {
349
360
  const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
350
361
  ctx.effect(() =>
351
362
  systemPrompt.section({
352
363
  name: 'device-fleet:members',
353
364
  order,
354
365
  text: () => {
366
+ if (runtime.current.promptSection === false) return ''
355
367
  const members = node.rosterEntries().length
356
368
  if (members === 0) return ''
357
369
  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.`
@@ -360,6 +372,48 @@ export function apply(ctx, config) {
360
372
  )
361
373
  }
362
374
 
375
+ // Live config application: every UI save runs these handlers in-process.
376
+ runtime.onChange(async (next, previous) => {
377
+ if (JSON.stringify(next.policy) !== JSON.stringify(previous?.policy)) {
378
+ policy = new ToolPolicy(next.policy)
379
+ if (host) host.policy = policy
380
+ if (delegation) delegation.policy = policy
381
+ }
382
+ if (JSON.stringify(next.execution) !== JSON.stringify(previous?.execution)) {
383
+ slot?.setConfig(next.execution)
384
+ if (host) {
385
+ host.shellTool = next.execution?.shellTool ?? DEFAULT_SHELL_TOOL
386
+ host.approvalTimeoutMs = next.execution?.approvalTimeoutMs ?? 60_000
387
+ }
388
+ if (delegation) {
389
+ delegation.defaultCwd = next.execution?.cwd
390
+ delegation.defaultAgentPreset = next.execution?.agentPreset
391
+ delegation.provider = next.execution?.subagentProvider ?? 'spawn'
392
+ delegation.timeoutMs = next.execution?.delegateTimeoutMs ?? 180_000
393
+ }
394
+ }
395
+ if (JSON.stringify(next.approvals) !== JSON.stringify(previous?.approvals)) {
396
+ if (Number.isInteger(next.approvals?.ttlMs) && next.approvals.ttlMs >= 1000) inbox.ttlMs = next.approvals.ttlMs
397
+ if (Number.isInteger(next.approvals?.maxPending) && next.approvals.maxPending >= 1) inbox.maxPending = next.approvals.maxPending
398
+ }
399
+ if (next.deviceName !== previous?.deviceName) {
400
+ node.name = next.deviceName
401
+ if (host) host.name = next.deviceName
402
+ }
403
+ const networkChanged =
404
+ JSON.stringify(next.direct) !== JSON.stringify(previous?.direct) ||
405
+ next.discovery !== previous?.discovery ||
406
+ JSON.stringify(next.relay) !== JSON.stringify(previous?.relay)
407
+ if (networkChanged) {
408
+ await node.reconfigure({
409
+ name: next.deviceName,
410
+ direct: next.direct,
411
+ discovery: next.discovery ? true : false,
412
+ relay: next.relay ?? null,
413
+ })
414
+ }
415
+ })
416
+
363
417
  // Adoption layer 4: one-line nudge when the model starts orchestrating.
364
418
  ctx.on?.('tools/post-execute', async (exec, _result, next) => {
365
419
  const decision = await next()