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

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,383 @@ window.__ModuleLoader__.load({
501
501
  );
502
502
  }
503
503
 
504
+ // ---- DSH-styled configuration page -------------------------------------
505
+ const CONTROL_WIDTH = 260
506
+ const rowStyle = {
507
+ display: 'flex',
508
+ alignItems: 'center',
509
+ gap: 16,
510
+ minHeight: 42,
511
+ padding: '5px 0',
512
+ borderBottom: '1px solid var(--dsw-alias-border-l1, #333)',
513
+ }
514
+ const rowLabelStyle = { flex: 1, fontSize: 13, color: 'var(--dsw-alias-label-primary, #e8e8e8)' }
515
+ const controlStyle = {
516
+ width: CONTROL_WIDTH,
517
+ boxSizing: 'border-box',
518
+ height: 30,
519
+ padding: '3px 8px',
520
+ borderRadius: 6,
521
+ border: '1px solid var(--dsw-alias-border-l1, #333)',
522
+ background: 'var(--dsw-alias-bg-layer-2, #262626)',
523
+ color: 'var(--dsw-alias-label-primary, #e8e8e8)',
524
+ fontSize: 13,
525
+ outline: 'none',
526
+ }
527
+ const selectStyle = {
528
+ ...controlStyle,
529
+ appearance: 'none',
530
+ WebkitAppearance: 'none',
531
+ paddingRight: 24,
532
+ cursor: 'pointer',
533
+ }
534
+ const textareaStyle = {
535
+ ...controlStyle,
536
+ width: '100%',
537
+ height: 'auto',
538
+ minHeight: 64,
539
+ resize: 'vertical',
540
+ padding: '6px 8px',
541
+ lineHeight: 1.5,
542
+ fontFamily: 'ui-monospace, monospace',
543
+ }
544
+ const sectionStyle = {
545
+ margin: '22px 0 4px',
546
+ fontSize: 13,
547
+ fontWeight: 600,
548
+ color: 'var(--dsw-alias-label-primary, #e8e8e8)',
549
+ }
550
+
551
+ function Row({ label, children, wide = false }) {
552
+ return h('div', { style: rowStyle },
553
+ h('div', { style: rowLabelStyle }, label),
554
+ h('div', { style: wide ? { width: '100%' } : { width: CONTROL_WIDTH } }, children),
555
+ )
556
+ }
557
+
558
+ function SelectControl({ value, options, onChange }) {
559
+ return h('select', {
560
+ value: value ?? '',
561
+ onChange: (event) => onChange(event.target.value),
562
+ style: selectStyle,
563
+ },
564
+ ...(options ?? []).map((option) => {
565
+ const entry = typeof option === 'string' ? { value: option, label: option } : option
566
+ return h('option', { key: entry.value, value: entry.value }, entry.label)
567
+ }),
568
+ )
569
+ }
570
+
571
+ function TextControl({ value, onChange, placeholder }) {
572
+ return h('input', {
573
+ type: 'text',
574
+ value: value ?? '',
575
+ placeholder: placeholder ?? '',
576
+ onChange: (event) => onChange(event.target.value),
577
+ style: controlStyle,
578
+ })
579
+ }
580
+
581
+ function NumberControl({ value, onChange }) {
582
+ return h('input', {
583
+ type: 'number',
584
+ value: value ?? 0,
585
+ onChange: (event) => onChange(Number(event.target.value || 0)),
586
+ style: controlStyle,
587
+ })
588
+ }
589
+
590
+ function ToggleControl({ value, onChange }) {
591
+ return h(SelectControl, {
592
+ value: value ? 'on' : 'off',
593
+ options: [{ value: 'on', label: '开启' }, { value: 'off', label: '关闭' }],
594
+ onChange: (next) => onChange(next === 'on'),
595
+ })
596
+ }
597
+
598
+ const CAPABILITY_ROWS = [
599
+ ['capRead', '读取文件'],
600
+ ['capList', '列目录'],
601
+ ['capGrep', '搜索内容'],
602
+ ['capFetch', '获取文件'],
603
+ ['capRun', '执行命令'],
604
+ ['capTool', '调用原始工具'],
605
+ ['capDelegate', '委派任务'],
606
+ ]
607
+
608
+ function configToDraft(config = {}, options = {}) {
609
+ const execution = config.execution ?? {}
610
+ const policy = config.policy ?? {}
611
+ const approvals = config.approvals ?? {}
612
+ const direct = config.direct ?? {}
613
+ const relay = config.relay ?? null
614
+ const cwd = execution.cwd ?? ''
615
+ const cwdInfo = options.cwd ?? {}
616
+ let cwdMode = 'custom'
617
+ if (cwd === '') cwdMode = 'default'
618
+ else if (cwdInfo.workspace && cwd === cwdInfo.workspace) cwdMode = 'workspace'
619
+ else if (cwdInfo.dshHome && cwd === cwdInfo.dshHome) cwdMode = 'dshHome'
620
+ else if (cwdInfo.defaultDirectory && cwd === cwdInfo.defaultDirectory) cwdMode = 'default'
621
+ return {
622
+ deviceName: config.deviceName ?? '',
623
+ directHost: direct.host ?? '0.0.0.0',
624
+ directPort: direct.port ?? 0,
625
+ discovery: config.discovery !== false,
626
+ relayMode: relay && (relay.host || relay.port) ? 'custom' : 'off',
627
+ relayHost: relay?.host ?? '',
628
+ relayPort: relay?.port ?? 0,
629
+ relayDataPort: relay?.dataPort ?? 0,
630
+ cwdMode,
631
+ customCwd: cwdMode === 'custom' ? cwd : '',
632
+ execPreset: execution.agentPreset ?? '',
633
+ execShellTool: execution.shellTool ?? '',
634
+ execApprovalTimeoutMs: execution.approvalTimeoutMs ?? 60000,
635
+ execDelegateTimeoutMs: execution.delegateTimeoutMs ?? 180000,
636
+ execSubagentProvider: execution.subagentProvider ?? 'spawn',
637
+ rootsText: (policy.allowedRoots ?? []).join('\n'),
638
+ toolsText: (policy.allowedTools ?? []).join('\n'),
639
+ capRead: policy.capabilities?.read ?? 'auto',
640
+ capList: policy.capabilities?.list ?? 'auto',
641
+ capGrep: policy.capabilities?.grep ?? 'auto',
642
+ capFetch: policy.capabilities?.fetch ?? 'auto',
643
+ capRun: policy.capabilities?.run ?? 'ask',
644
+ capTool: policy.capabilities?.tool ?? 'deny',
645
+ capDelegate: policy.capabilities?.delegate ?? 'ask',
646
+ policyApprovalTtlMs: policy.approvalTtlMs ?? 900000,
647
+ approvalsTtlMs: approvals.ttlMs ?? 60000,
648
+ approvalsMaxPending: approvals.maxPending ?? 50,
649
+ autoApproveAsk: config.autoApproveAsk === true,
650
+ promptSection: config.promptSection !== false,
651
+ simulation: approvals.simulation === true,
652
+ }
653
+ }
654
+
655
+ function draftToPatch(draft, options = {}) {
656
+ const cwdInfo = options.cwd ?? {}
657
+ let cwd = ''
658
+ if (draft.cwdMode === 'workspace') cwd = cwdInfo.workspace ?? ''
659
+ else if (draft.cwdMode === 'dshHome') cwd = cwdInfo.dshHome ?? ''
660
+ else if (draft.cwdMode === 'default') cwd = cwdInfo.defaultDirectory || cwdInfo.workspace || ''
661
+ else if (draft.cwdMode === 'custom') cwd = draft.customCwd ?? ''
662
+ return {
663
+ deviceName: draft.deviceName,
664
+ direct: { host: draft.directHost, port: draft.directPort },
665
+ discovery: draft.discovery,
666
+ relay: draft.relayMode === 'custom'
667
+ ? { host: draft.relayHost, port: draft.relayPort, dataPort: draft.relayDataPort }
668
+ : null,
669
+ execution: {
670
+ cwd,
671
+ agentPreset: draft.execPreset,
672
+ shellTool: draft.execShellTool,
673
+ approvalTimeoutMs: draft.execApprovalTimeoutMs,
674
+ delegateTimeoutMs: draft.execDelegateTimeoutMs,
675
+ subagentProvider: draft.execSubagentProvider,
676
+ },
677
+ policy: {
678
+ allowedRoots: draft.rootsText.split('\n').map((item) => item.trim()).filter(Boolean),
679
+ allowedTools: draft.toolsText.split('\n').map((item) => item.trim()).filter(Boolean),
680
+ capabilities: {
681
+ read: draft.capRead,
682
+ list: draft.capList,
683
+ grep: draft.capGrep,
684
+ fetch: draft.capFetch,
685
+ run: draft.capRun,
686
+ tool: draft.capTool,
687
+ delegate: draft.capDelegate,
688
+ },
689
+ approvalTtlMs: draft.policyApprovalTtlMs,
690
+ },
691
+ approvals: {
692
+ ttlMs: draft.approvalsTtlMs,
693
+ maxPending: draft.approvalsMaxPending,
694
+ simulation: draft.simulation,
695
+ },
696
+ autoApproveAsk: draft.autoApproveAsk,
697
+ promptSection: draft.promptSection,
698
+ }
699
+ }
700
+
701
+ function FleetConfig() {
702
+ const [draft, setDraft] = useState(null)
703
+ const [options, setOptions] = useState({})
704
+ const [status, setStatus] = useState('')
705
+ const [busy, setBusy] = useState(false)
706
+
707
+ useEffect(() => {
708
+ let disposed = false
709
+ const load = async () => {
710
+ try {
711
+ const [configResponse, optionsResponse] = await Promise.all([
712
+ fetch('/device-fleet/config', { headers: { accept: 'application/json' } }),
713
+ fetch('/device-fleet/options', { headers: { accept: 'application/json' } }),
714
+ ])
715
+ const configData = await configResponse.json()
716
+ const optionsData = optionsResponse.ok ? await optionsResponse.json() : {}
717
+ if (disposed) return
718
+ setOptions(optionsData ?? {})
719
+ setDraft(configToDraft(configData.config ?? {}, optionsData ?? {}))
720
+ } catch (problem) {
721
+ if (!disposed) setStatus('加载失败')
722
+ }
723
+ }
724
+ void load()
725
+ return () => { disposed = true }
726
+ }, [])
727
+
728
+ if (!draft) return h('div', { style: { padding: 8, color: 'var(--dsw-alias-label-secondary, #999)' } }, status || '加载中…')
729
+
730
+ const set = (key, value) => setDraft((current) => ({ ...current, [key]: value }))
731
+ const save = async () => {
732
+ setBusy(true)
733
+ setStatus('')
734
+ try {
735
+ const response = await fetch('/device-fleet/config', {
736
+ method: 'POST',
737
+ headers: { 'content-type': 'application/json' },
738
+ body: JSON.stringify(draftToPatch(draft, options)),
739
+ })
740
+ const data = await response.json()
741
+ if (!response.ok) throw new Error(data.error ?? 'HTTP ' + response.status)
742
+ setDraft(configToDraft(data.config ?? {}, options))
743
+ setStatus('已保存')
744
+ } catch (problem) {
745
+ setStatus('保存失败:' + String(problem.message ?? problem))
746
+ } finally {
747
+ setBusy(false)
748
+ }
749
+ }
750
+
751
+ const cwdOptions = [
752
+ { value: 'workspace', label: 'Device Fleet 工作区' },
753
+ { value: 'default', label: 'DSH 默认目录' },
754
+ { value: 'dshHome', label: 'DSH 主目录' },
755
+ { value: 'custom', label: '自定义路径' },
756
+ ]
757
+ const presetOptions = [{ value: '', label: '默认' }, ...((options.presets ?? []).map((preset) => ({ value: preset.id, label: preset.label })))]
758
+ const shellOptions = (options.shells ?? ['pwsh']).map((shell) => ({ value: shell, label: shell }))
759
+ const providerOptions = (options.subagentProviders ?? ['spawn']).map((provider) => ({ value: provider, label: provider }))
760
+ const hostOptions = (options.addresses ?? ['0.0.0.0']).map((address) => ({ value: address, label: address }))
761
+ const modeOptions = [{ value: 'auto', label: '自动' }, { value: 'ask', label: '询问' }, { value: 'deny', label: '拒绝' }]
762
+
763
+ return h('div', { style: { maxWidth: 760, padding: '4px 2px 28px', color: 'var(--dsw-alias-label-primary, #e8e8e8)' } },
764
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 } },
765
+ h('div', { style: { fontSize: 15, fontWeight: 600 } }, 'Device Fleet'),
766
+ h('span', { style: { flex: 1 } }),
767
+ status ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #999)' } }, status) : null,
768
+ h('button', {
769
+ type: 'button',
770
+ disabled: busy,
771
+ onClick: () => void save(),
772
+ style: {
773
+ height: 30,
774
+ padding: '0 14px',
775
+ borderRadius: 6,
776
+ border: '1px solid var(--dsw-alias-brand-primary, #2f6fed)',
777
+ background: 'var(--dsw-alias-brand-primary, #2f6fed)',
778
+ color: '#fff',
779
+ fontSize: 13,
780
+ cursor: busy ? 'default' : 'pointer',
781
+ opacity: busy ? 0.6 : 1,
782
+ },
783
+ }, busy ? '保存中' : '保存'),
784
+ ),
785
+ h('div', { style: sectionStyle }, '设备'),
786
+ h(Row, { label: '名称' },
787
+ h(TextControl, { value: draft.deviceName, onChange: (value) => set('deviceName', value) }),
788
+ ),
789
+ h('div', { style: sectionStyle }, '网络'),
790
+ h(Row, { label: '直连地址' },
791
+ h(SelectControl, { value: draft.directHost, options: hostOptions, onChange: (value) => set('directHost', value) }),
792
+ ),
793
+ h(Row, { label: '直连端口' },
794
+ h(NumberControl, { value: draft.directPort, onChange: (value) => set('directPort', value) }),
795
+ ),
796
+ h(Row, { label: '局域网发现' },
797
+ h(ToggleControl, { value: draft.discovery, onChange: (value) => set('discovery', value) }),
798
+ ),
799
+ h(Row, { label: 'Relay' },
800
+ h(SelectControl, {
801
+ value: draft.relayMode,
802
+ options: [{ value: 'off', label: '不使用' }, { value: 'custom', label: '自定义' }],
803
+ onChange: (value) => set('relayMode', value),
804
+ }),
805
+ ),
806
+ draft.relayMode === 'custom' ? h(Row, { label: 'Relay 地址' },
807
+ h(TextControl, { value: draft.relayHost, onChange: (value) => set('relayHost', value) }),
808
+ ) : null,
809
+ draft.relayMode === 'custom' ? h(Row, { label: 'Relay 端口' },
810
+ h(NumberControl, { value: draft.relayPort, onChange: (value) => set('relayPort', value) }),
811
+ ) : null,
812
+ draft.relayMode === 'custom' ? h(Row, { label: 'Relay 数据端口' },
813
+ h(NumberControl, { value: draft.relayDataPort, onChange: (value) => set('relayDataPort', value) }),
814
+ ) : null,
815
+ h('div', { style: sectionStyle }, '执行上下文'),
816
+ h(Row, { label: '工作目录' },
817
+ h(SelectControl, { value: draft.cwdMode, options: cwdOptions, onChange: (value) => set('cwdMode', value) }),
818
+ ),
819
+ draft.cwdMode === 'custom' ? h(Row, { label: '路径' },
820
+ h(TextControl, { value: draft.customCwd, onChange: (value) => set('customCwd', value) }),
821
+ ) : null,
822
+ h(Row, { label: 'Agent Preset' },
823
+ h(SelectControl, { value: draft.execPreset, options: presetOptions, onChange: (value) => set('execPreset', value) }),
824
+ ),
825
+ h(Row, { label: 'Shell' },
826
+ h(SelectControl, { value: draft.execShellTool, options: shellOptions, onChange: (value) => set('execShellTool', value) }),
827
+ ),
828
+ h(Row, { label: '委派 Provider' },
829
+ h(SelectControl, { value: draft.execSubagentProvider, options: providerOptions, onChange: (value) => set('execSubagentProvider', value) }),
830
+ ),
831
+ h(Row, { label: '审批超时 (ms)' },
832
+ h(NumberControl, { value: draft.execApprovalTimeoutMs, onChange: (value) => set('execApprovalTimeoutMs', value) }),
833
+ ),
834
+ h(Row, { label: '委派超时 (ms)' },
835
+ h(NumberControl, { value: draft.execDelegateTimeoutMs, onChange: (value) => set('execDelegateTimeoutMs', value) }),
836
+ ),
837
+ h('div', { style: sectionStyle }, '远端权限'),
838
+ h(Row, { label: '可访问根目录', wide: true },
839
+ h('textarea', {
840
+ value: draft.rootsText,
841
+ onChange: (event) => set('rootsText', event.target.value),
842
+ rows: 3,
843
+ style: textareaStyle,
844
+ }),
845
+ ),
846
+ h(Row, { label: '允许的原始工具', wide: true },
847
+ h('textarea', {
848
+ value: draft.toolsText,
849
+ onChange: (event) => set('toolsText', event.target.value),
850
+ rows: 3,
851
+ style: textareaStyle,
852
+ }),
853
+ ),
854
+ ...CAPABILITY_ROWS.map(([key, label]) =>
855
+ h(Row, { key, label },
856
+ h(SelectControl, { value: draft[key], options: modeOptions, onChange: (value) => set(key, value) }),
857
+ ),
858
+ ),
859
+ h(Row, { label: '授权有效期 (ms)' },
860
+ h(NumberControl, { value: draft.policyApprovalTtlMs, onChange: (value) => set('policyApprovalTtlMs', value) }),
861
+ ),
862
+ h('div', { style: sectionStyle }, '审批与提示'),
863
+ h(Row, { label: '审批超时 (ms)' },
864
+ h(NumberControl, { value: draft.approvalsTtlMs, onChange: (value) => set('approvalsTtlMs', value) }),
865
+ ),
866
+ h(Row, { label: '最大排队数' },
867
+ h(NumberControl, { value: draft.approvalsMaxPending, onChange: (value) => set('approvalsMaxPending', value) }),
868
+ ),
869
+ h(Row, { label: '自动批准(测试)' },
870
+ h(ToggleControl, { value: draft.autoApproveAsk, onChange: (value) => set('autoApproveAsk', value) }),
871
+ ),
872
+ h(Row, { label: 'Fleet 系统提示' },
873
+ h(ToggleControl, { value: draft.promptSection, onChange: (value) => set('promptSection', value) }),
874
+ ),
875
+ h(Row, { label: '审批模拟(测试)' },
876
+ h(ToggleControl, { value: draft.simulation, onChange: (value) => set('simulation', value) }),
877
+ ),
878
+ )
879
+ }
880
+
504
881
  return {
505
882
  inject: ['slots'],
506
883
  apply(ctx) {
@@ -517,6 +894,13 @@ window.__ModuleLoader__.load({
517
894
  ctx.slots.register({ name: 'sidebar.panellist', id: 'fleet', order: 20, label: '设备联邦' }, FleetIcon),
518
895
  );
519
896
  ctx.slots.register({ name: 'main', key: 'fleet' }, FleetConsole);
897
+ // Configuration page: Settings → Plugins → Device Fleet (row + bundle).
898
+ ctx.slots.inject('plugins.row.config', () =>
899
+ ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-device-fleet#device-fleet' }, FleetConfig),
900
+ );
901
+ ctx.slots.inject('plugins.bundle.config', () =>
902
+ ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-device-fleet' }, FleetConfig),
903
+ );
520
904
  },
521
905
  };
522
906
  },
