@ran-sh/dsh-crew 1.0.3 → 1.1.0

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.
@@ -18,6 +18,17 @@ import { dirname, join, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { npxInstall, npxIntegrate } from '../src/install/npx-lifecycle.mjs';
20
20
 
21
+ // RETIRED as a release gate: the legacy 3080 -> 3210 bridge is retired and
22
+ // npxIntegrate()/npxDetach() always return OFFICIAL_WEB_PROFILE_READ_ONLY, so
23
+ // the bridge journey this script asserts can no longer pass on current code.
24
+ // Kept only as an operator diagnostic: run with --diagnostic-only to skip the
25
+ // retired integrate assertion and report the read-only policy explicitly.
26
+ const diagnosticOnly = process.argv.includes('--diagnostic-only');
27
+ if (diagnosticOnly) {
28
+ console.log('[diagnostic] legacy official-bridge e2e retired; official web profile is read-only; nothing to verify.');
29
+ process.exit(0);
30
+ }
31
+
21
32
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
22
33
  const candidateVersion = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
23
34
  if (typeof candidateVersion !== 'string' || candidateVersion === '') throw new Error('candidate package version is unavailable');
@@ -128,7 +139,7 @@ try {
128
139
  const target = join(harnessHome, 'runtime');
129
140
  mkdirSync(dirname(target), { recursive: true });
130
141
  symlinkSync(realpathSync(runtimeRoot), target, process.platform === 'win32' ? 'junction' : 'dir');
131
- return { ok: true, version: '0.1.1-rc.2' };
142
+ return { ok: true, version: '0.1.2-rc.1' };
132
143
  },
133
144
  });
134
145
  assert(installed.ok, `disposable install failed: ${logs.join(' | ')}`);
@@ -1,59 +1,62 @@
1
- export const READINESS_STATES = Object.freeze({
2
- READY: 'READY',
3
- DEGRADED: 'DEGRADED',
4
- UNAVAILABLE: 'UNAVAILABLE',
5
- UNKNOWN: 'UNKNOWN',
6
- });
7
-
8
- function componentState(status, keys, { aligned = false } = {}) {
9
- if (status === undefined || status === null || typeof status !== 'object') return READINESS_STATES.UNKNOWN;
10
- if (status.installed === false) return READINESS_STATES.UNAVAILABLE;
11
- if (status.installed !== true || !status.components || typeof status.components !== 'object') return READINESS_STATES.UNKNOWN;
12
- const evidence = keys.map((key) => status.components[key]);
13
- if (evidence.some((value) => typeof value !== 'boolean')) return READINESS_STATES.UNKNOWN;
14
- const ready = evidence.every(Boolean) && (!aligned || status.components.target_alignment === true);
15
- return ready ? READINESS_STATES.READY : READINESS_STATES.DEGRADED;
16
- }
17
-
18
- function runtimeState(runtime, readinessSnapshot) {
19
- if (readinessSnapshot && typeof readinessSnapshot === 'object') {
20
- const identity = readinessSnapshot.runtime;
21
- if (!(identity?.execution_plane === 'hub-3210' && identity.profile === 'dsh-crew'
22
- && Number(identity.listen_port) === 3210 && typeof identity.runtime_id === 'string' && identity.runtime_id.trim())) {
23
- return READINESS_STATES.UNAVAILABLE;
24
- }
25
- const row = Array.isArray(readinessSnapshot.readiness_matrix?.rows)
26
- ? readinessSnapshot.readiness_matrix.rows.find((entry) => entry?.id === 'hub_compatibility') : undefined;
27
- if (row?.status === 'PASS') return READINESS_STATES.READY;
28
- if (row?.status === 'FAIL') return READINESS_STATES.UNAVAILABLE;
29
- if (row?.status === 'NOT_RUN' || row?.status === 'SKIP') return READINESS_STATES.DEGRADED;
30
- return READINESS_STATES.UNKNOWN;
31
- }
32
- if (runtime === undefined) return READINESS_STATES.UNKNOWN;
33
- if (runtime === null) return READINESS_STATES.UNAVAILABLE;
34
- return runtime?.ok === true && runtime.service === 'dsh-crew-hub'
35
- ? READINESS_STATES.READY
36
- : READINESS_STATES.DEGRADED;
37
- }
38
-
39
- function bridgeState(surface) {
40
- if (surface === 'official-bridge') return READINESS_STATES.READY;
41
- if (surface === 'native-crew-harness') return READINESS_STATES.UNAVAILABLE;
42
- return READINESS_STATES.UNKNOWN;
43
- }
44
-
45
- /** Project only structured, non-secret installer/runtime evidence. */
46
- export function projectHostReadiness({ installStatus, runtime, surface, readinessSnapshot } = {}) {
47
- const codex = installStatus?.codex;
48
- const claude = installStatus?.claude;
49
- const zcode = installStatus?.zcode;
50
- return [
51
- { id: 'codex_mcp', state: componentState(codex, ['mcp'], { aligned: true }) },
52
- { id: 'ds_worker', state: componentState(codex, ['worker_role'], { aligned: true }) },
53
- { id: 'ds_reviewer', state: componentState(codex, ['reviewer_role'], { aligned: true }) },
54
- { id: 'claude_plugin', state: componentState(claude, ['enabled', 'marketplace', 'snapshot', 'permissions']) },
55
- { id: 'zcode_mcp', state: componentState(zcode, ['mcp', 'policy', 'worker_agent', 'reviewer_agent', 'config_prompt', 'status_prompt', 'ownership']) },
56
- { id: 'crew_harness', state: runtimeState(runtime, readinessSnapshot), detail: runtime?.runtime_version ?? null },
57
- { id: 'official_bridge', state: bridgeState(surface) },
58
- ];
59
- }
1
+ export const READINESS_STATES = Object.freeze({
2
+ READY: 'READY',
3
+ DEGRADED: 'DEGRADED',
4
+ UNAVAILABLE: 'UNAVAILABLE',
5
+ UNKNOWN: 'UNKNOWN',
6
+ });
7
+
8
+ function componentState(status, keys, { aligned = false } = {}) {
9
+ if (status === undefined || status === null || typeof status !== 'object') return READINESS_STATES.UNKNOWN;
10
+ if (status.installed === false) return READINESS_STATES.UNAVAILABLE;
11
+ if (status.installed !== true || !status.components || typeof status.components !== 'object') return READINESS_STATES.UNKNOWN;
12
+ const evidence = keys.map((key) => status.components[key]);
13
+ if (evidence.some((value) => typeof value !== 'boolean')) return READINESS_STATES.UNKNOWN;
14
+ const ready = evidence.every(Boolean) && (!aligned || status.components.target_alignment === true);
15
+ return ready ? READINESS_STATES.READY : READINESS_STATES.DEGRADED;
16
+ }
17
+
18
+ function runtimeState(runtime, readinessSnapshot) {
19
+ if (readinessSnapshot && typeof readinessSnapshot === 'object') {
20
+ const identity = readinessSnapshot.runtime;
21
+ if (!(identity?.execution_plane === 'hub-3210' && identity.profile === 'dsh-crew'
22
+ && Number(identity.listen_port) === 3210 && typeof identity.runtime_id === 'string' && identity.runtime_id.trim())) {
23
+ return READINESS_STATES.UNAVAILABLE;
24
+ }
25
+ const row = Array.isArray(readinessSnapshot.readiness_matrix?.rows)
26
+ ? readinessSnapshot.readiness_matrix.rows.find((entry) => entry?.id === 'hub_compatibility') : undefined;
27
+ if (row?.status === 'PASS') return READINESS_STATES.READY;
28
+ if (row?.status === 'FAIL') return READINESS_STATES.UNAVAILABLE;
29
+ if (row?.status === 'NOT_RUN' || row?.status === 'SKIP') return READINESS_STATES.DEGRADED;
30
+ return READINESS_STATES.UNKNOWN;
31
+ }
32
+ if (runtime === undefined) return READINESS_STATES.UNKNOWN;
33
+ if (runtime === null) return READINESS_STATES.UNAVAILABLE;
34
+ return runtime?.ok === true && runtime.service === 'dsh-crew-hub'
35
+ ? READINESS_STATES.READY
36
+ : READINESS_STATES.DEGRADED;
37
+ }
38
+
39
+ function bridgeState(surface) {
40
+ // The official 3080 quick surface is an OPTIONAL convenience panel. Its
41
+ // absence must never degrade Crew readiness: on the native 3210 control
42
+ // plane the bridge is simply not part of the picture.
43
+ if (surface === 'official-bridge') return READINESS_STATES.READY;
44
+ if (surface === 'native-crew-harness') return READINESS_STATES.UNKNOWN;
45
+ return READINESS_STATES.UNKNOWN;
46
+ }
47
+
48
+ /** Project only structured, non-secret installer/runtime evidence. */
49
+ export function projectHostReadiness({ installStatus, runtime, surface, readinessSnapshot } = {}) {
50
+ const codex = installStatus?.codex;
51
+ const claude = installStatus?.claude;
52
+ const zcode = installStatus?.zcode;
53
+ return [
54
+ { id: 'codex_mcp', state: componentState(codex, ['mcp'], { aligned: true }) },
55
+ { id: 'ds_worker', state: componentState(codex, ['worker_role'], { aligned: true }) },
56
+ { id: 'ds_reviewer', state: componentState(codex, ['reviewer_role'], { aligned: true }) },
57
+ { id: 'claude_plugin', state: componentState(claude, ['enabled', 'marketplace', 'snapshot', 'permissions']) },
58
+ { id: 'zcode_mcp', state: componentState(zcode, ['mcp', 'policy', 'worker_agent', 'reviewer_agent', 'config_prompt', 'status_prompt', 'ownership']) },
59
+ { id: 'crew_harness', state: runtimeState(runtime, readinessSnapshot), detail: runtime?.runtime_version ?? null },
60
+ { id: 'official_bridge', state: bridgeState(surface) },
61
+ ];
62
+ }
@@ -640,7 +640,8 @@ function WorkersPanel({ ctx }: { ctx: any }) {
640
640
  }, [get]);