@@ -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.14(稳定化)
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.14(审批收件箱,逻辑层)
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.14(Room 逻辑层,本机模拟)
55
55
 
56
56
  - `RoomService`:当前会话升级为 Room Host;本地模拟参与者(真实 DSH session/agent);
57
57
  - 寻址消息:`room_send` 只唤醒被点名成员,Host 会话写权威 `room/message` 事件;
@@ -59,15 +59,22 @@ 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.14(审批面板 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.14(审批入口不遮挡)
69
69
 
70
70
  - 触发按钮从右下角浮动改为 `sidebar.footer.action`(设置旁边),不再遮挡任务列表/内容面板;
71
71
  - 审批 inbox 改为点击后居中的模态对话框(带遮罩,Esc/点遮罩关闭);
72
72
  - 轮询在 Client apply 中启动并由 ctx.effect 清理;待审批数量作为按钮徽标;
73
- - 已实测:面板点击「允许」后 Host `fleet_approvals` 清空。
73
+ - 已实测:面板点击「允许」后 Host `fleet_approvals` 清空。
74
+ ## 0.1.0-dev.14(Phase B:运行时配置 + 配置页)
75
+
76
+ - `RuntimeConfig`:profile patch 为 base,UI 修改写入 `$DSH_HOME/device-fleet/config-overrides.json`,apply 时合并并持久化;
77
+ - 保存即生效:policy/execution/approvals/deviceName/promptSection/autoApproveAsk 直接更新运行时对象;direct/discovery/relay 通过 `FleetNode.reconfigure()` 原地重启网络监听(不重启插件/DSH);`dataDir` 只读;
78
+ - Host:`GET /device-fleet/config`、`POST /device-fleet/config`(校验/清洗后应用);
79
+ - Client:注册 `plugins.row.config`(key `dsh-device-fleet#device-fleet`)与 `plugins.bundle.config`(key `dsh-device-fleet`),配置页含设备/网络、执行上下文、Tool Policy、审批与提示四组表单;
80
+ - 单测 18/18、网络 9/9。
@@ -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.15",
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,84 @@
1
+ // Runtime config HTTP surface (dev.15): read effective config, list UI options,
2
+ // and apply a 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
+ /**
29
+ * @param {object} options
30
+ * @param {object} options.webServer ctx.webServer
31
+ * @param {object} options.runtime RuntimeConfig
32
+ * @param {() => Promise<object>|object} [options.getOptions] UI option catalog (presets, shells, paths)
33
+ * @param {(patch: object) => Promise<object>|object} [options.preparePatch] side effects before applying (e.g. mkdir)
34
+ */
35
+ export function registerConfigRoutes({ webServer, runtime, getOptions = null, preparePatch = null, logger = null }) {
36
+ if (!webServer?.register || !runtime) return () => {}
37
+ const disposers = []
38
+ disposers.push(
39
+ webServer.register({
40
+ kind: 'exact',
41
+ path: '/device-fleet/options',
42
+ handler: async (req, res) => {
43
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
44
+ try {
45
+ json(res, 200, (await getOptions?.()) ?? {})
46
+ } catch (error) {
47
+ logger?.warn?.(`[device-fleet] options failed: ${String(error?.message ?? error)}`)
48
+ json(res, 500, { error: 'internal error' })
49
+ }
50
+ },
51
+ }),
52
+ )
53
+ disposers.push(
54
+ webServer.register({
55
+ kind: 'exact',
56
+ path: '/device-fleet/config',
57
+ handler: async (req, res) => {
58
+ if (req.method === 'GET') {
59
+ try {
60
+ json(res, 200, runtime.snapshot())
61
+ } catch (error) {
62
+ json(res, 500, { error: String(error?.message ?? error) })
63
+ }
64
+ return
65
+ }
66
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
67
+ try {
68
+ const body = await readJsonBody(req)
69
+ const patch = body?.patch ?? body
70
+ const prepared = preparePatch ? await preparePatch(patch) : patch
71
+ const config = await runtime.update(prepared ?? patch)
72
+ json(res, 200, { ok: true, config, appliedAt: Date.now() })
73
+ } catch (error) {
74
+ const status = ['bad-request', 'bad-json', 'body-too-large', 'readonly'].includes(error?.code) ? 400 : 500
75
+ if (status === 500) logger?.warn?.(`[device-fleet] config update failed: ${String(error?.message ?? error)}`)
76
+ json(res, status, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
77
+ }
78
+ },
79
+ }),
80
+ )
81
+ return () => {
82
+ for (const dispose of disposers) dispose()
83
+ }
84
+ }
@@ -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)
@@ -4,7 +4,7 @@
4
4
  // the six `fleet_*` tools, and the two adoption mechanisms that are cheap to
5
5
  // land now (a prompt section and a first-use manual + orchestration nudge).
6
6
  // Room, Room Board, delegation and the UI land in later steps.
7
- import { readFileSync } from 'node:fs'
7
+ import { mkdirSync, readFileSync } from 'node:fs'
8
8
  import { networkInterfaces } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
  import { fileURLToPath } from 'node:url'
@@ -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,
@@ -169,6 +179,8 @@ export function apply(ctx, config) {
169
179
  const sessionController = service('sessionController')
170
180
  const fs = service('fs')
171
181
  const subagents = service('subagents')
182
+ const agentPresets = service('agentPresets')
183
+ const workingDirectory = service('workingDirectory')
172
184
  const inbox = new ApprovalInbox({
173
185
  ttlMs: config.approvals?.ttlMs ?? 60_000,
174
186
  maxPending: config.approvals?.maxPending ?? 50,
@@ -178,26 +190,26 @@ export function apply(ctx, config) {
178
190
  list: () => inbox.list(),
179
191
  approve: (id, options) => inbox.approve(id, options),
180
192
  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,
193
+ simulate: async ({ fromDeviceId, capability, tool, reason }) => {
194
+ if (runtime.current.approvals?.simulation !== true) {
195
+ throw Object.assign(new Error('approval simulation is disabled'), { code: 'unsupported' })
196
+ }
197
+ const promise = inbox.request({
198
+ fromDeviceId: fromDeviceId ?? 'simulated-device',
199
+ capability: capability ?? 'run',
200
+ tool: tool ?? null,
201
+ reason: reason ?? 'simulated approval request',
202
+ args: {},
203
+ })
204
+ const summary = inbox.list().at(-1)
205
+ // Keep the promise from becoming an unhandled rejection when the
206
+ // request expires without an answer; the demo only inspects the inbox.
207
+ promise.catch(() => {})
208
+ return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
209
+ },
197
210
  }
198
- const requestApproval = config.autoApproveAsk
199
- ? async () => ({ approved: true, scope: 'once' })
200
- : async (request) => inbox.request(request)
211
+ const requestApproval = async (request) =>
212
+ runtime.current.autoApproveAsk === true ? { approved: true, scope: 'once' } : inbox.request(request)
201
213
 
202
214
  const rooms = new RoomService({
203
215
  sessionController,
@@ -208,8 +220,74 @@ export function apply(ctx, config) {
208
220
  // Client approval panel data plane (dev.9). Optional service: host-only
209
221
  // deployments simply do not expose the panel routes.
210
222
  const webServer = service('webServer')
223
+ const workspaceDir = join(state.dataDir, 'workspace')
224
+ const getOptions = async () => {
225
+ const addresses = new Set(['0.0.0.0', '127.0.0.1', runtime.current.direct?.host ?? ''])
226
+ for (const entries of Object.values(networkInterfaces())) {
227
+ for (const entry of entries ?? []) {
228
+ if (entry.family === 'IPv4' && !entry.internal) addresses.add(entry.address)
229
+ }
230
+ }
231
+ addresses.delete('')
232
+ let toolNames = []
233
+ try {
234
+ toolNames = (ctx.tools.definitions?.() ?? []).map((definition) => definition.name)
235
+ } catch {
236
+ toolNames = []
237
+ }
238
+ const shellCandidates = ['pwsh', 'bash', 'cmd', 'sh']
239
+ let shells = shellCandidates.filter((name) => toolNames.includes(name))
240
+ if (shells.length === 0) shells = [process.platform === 'win32' ? 'pwsh' : 'bash']
241
+ let presets = []
242
+ try {
243
+ presets = (await agentPresets?.list?.()) ?? []
244
+ } catch {
245
+ presets = []
246
+ }
247
+ return {
248
+ platform: process.platform,
249
+ addresses: [...addresses],
250
+ shells,
251
+ subagentProviders: (() => {
252
+ try {
253
+ return subagents?.list?.() ?? ['spawn']
254
+ } catch {
255
+ return ['spawn']
256
+ }
257
+ })(),
258
+ presets: presets.map((preset) => ({
259
+ id: preset.id ?? preset.name,
260
+ label: preset.name ?? preset.title ?? preset.id,
261
+ })),
262
+ cwd: {
263
+ workspace: workspaceDir,
264
+ dshHome: process.env.DSH_HOME ?? '',
265
+ defaultDirectory: workingDirectory?.defaultDirectory ?? '',
266
+ current: runtime.current.execution?.cwd ?? '',
267
+ },
268
+ }
269
+ }
211
270
  if (webServer) {
212
271
  ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
272
+ ctx.effect(() =>
273
+ registerConfigRoutes({
274
+ webServer,
275
+ runtime,
276
+ getOptions,
277
+ preparePatch: async (patch) => {
278
+ const cwd = patch?.execution?.cwd
279
+ if (typeof cwd === 'string' && cwd !== '') {
280
+ try {
281
+ mkdirSync(cwd, { recursive: true })
282
+ } catch (error) {
283
+ logger.warn?.(`[device-fleet] cannot create execution cwd: ${String(error?.message ?? error)}`)
284
+ }
285
+ }
286
+ return patch
287
+ },
288
+ logger,
289
+ }),
290
+ )
213
291
  ctx.effect(() =>
214
292
  registerUiRoutes({
215
293
  webServer,
@@ -317,7 +395,7 @@ export function apply(ctx, config) {
317
395
  : (state.enrollment?.accountFingerprint ?? null)
318
396
  return createInvite(state, {
319
397
  accountFingerprint: fingerprint,
320
- relay: config.relay ?? state.enrollment?.relay ?? null,
398
+ relay: runtime.current.relay ?? state.enrollment?.relay ?? null,
321
399
  endpoints: localEndpoints(),
322
400
  })
323
401
  },
@@ -345,13 +423,14 @@ export function apply(ctx, config) {
345
423
 
346
424
  // Adoption layer 2: one sentence, only when the Fleet has members.
347
425
  const systemPrompt = service('systemPrompt')
348
- if (systemPrompt && config.promptSection !== false) {
426
+ if (systemPrompt) {
349
427
  const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
350
428
  ctx.effect(() =>
351
429
  systemPrompt.section({
352
430
  name: 'device-fleet:members',
353
431
  order,
354
432
  text: () => {
433
+ if (runtime.current.promptSection === false) return ''
355
434
  const members = node.rosterEntries().length
356
435
  if (members === 0) return ''
357
436
  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 +439,48 @@ export function apply(ctx, config) {
360
439
  )
361
440
  }
362
441
 
442
+ // Live config application: every UI save runs these handlers in-process.
443
+ runtime.onChange(async (next, previous) => {
444
+ if (JSON.stringify(next.policy) !== JSON.stringify(previous?.policy)) {
445
+ policy = new ToolPolicy(next.policy)
446
+ if (host) host.policy = policy
447
+ if (delegation) delegation.policy = policy
448
+ }
449
+ if (JSON.stringify(next.execution) !== JSON.stringify(previous?.execution)) {
450
+ slot?.setConfig(next.execution)
451
+ if (host) {
452
+ host.shellTool = next.execution?.shellTool ?? DEFAULT_SHELL_TOOL
453
+ host.approvalTimeoutMs = next.execution?.approvalTimeoutMs ?? 60_000
454
+ }
455
+ if (delegation) {
456
+ delegation.defaultCwd = next.execution?.cwd
457
+ delegation.defaultAgentPreset = next.execution?.agentPreset
458
+ delegation.provider = next.execution?.subagentProvider ?? 'spawn'
459
+ delegation.timeoutMs = next.execution?.delegateTimeoutMs ?? 180_000
460
+ }
461
+ }
462
+ if (JSON.stringify(next.approvals) !== JSON.stringify(previous?.approvals)) {
463
+ if (Number.isInteger(next.approvals?.ttlMs) && next.approvals.ttlMs >= 1000) inbox.ttlMs = next.approvals.ttlMs
464
+ if (Number.isInteger(next.approvals?.maxPending) && next.approvals.maxPending >= 1) inbox.maxPending = next.approvals.maxPending
465
+ }
466
+ if (next.deviceName !== previous?.deviceName) {
467
+ node.name = next.deviceName
468
+ if (host) host.name = next.deviceName
469
+ }
470
+ const networkChanged =
471
+ JSON.stringify(next.direct) !== JSON.stringify(previous?.direct) ||
472
+ next.discovery !== previous?.discovery ||
473
+ JSON.stringify(next.relay) !== JSON.stringify(previous?.relay)
474
+ if (networkChanged) {
475
+ await node.reconfigure({
476
+ name: next.deviceName,
477
+ direct: next.direct,
478
+ discovery: next.discovery ? true : false,
479
+ relay: next.relay ?? null,
480
+ })
481
+ }
482
+ })
483
+
363
484
  // Adoption layer 4: one-line nudge when the model starts orchestrating.
364
485
  ctx.on?.('tools/post-execute', async (exec, _result, next) => {
365
486
  const decision = await next()