641
641
 
642
642
  useEffect(() => {
643
- if (surface !== CREW_UI_SURFACES.OFFICIAL) return undefined;
643
+ const responsibilities = surfaceResponsibilities(surface);
644
+ if (!responsibilities.fullControlPlane) return undefined;
644
645
  void refreshAll();
645
646
  const timer = setInterval(() => {
646
647
  void Promise.all([get('/jobs'), get('/provider-health').catch(() => null)]).then(([j, health]) => {
@@ -704,11 +705,11 @@ function WorkersPanel({ ctx }: { ctx: any }) {
704
705
  }, [copy, locale, post, readJson, refreshProviderInventory, withLang]);
705
706
 
706
707
  useEffect(() => {
707
- if (surface === CREW_UI_SURFACES.OFFICIAL) void refreshHarnessModels();
708
+ if (surfaceResponsibilities(surface).fullControlPlane) void refreshHarnessModels();
708
709
  }, [surface, refreshHarnessModels]);
709
710
 
710
711
  useEffect(() => {
711
- if (surface === CREW_UI_SURFACES.OFFICIAL) void refreshProviderInventory();
712
+ if (surfaceResponsibilities(surface).fullControlPlane) void refreshProviderInventory();
712
713
  }, [surface, refreshProviderInventory]);
713
714
 
714
715
  const act = useCallback(async (target: string, confirmName?: string) => {
@@ -739,6 +740,25 @@ function WorkersPanel({ ctx }: { ctx: any }) {
739
740
  return false;
740
741
  }
741
742
  }, [post, get, copy]);
743
+
744
+ /** Request a Crew 3210 restart through the durable supervisor channel
745
+ * (hub writes the request; the Windows watcher executes it). Polls until
746
+ * VERIFIED or a terminal failure. Same-origin against the 3210 API — the
747
+ * legacy 3080 supervisor endpoint is retired. */
748
+ const requestRestart = useCallback(async (reason?: string): Promise<any> => {
749
+ const created = await post('/runtime/restart-request', { confirm: true, reason: reason ?? 'configuration change' });
750
+ if (!created.ok) throw new Error(created.code ?? created.error ?? copy.providerLifecycleError);
751
+ const requestId = created.request_id;
752
+ const deadline = Date.now() + 90_000;
753
+ for (;;) {
754
+ await new Promise((resolve) => setTimeout(resolve, 1000));
755
+ if (Date.now() > deadline) throw new Error('Crew restart timed out');
756
+ const status = await get(`/runtime/restart-status?id=${encodeURIComponent(requestId)}`);
757
+ if (!status.ok) continue; // 404 while the watcher has not picked it up
758
+ if (status.state === 'VERIFIED') return { ok: true, ...status };
759
+ if (status.state !== 'RESTART_REQUESTED') throw new Error(status.state ?? 'Crew restart failed');
760
+ }
761
+ }, [post, get, copy]);
742
762
  /** Selects & checkboxes: apply immediately. */
743
763
  const field = (key: string, value: any) => {
744
764
  setConfig((c: any) => ({ ...c, [key]: value }));
@@ -908,12 +928,7 @@ function WorkersPanel({ ctx }: { ctx: any }) {
908
928
  }), path);
909
929
  if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
910
930
  if (result.restart_required === true && result.result?.state === 'RESTART_PENDING') {
911
- const restartPath = 'http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart';
912
- const restarted = await readJson(await fetch(restartPath, {
913
- method: 'POST', headers: { 'content-type': 'application/json' },
914
- body: JSON.stringify({ confirm: true }),
915
- }), restartPath);
916
- if (!restarted.ok) throw new Error(restarted.code ?? restarted.error ?? copy.providerLifecycleError);
931
+ await requestRestart('provider delete');
917
932
  const verified = await post(`/providers/${encoded}/verify-delete`, { transaction_id: planned.plan.plan_id, confirm: true });
918
933
  if (!verified.ok || verified.state !== 'VERIFIED') throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
919
934
  }
@@ -945,11 +960,7 @@ function WorkersPanel({ ctx }: { ctx: any }) {
945
960
  const result = await post(`/providers/${encoded}/migrate`, { plan_id: planned.plan.plan_id, confirm: true });
946
961
  if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
947
962
  if (result.restart_required === true && result.result?.state === 'RESTART_PENDING') {
948
- const restartPath = 'http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart';
949
- const restarted = await readJson(await fetch(restartPath, {
950
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }),
951
- }), restartPath);
952
- if (!restarted.ok) throw new Error(restarted.code ?? restarted.error ?? copy.providerLifecycleError);
963
+ await requestRestart('provider migrate');
953
964
  const verified = await post(`/providers/${encoded}/verify-migration`, { transaction_id: planned.plan.plan_id, confirm: true });
954
965
  if (!verified.ok || verified.state !== 'VERIFIED') throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
955
966
  }
@@ -972,11 +983,7 @@ function WorkersPanel({ ctx }: { ctx: any }) {
972
983
  const result = await post(`/providers/${encoded}/rollback-migration`, { transaction_id: transactionId, confirm: true });
973
984
  if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
974
985
  if (result.restart_required === true) {
975
- const restartPath = 'http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart';
976
- const restarted = await readJson(await fetch(restartPath, {
977
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }),
978
- }), restartPath);
979
- if (!restarted.ok) throw new Error(restarted.code ?? restarted.error ?? copy.providerLifecycleError);
986
+ await requestRestart('provider migrate');
980
987
  }
981
988
  setNotice(copy.saved);
982
989
  await refreshProviderInventory();
@@ -1003,11 +1010,7 @@ function WorkersPanel({ ctx }: { ctx: any }) {
1003
1010
  : await post(`/providers/${encoded}/rollback`, { transaction_id: transactionId, confirm: true });
1004
1011
  if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
1005
1012
  if (result.restart_required === true && result.state === 'ROLLBACK_PENDING') {
1006
- const restartPath = 'http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart';
1007
- const restarted = await readJson(await fetch(restartPath, {
1008
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }),
1009
- }), restartPath);
1010
- if (!restarted.ok) throw new Error(restarted.code ?? restarted.error ?? copy.providerLifecycleError);
1013
+ await requestRestart('provider migrate');
1011
1014
  const verified = await post(`/providers/${encoded}/verify-rollback`, { transaction_id: transactionId, confirm: true });
1012
1015
  if (!verified.ok || verified.state !== 'ROLLED_BACK') throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
1013
1016
  } else if (result.state !== 'ROLLED_BACK') {
@@ -1841,7 +1844,7 @@ function WorkersPanel({ ctx }: { ctx: any }) {
1841
1844
 
1842
1845
  export function apply(ctx: any): void {
1843
1846
  let runningCount = 0;
1844
- let officialSurface: boolean | null = null;
1847
+ let badgeSurface: boolean | null = null;
1845
1848
  ctx.slots.inject('settings.section', () => {
1846
1849
  let dispose = register();
1847
1850
  function register() {
@@ -1865,12 +1868,14 @@ export function apply(ctx: any): void {
1865
1868
  const poll = async () => {
1866
1869
  if (document.visibilityState !== 'visible') return;
1867
1870
  try {
1868
- if (officialSurface === null) {
1869
- const response = await fetch(`${API}/bridge-status`, { cache: 'no-store' });
1870
- const bridgeStatus = response.ok ? await response.json() : null;
1871
- officialSurface = classifyCrewSurface({ bridgeStatus }) === CREW_UI_SURFACES.OFFICIAL;
1871
+ if (badgeSurface === null) {
1872
+ const [bridgeStatus, runtime] = await Promise.all([
1873
+ fetch(`${API}/bridge-status`, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
1874
+ fetch(`${API}/runtime`, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
1875
+ ]);
1876
+ badgeSurface = surfaceResponsibilities(classifyCrewSurface({ bridgeStatus, runtime })).fullControlPlane;
1872
1877
  }
1873
- if (!officialSurface) return;
1878
+ if (!badgeSurface) return;
1874
1879
  const r = await (await fetch(`${API}/jobs`, { cache: 'no-store' })).json();
1875
1880
  const n = r.ok ? (r.jobs ?? []).filter((j: any) => j.status === 'running').length : 0;
1876
1881
  if (n !== runningCount) {
@@ -0,0 +1,10 @@
1
+ import { applyQuick } from './quick-panel';
2
+
3
+ export const inject = ['slots', 'locale'];
4
+
5
+ // Quick-controls entry for the official 3080 surface: master switch,
6
+ // flash/pro model priority, vision/imagegen toggles only. Never ships the
7
+ // full control-plane code.
8
+ export function apply(ctx: any): void {
9
+ applyQuick(ctx);
10
+ }
@@ -0,0 +1,295 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+
3
+ // DSH Crew QUICK CONTROLS panel for the official 3080 surface.
4
+ //
5
+ // Narrow by design: only the user-facing master switch, flash/pro model
6
+ // priority lists and vision/imagegen toggles — all other operations belong
7
+ // to the native 3210 full control plane. Talks only to the quick endpoints
8
+ // (/_dsh/dsh-crew/quick-config, /quick-status, /runtime/restart-request,
9
+ // /runtime/restart-status). NEVER writes to any other surface.
10
+
11
+ const API = '/_dsh/dsh-crew';
12
+ const CREW_CONTROL_PLANE_URL = 'http://127.0.0.1:3210/';
13
+ const FULL = 'http://127.0.0.1:3210/';
14
+
15
+ type ModelEntry = { provider: string; model: string };
16
+ type QuickConfig = {
17
+ subagents_enabled?: boolean;
18
+ flash_model_priority?: ModelEntry[];
19
+ pro_model_priority?: ModelEntry[];
20
+ vision_enabled?: boolean;
21
+ imagegen_enabled?: boolean;
22
+ vision_provider?: string;
23
+ imagegen_provider?: string;
24
+ };
25
+
26
+ const RESTART_KEYS = new Set([
27
+ 'vision_enabled',
28
+ 'imagegen_enabled',
29
+ 'vision_provider',
30
+ 'imagegen_provider',
31
+ ]);
32
+
33
+ const T = {
34
+ zh: {
35
+ title: 'DSH Crew 快捷控制',
36
+ openFull: '打开完整设置 →',
37
+ running: '运行中',
38
+ unavailable: 'Crew 后端不可用',
39
+ openDiag: '打开诊断',
40
+ crew: 'Crew',
41
+ enabled: '启用子 Agent',
42
+ flash: 'Flash 模型',
43
+ pro: 'Pro 模型',
44
+ addModel: '+ 添加模型',
45
+ multimodal: '多模态',
46
+ vision: '视觉',
47
+ imagegen: '生图',
48
+ provider: 'Provider',
49
+ applyRestart: '应用并重启 Crew',
50
+ savedNeedsRestart: '配置已保存 · 需要重启 Crew 才会生效',
51
+ saved: '已保存',
52
+ working: '处理中…',
53
+ providerPlaceholder: 'provider',
54
+ modelPlaceholder: 'model',
55
+ },
56
+ en: {
57
+ title: 'DSH Crew Quick Controls',
58
+ openFull: 'Open full settings →',
59
+ running: 'Running',
60
+ unavailable: 'Crew backend unavailable',
61
+ openDiag: 'Open diagnostics',
62
+ crew: 'Crew',
63
+ enabled: 'Enable sub-agents',
64
+ flash: 'Flash models',
65
+ pro: 'Pro models',
66
+ addModel: '+ Add model',
67
+ multimodal: 'Multimodal',
68
+ vision: 'Vision',
69
+ imagegen: 'Imagegen',
70
+ provider: 'Provider',
71
+ applyRestart: 'Apply & restart Crew',
72
+ savedNeedsRestart: 'Saved · restart Crew to take effect',
73
+ saved: 'Saved',
74
+ working: 'Working…',
75
+ providerPlaceholder: 'provider',
76
+ modelPlaceholder: 'model',
77
+ },
78
+ };
79
+
80
+ function LocalStyles() {
81
+ return (
82
+ <style>{`
83
+ .crew-quick-card { border: 1px solid rgba(128,128,128,0.24); border-radius: 12px; padding: 14px 15px; font-size: 13px; line-height: 1.55; display: flex; flex-direction: column; gap: 10px; background: linear-gradient(135deg, rgba(74,158,255,0.10), rgba(128,128,128,0.025) 56%); }
84
+ .crew-quick-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
85
+ .crew-quick-title { font-size: 16px; font-weight: 680; }
86
+ .crew-quick-section { font-weight: 650; opacity: 0.85; }
87
+ .crew-quick-chip { border: 1px solid rgba(128,128,128,0.35); border-radius: 999px; padding: 1px 10px; font-size: 12px; }
88
+ .crew-quick-btn { border: 1px solid rgba(128,128,128,0.4); border-radius: 8px; padding: 2px 10px; cursor: pointer; background: transparent; font-size: 12.5px; }
89
+ .crew-quick-btn.primary { border-color: #4a9eff; color: #4a9eff; font-weight: 650; }
90
+ .crew-quick-input { border: 1px solid rgba(128,128,128,0.35); border-radius: 6px; padding: 2px 8px; font-size: 12.5px; width: 130px; background: transparent; color: inherit; }
91
+ .crew-quick-notice { opacity: 0.75; font-size: 12.5px; }
92
+ .crew-quick-model { display: flex; gap: 6px; align-items: center; font-size: 12.5px; }
93
+ `}</style>
94
+ );
95
+ }
96
+
97
+ async function readJson(res: Response) {
98
+ if (!res.ok) return { ok: false, status: res.status };
99
+ try { return await res.json(); } catch { return { ok: false }; }
100
+ }
101
+
102
+ export function QuickPanel({ ctx }: { ctx: any }) {
103
+ const locale = ctx?.locale?.getLocale?.().active === 'zh' ? 'zh' : 'en';
104
+ const t = (T as any)[locale] ?? T.zh;
105
+ const [config, setConfig] = useState<QuickConfig | null>(null);
106
+ const [ready, setReady] = useState<boolean | null>(null);
107
+ const [notice, setNotice] = useState('');
108
+ const [busy, setBusy] = useState(false);
109
+ const [restartPending, setRestartPending] = useState(false);
110
+ const [drafts, setDrafts] = useState<Record<string, ModelEntry>>({});
111
+
112
+ const load = useCallback(async () => {
113
+ try {
114
+ const status = await readJson(await fetch(`${API}/quick-status`, { cache: 'no-store' }));
115
+ if (!status.ok) { setReady(false); return; }
116
+ setReady(true);
117
+ setConfig(status.config ?? {});
118
+ } catch { setReady(false); }
119
+ }, []);
120
+
121
+ useEffect(() => { void load(); }, [load]);
122
+
123
+ const patch = useCallback(async (next: QuickConfig) => {
124
+ setBusy(true); setNotice(t.working);
125
+ try {
126
+ const res = await readJson(await fetch(`${API}/quick-config`, {
127
+ method: 'POST', headers: { 'content-type': 'application/json' },
128
+ body: JSON.stringify(next),
129
+ }));
130
+ if (!res.ok) throw new Error(res.code ?? res.error ?? 'save failed');
131
+ setConfig(res.config ?? {});
132
+ const needsRestart = Object.keys(next).some((k) => RESTART_KEYS.has(k));
133
+ if (needsRestart) { setRestartPending(true); setNotice(t.savedNeedsRestart); }
134
+ else { setNotice(t.saved); setTimeout(() => setNotice(''), 1500); }
135
+ return true;
136
+ } catch (e: any) {
137
+ setNotice(String(e?.message ?? e));
138
+ return false;
139
+ } finally { setBusy(false); }
140
+ }, [t]);
141
+
142
+ const toggle = (key: 'subagents_enabled' | 'vision_enabled' | 'imagegen_enabled', value: boolean) => {
143
+ setConfig((c) => ({ ...(c ?? {}), [key]: value }));
144
+ void patch({ [key]: value } as QuickConfig);
145
+ };
146
+
147
+ const setProvider = (key: 'vision_provider' | 'imagegen_provider', value: string) => {
148
+ setConfig((c) => ({ ...(c ?? {}), [key]: value }));
149
+ void patch({ [key]: value } as QuickConfig);
150
+ };
151
+
152
+ const moveModel = (listKey: 'flash_model_priority' | 'pro_model_priority', index: number, dir: -1 | 1) => {
153
+ const list = [...((config?.[listKey] as ModelEntry[] | undefined) ?? [])];
154
+ const to = index + dir;
155
+ if (to < 0 || to >= list.length) return;
156
+ [list[index], list[to]] = [list[to], list[index]];
157
+ setConfig((c) => ({ ...(c ?? {}), [listKey]: list }));
158
+ void patch({ [listKey]: list } as unknown as QuickConfig);
159
+ };
160
+
161
+ const removeModel = (listKey: 'flash_model_priority' | 'pro_model_priority', index: number) => {
162
+ const list = [...((config?.[listKey] as ModelEntry[] | undefined) ?? [])];
163
+ list.splice(index, 1);
164
+ setConfig((c) => ({ ...(c ?? {}), [listKey]: list }));
165
+ void patch({ [listKey]: list } as unknown as QuickConfig);
166
+ };
167
+
168
+ const addModel = (listKey: 'flash_model_priority' | 'pro_model_priority') => {
169
+ const draft = drafts[listKey] ?? { provider: '', model: '' };
170
+ if (!draft.provider.trim() || !draft.model.trim()) return;
171
+ const list = [...((config?.[listKey] as ModelEntry[] | undefined) ?? []), { provider: draft.provider.trim(), model: draft.model.trim() }];
172
+ setConfig((c) => ({ ...(c ?? {}), [listKey]: list }));
173
+ setDrafts((d) => ({ ...d, [listKey]: { provider: '', model: '' } }));
174
+ void patch({ [listKey]: list } as unknown as QuickConfig);
175
+ };
176
+
177
+ const applyRestart = useCallback(async () => {
178
+ setBusy(true); setNotice(t.working);
179
+ try {
180
+ const created = await readJson(await fetch(`${API}/runtime/restart-request`, {
181
+ method: 'POST', headers: { 'content-type': 'application/json' },
182
+ body: JSON.stringify({ confirm: true, reason: 'quick panel restart' }),
183
+ }));
184
+ if (!created.ok) throw new Error(created.code ?? created.error ?? 'restart failed');
185
+ const requestId = created.request_id;
186
+ const deadline = Date.now() + 90_000;
187
+ for (;;) {
188
+ await new Promise((r) => setTimeout(r, 1000));
189
+ if (Date.now() > deadline) throw new Error('restart timed out');
190
+ const status = await readJson(await fetch(`${API}/runtime/restart-status?id=${encodeURIComponent(requestId)}`, { cache: 'no-store' }));
191
+ if (!status.ok) continue;
192
+ if (status.state === 'VERIFIED') { setRestartPending(false); setNotice(t.saved); return; }
193
+ if (status.state !== 'RESTART_REQUESTED') throw new Error(status.state ?? 'restart failed');
194
+ }
195
+ } catch (e: any) {
196
+ setNotice(String(e?.message ?? e));
197
+ } finally { setBusy(false); }
198
+ }, [t]);
199
+
200
+ const modelList = (listKey: 'flash_model_priority' | 'pro_model_priority', label: string) => {
201
+ const list: ModelEntry[] = (config?.[listKey] as ModelEntry[] | undefined) ?? [];
202
+ const draft = drafts[listKey] ?? { provider: '', model: '' };
203
+ return (
204
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
205
+ <div className="crew-quick-section">{label}</div>
206
+ {list.map((entry, i) => (
207
+ <div key={`${entry.provider}/${entry.model}/${i}`} className="crew-quick-model">
208
+ <span>{i + 1}. {entry.provider} / {entry.model}</span>
209
+ <button className="crew-quick-btn" disabled={busy || i === 0} onClick={() => moveModel(listKey, i, -1)}>↑</button>
210
+ <button className="crew-quick-btn" disabled={busy || i === list.length - 1} onClick={() => moveModel(listKey, i, 1)}>↓</button>
211
+ <button className="crew-quick-btn" disabled={busy} onClick={() => removeModel(listKey, i)}>×</button>
212
+ </div>
213
+ ))}
214
+ <div className="crew-quick-row">
215
+ <input className="crew-quick-input" placeholder={t.providerPlaceholder} disabled={busy}
216
+ value={draft.provider} onChange={(e) => setDrafts((d) => ({ ...d, [listKey]: { ...draft, provider: e.target.value } }))} />
217
+ <input className="crew-quick-input" placeholder={t.modelPlaceholder} disabled={busy}
218
+ value={draft.model} onChange={(e) => setDrafts((d) => ({ ...d, [listKey]: { ...draft, model: e.target.value } }))} />
219
+ <button className="crew-quick-btn" disabled={busy} onClick={() => addModel(listKey)}>{t.addModel}</button>
220
+ </div>
221
+ </div>
222
+ );
223
+ };
224
+
225
+ if (ready === false) {
226
+ return (
227
+ <div className="crew-quick-card">
228
+ <LocalStyles />
229
+ <div className="crew-quick-title">{t.title}</div>
230
+ <div className="crew-quick-notice">{t.unavailable}</div>
231
+ <div className="crew-quick-row">
232
+ <a href={FULL} target="_blank" rel="noreferrer">{t.openDiag}</a>
233
+ </div>
234
+ </div>
235
+ );
236
+ }
237
+ if (config === null) {
238
+ return (<div className="crew-quick-card"><LocalStyles /><div className="crew-quick-notice">{t.working}</div></div>);
239
+ }
240
+
241
+ return (
242
+ <div className="crew-quick-card">
243
+ <LocalStyles />
244
+ <div className="crew-quick-row" style={{ justifyContent: 'space-between' }}>
245
+ <span className="crew-quick-title">{t.title}</span>
246
+ <span className="crew-quick-chip">{t.running} · 3210</span>
247
+ <a href={FULL} target="_blank" rel="noreferrer">{t.openFull}</a>
248
+ </div>
249
+ <div className="crew-quick-row">
250
+ <span className="crew-quick-section">{t.crew}</span>
251
+ <label><input type="checkbox" disabled={busy} checked={config.subagents_enabled !== false}
252
+ onChange={(e) => toggle('subagents_enabled', e.target.checked)} /> {t.enabled}</label>
253
+ </div>
254
+ {modelList('flash_model_priority', t.flash)}
255
+ {modelList('pro_model_priority', t.pro)}
256
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
257
+ <div className="crew-quick-section">{t.multimodal}</div>
258
+ <div className="crew-quick-row">
259
+ <label><input type="checkbox" disabled={busy} checked={config.vision_enabled === true}
260
+ onChange={(e) => toggle('vision_enabled', e.target.checked)} /> {t.vision}</label>
261
+ <span>{t.provider}</span>
262
+ <input className="crew-quick-input" disabled={busy} defaultValue={config.vision_provider ?? ''} key={`vision-${config.vision_provider ?? ''}`}
263
+ onBlur={(e) => setProvider('vision_provider', e.target.value)} />
264
+ </div>
265
+ <div className="crew-quick-row">
266
+ <label><input type="checkbox" disabled={busy} checked={config.imagegen_enabled === true}
267
+ onChange={(e) => toggle('imagegen_enabled', e.target.checked)} /> {t.imagegen}</label>
268
+ <span>{t.provider}</span>
269
+ <input className="crew-quick-input" disabled={busy} defaultValue={config.imagegen_provider ?? ''}
270
+ onBlur={(e) => setProvider('imagegen_provider', e.target.value)} />
271
+ </div>
272
+ </div>
273
+ {restartPending && (
274
+ <div className="crew-quick-row">
275
+ <button className="crew-quick-btn primary" disabled={busy} onClick={() => void applyRestart()}>{t.applyRestart}</button>
276
+ </div>
277
+ )}
278
+ {notice && <div className="crew-quick-notice">{notice}</div>}
279
+ </div>
280
+ );
281
+ }
282
+
283
+ export function applyQuick(ctx: any): void {
284
+ ctx.slots.inject('settings.section', () => {
285
+ return ctx.slots.register(
286
+ {
287
+ name: 'settings.section',
288
+ id: 'dsh-crew-quick',
289
+ order: 65,
290
+ label: () => 'DSH Crew',
291
+ },
292
+ () => <QuickPanel ctx={ctx} />,
293
+ );
294
+ });
295
+ }