@ran-sh/dsh-crew 0.5.6 → 1.0.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.
Files changed (47) hide show
  1. package/README.md +20 -7
  2. package/README.zh.md +18 -7
  3. package/docs/installation.md +1 -1
  4. package/docs/ui-surfaces.md +65 -46
  5. package/lib/client.js +565 -28
  6. package/official-web-bridge/lib/client.js +565 -28
  7. package/package.json +1 -1
  8. package/src/client/collapsible-sections.mjs +3 -2
  9. package/src/client/host-readiness.mjs +16 -3
  10. package/src/client/index.tsx +331 -54
  11. package/src/config-readiness.mjs +66 -5
  12. package/src/credential-lifecycle.mjs +73 -0
  13. package/src/credential-purge-adapters.mjs +127 -0
  14. package/src/credential-purge-state.mjs +59 -0
  15. package/src/credential-reference-inventory.mjs +78 -0
  16. package/src/dsh-cli-runtime.mjs +36 -9
  17. package/src/extension-contract.mjs +16 -10
  18. package/src/hub/index.mjs +1004 -438
  19. package/src/hub-client.mjs +25 -5
  20. package/src/hub-compatibility.mjs +23 -7
  21. package/src/install/install.mjs +4 -4
  22. package/src/install/npx-lifecycle.mjs +495 -134
  23. package/src/job-contracts.mjs +27 -6
  24. package/src/jobs.mjs +2 -4
  25. package/src/local-request-guard.mjs +60 -0
  26. package/src/mcp-runtime.mjs +7 -5
  27. package/src/model-routing.mjs +141 -49
  28. package/src/multimodal.mjs +0 -0
  29. package/src/official-web-bridge.mjs +248 -95
  30. package/src/policy.mjs +62 -31
  31. package/src/provider-config-scrub.mjs +71 -0
  32. package/src/provider-delete-adapters.mjs +451 -0
  33. package/src/provider-desired-state.mjs +37 -0
  34. package/src/provider-health.mjs +124 -0
  35. package/src/provider-inventory.mjs +132 -0
  36. package/src/provider-lifecycle-state.mjs +95 -0
  37. package/src/provider-lifecycle.mjs +214 -0
  38. package/src/provider-profile-store.mjs +171 -0
  39. package/src/readiness-matrix.mjs +11 -5
  40. package/src/removable-waiter.mjs +29 -0
  41. package/src/runtime-identity.mjs +66 -21
  42. package/src/runtime-readiness-snapshot.mjs +87 -0
  43. package/src/server.mjs +64 -21
  44. package/src/status-shard.mjs +13 -2
  45. package/src/workflow-runtime.mjs +44 -10
  46. package/windows/start-dsh-crew.cmd +3 -3
  47. package/windows/start-dsh-crew.ps1 +49 -5
@@ -38,6 +38,7 @@ const SETTINGS_SECTION_IDS = Object.freeze([
38
38
  "adaptive",
39
39
  "runtime",
40
40
  "multimodal",
41
+ "harnessProviders",
41
42
  "providers",
42
43
  "jobs"
43
44
  ]);
@@ -178,7 +179,16 @@ function componentState(status, keys, { aligned = false } = {}) {
178
179
  if (evidence.some((value) => typeof value !== "boolean")) return READINESS_STATES.UNKNOWN;
179
180
  return evidence.every(Boolean) && (!aligned || status.components.target_alignment === true) ? READINESS_STATES.READY : READINESS_STATES.DEGRADED;
180
181
  }
181
- function runtimeState(runtime) {
182
+ function runtimeState(runtime, readinessSnapshot) {
183
+ if (readinessSnapshot && typeof readinessSnapshot === "object") {
184
+ const identity = readinessSnapshot.runtime;
185
+ if (!(identity?.execution_plane === "hub-3210" && identity.profile === "dsh-crew" && Number(identity.listen_port) === 3210 && typeof identity.runtime_id === "string" && identity.runtime_id.trim())) return READINESS_STATES.UNAVAILABLE;
186
+ const row = Array.isArray(readinessSnapshot.readiness_matrix?.rows) ? readinessSnapshot.readiness_matrix.rows.find((entry) => entry?.id === "hub_compatibility") : void 0;
187
+ if (row?.status === "PASS") return READINESS_STATES.READY;
188
+ if (row?.status === "FAIL") return READINESS_STATES.UNAVAILABLE;
189
+ if (row?.status === "NOT_RUN" || row?.status === "SKIP") return READINESS_STATES.DEGRADED;
190
+ return READINESS_STATES.UNKNOWN;
191
+ }
182
192
  if (runtime === void 0) return READINESS_STATES.UNKNOWN;
183
193
  if (runtime === null) return READINESS_STATES.UNAVAILABLE;
184
194
  return runtime?.ok === true && runtime.service === "dsh-crew-hub" ? READINESS_STATES.READY : READINESS_STATES.DEGRADED;
@@ -189,7 +199,7 @@ function bridgeState(surface) {
189
199
  return READINESS_STATES.UNKNOWN;
190
200
  }
191
201
  /** Project only structured, non-secret installer/runtime evidence. */
192
- function projectHostReadiness({ installStatus, runtime, surface } = {}) {
202
+ function projectHostReadiness({ installStatus, runtime, surface, readinessSnapshot } = {}) {
193
203
  const codex = installStatus?.codex;
194
204
  const claude = installStatus?.claude;
195
205
  const zcode = installStatus?.zcode;
@@ -229,7 +239,7 @@ function projectHostReadiness({ installStatus, runtime, surface } = {}) {
229
239
  },
230
240
  {
231
241
  id: "crew_harness",
232
- state: runtimeState(runtime),
242
+ state: runtimeState(runtime, readinessSnapshot),
233
243
  detail: runtime?.runtime_version ?? null
234
244
  },
235
245
  {
@@ -375,7 +385,8 @@ const COPY = {
375
385
  adaptive: "自适应路由",
376
386
  runtime: "运行 / 生效边界",
377
387
  multimodal: "视觉与生图",
378
- providers: "自定义 Provider",
388
+ harnessProviders: "Harness Providers",
389
+ providers: "多模态适配器",
379
390
  jobs: "任务状态"
380
391
  },
381
392
  openHarness: "打开 3210 Crew Harness →",
@@ -432,7 +443,7 @@ const COPY = {
432
443
  "follow-dsh": "跟随 Harness · 使用各档独立的模型优先级",
433
444
  "deepseek-official": "DeepSeek Official · 使用旧版固定模型路由"
434
445
  },
435
- workerProviderHint: "仅作用于 Hub 模式;Standalone 始终用 deepseek-official + DEEPSEEK_API_KEY。",
446
+ workerProviderHint: "仅作用于 Hub 模式;Standalone 仅保留为旧版迁移元数据,不是生产执行路径。",
436
447
  workerModels: "Worker 模型",
437
448
  refreshHarnessModels: "刷新 Harness 模型",
438
449
  refreshingModels: "刷新中…",
@@ -444,6 +455,8 @@ const COPY = {
444
455
  notAdvertised: "当前未列出",
445
456
  noPriority: "未配置优先项",
446
457
  modelPoolSummary: (p, m) => `Providers: ${p} · Models: ${m}`,
458
+ healthUnprobed: "UNPROBED",
459
+ checkedAgo: (s) => `${s} 秒前检查`,
447
460
  partialCatalog: "部分 Provider 读取失败",
448
461
  catalogFailed: "无法读取 Harness 模型目录",
449
462
  removePriority: "移除",
@@ -491,6 +504,31 @@ const COPY = {
491
504
  visionProvider: "视觉 provider",
492
505
  visionModel: "视觉模型",
493
506
  imagegenProvider: "生图 provider",
507
+ harnessProviders: "Harness Providers",
508
+ harnessProviderHint: "3210 Crew Harness 的真实 Provider 注册与生命周期;不是视觉 / 生图适配器。",
509
+ providerState: (state) => `状态:${state}`,
510
+ providerModels: (count) => `${count} 个模型`,
511
+ providerJobs: (count) => `${count} 个运行任务`,
512
+ providerDeletePlan: "生成删除计划",
513
+ providerDelete: "确认删除",
514
+ providerPlanReady: "计划已生成;再次确认后才会修改 3210 配置。",
515
+ providerRollback: "回滚",
516
+ providerRollbackConfirm: "按已保存事务恢复这个 Harness Provider 及原有路由?",
517
+ providerRollbackUnavailable: "没有可验证的回滚事务",
518
+ providerReplacement: "替换 Harness 默认 Provider(输入 Provider id)",
519
+ providerDeleteConfirm: "确认按计划删除这个 Harness Provider?会清理路由引用并重启 3210。",
520
+ providerDeleteBlocked: "当前 Provider 不能删除(内置、在用、已删除或缺少可用替换)。",
521
+ providerLifecycleError: "Provider 生命周期操作失败",
522
+ providerNoInventory: "暂时无法读取 3210 Provider inventory。",
523
+ credentialReferences: "Credential 引用",
524
+ credentialReferenceHint: "仅显示引用名、归属和孤儿状态;不会读取或删除密钥。",
525
+ credentialOrphan: "孤儿 · 可单独申请清理",
526
+ credentialInUse: (count) => `使用中 · ${count} 个 Provider`,
527
+ credentialPurgePlan: "申请清理",
528
+ credentialPurgeConfirm: "这是不可恢复的 Crew-owned 凭据清理,确认继续?",
529
+ credentialPurging: "清理中…",
530
+ credentialPurgeUnavailable: "凭据清理不可用",
531
+ credentialPurgeUnverified: "已执行清理,但验证失败;引用仍保留用于恢复检查",
494
532
  customProviders: "自定义 Provider",
495
533
  addProvider: "+ 添加 Provider",
496
534
  noCustomProviders: "暂无。添加后会出现在上方的视觉 / 生图 provider 选择里。",
@@ -544,6 +582,23 @@ const COPY = {
544
582
  roleStateHint: "auto:orchestrator 可自动派发;manual:仅显式点名;disabled:禁用。未显式设置时由协作模式推导。",
545
583
  autoReview: "自动复审",
546
584
  autoReviewHint: "worker 成功后自动运行一次 reviewer(独立模型策略,只读)",
585
+ modelOrdering: "候选排序",
586
+ orderingDesc: {
587
+ manual: "手动顺序",
588
+ "health-aware": "健康感知"
589
+ },
590
+ orderingHint: "手动 priority 始终保持顺序;健康感知仅重排未显式锁定的自动候选。",
591
+ healthGate: "健康门",
592
+ healthGateDesc: {
593
+ "hard-failures": "跳过明确不可调用",
594
+ off: "关闭健康门"
595
+ },
596
+ reviewGate: "Reviewer 验收门",
597
+ reviewGateDesc: {
598
+ required: "required · 未批准则失败",
599
+ optional: "optional · 记录但不阻断",
600
+ off: "off · 不自动复审"
601
+ },
547
602
  isolation: "工作区隔离",
548
603
  isolationHint: "worktree 为默认;非 Git 仓库时需显式 shared,否则任务以 NOT_GIT_REPOSITORY 失败。",
549
604
  isolationDesc: {
@@ -570,9 +625,8 @@ const COPY = {
570
625
  d: "接入自己的 API 或本地命令;保存后会出现在上面的视觉 / 生图 provider 选择里。"
571
626
  },
572
627
  modeDesc: {
573
- auto: "auto · 优先 hub",
574
- hub: "hub · 必须 hub",
575
- standalone: "standalone · 独立进程"
628
+ auto: "auto · 优先 3210",
629
+ hub: "hub · 必须 3210"
576
630
  },
577
631
  save: "保存",
578
632
  saved: "已保存",
@@ -654,7 +708,8 @@ const COPY = {
654
708
  adaptive: "Adaptive routing",
655
709
  runtime: "Runtime / activation boundaries",
656
710
  multimodal: "Vision & image generation",
657
- providers: "Custom providers",
711
+ harnessProviders: "Harness Providers",
712
+ providers: "Multimodal adapters",
658
713
  jobs: "Task status"
659
714
  },
660
715
  openHarness: "Open 3210 Crew Harness →",
@@ -711,7 +766,7 @@ const COPY = {
711
766
  "follow-dsh": "Follow Harness · use each tier's ordered model priority",
712
767
  "deepseek-official": "DeepSeek Official · use legacy fixed model routing"
713
768
  },
714
- workerProviderHint: "Applies to Hub workers only; Standalone always uses deepseek-official + DEEPSEEK_API_KEY.",
769
+ workerProviderHint: "Applies to Hub workers only; Standalone is legacy migration metadata only and is not a production execution path.",
715
770
  workerModels: "Worker Models",
716
771
  refreshHarnessModels: "Refresh Harness Models",
717
772
  refreshingModels: "Refreshing…",
@@ -723,6 +778,8 @@ const COPY = {
723
778
  notAdvertised: "Currently not advertised",
724
779
  noPriority: "No priority models configured",
725
780
  modelPoolSummary: (p, m) => `Providers: ${p} · Models: ${m}`,
781
+ healthUnprobed: "UNPROBED",
782
+ checkedAgo: (s) => `checked ${s}s ago`,
726
783
  partialCatalog: "Some providers failed to load",
727
784
  catalogFailed: "Unable to read Harness model catalog",
728
785
  removePriority: "Remove",
@@ -770,6 +827,31 @@ const COPY = {
770
827
  visionProvider: "Vision provider",
771
828
  visionModel: "Vision model",
772
829
  imagegenProvider: "Image-gen provider",
830
+ harnessProviders: "Harness Providers",
831
+ harnessProviderHint: "Live Provider registration and lifecycle in the 3210 Crew Harness; separate from vision / image-gen adapters.",
832
+ providerState: (state) => `State: ${state}`,
833
+ providerModels: (count) => `${count} models`,
834
+ providerJobs: (count) => `${count} running jobs`,
835
+ providerDeletePlan: "Plan deletion",
836
+ providerDelete: "Confirm delete",
837
+ providerPlanReady: "Plan ready; a second confirmation is required before changing 3210 config.",
838
+ providerRollback: "Rollback",
839
+ providerRollbackConfirm: "Restore this Harness Provider and its previous routing from the saved transaction?",
840
+ providerRollbackUnavailable: "No verified rollback transaction is available",
841
+ providerReplacement: "Replacement Harness Default provider id",
842
+ providerDeleteConfirm: "Delete this Harness Provider per the plan? Routing refs will be scrubbed and 3210 restarted.",
843
+ providerDeleteBlocked: "This Provider cannot be deleted (built-in, in use, already absent, or missing a valid replacement).",
844
+ providerLifecycleError: "Provider lifecycle operation failed",
845
+ providerNoInventory: "3210 Provider inventory is temporarily unavailable.",
846
+ credentialReferences: "Credential references",
847
+ credentialReferenceHint: "Names, ownership, and orphan status only; values are never read or deleted.",
848
+ credentialOrphan: "orphan · separate purge approval required",
849
+ credentialInUse: (count) => `in use · ${count} provider(s)`,
850
+ credentialPurgePlan: "Purge…",
851
+ credentialPurgeConfirm: "This irreversibly removes a Crew-owned credential. Continue?",
852
+ credentialPurging: "Purging…",
853
+ credentialPurgeUnavailable: "Credential purge unavailable",
854
+ credentialPurgeUnverified: "Purge ran but verification failed; the reference remains visible for recovery",
773
855
  customProviders: "Custom providers",
774
856
  addProvider: "+ Add provider",
775
857
  noCustomProviders: "None yet. Added providers appear in the vision / image-gen selects above.",
@@ -823,6 +905,23 @@ const COPY = {
823
905
  roleStateHint: "auto = the orchestrator may dispatch automatically; manual = only when explicitly named; disabled = off. When unset, derived from the collaboration mode.",
824
906
  autoReview: "Automatic review",
825
907
  autoReviewHint: "Run one reviewer pass (independent model policy, read-only) after a successful worker run",
908
+ modelOrdering: "Candidate ordering",
909
+ orderingDesc: {
910
+ manual: "Manual order",
911
+ "health-aware": "Health-aware"
912
+ },
913
+ orderingHint: "Explicit priority always keeps its order; health-aware mode only reorders automatic, unlocked candidates.",
914
+ healthGate: "Health gate",
915
+ healthGateDesc: {
916
+ "hard-failures": "Skip known hard failures",
917
+ off: "Health gate off"
918
+ },
919
+ reviewGate: "Reviewer acceptance gate",
920
+ reviewGateDesc: {
921
+ required: "required · fail unless approved",
922
+ optional: "optional · record without blocking",
923
+ off: "off · no automatic review"
924
+ },
826
925
  isolation: "Workspace isolation",
827
926
  isolationHint: "worktree is the default; non-git workspaces require explicit shared, otherwise jobs fail with NOT_GIT_REPOSITORY.",
828
927
  isolationDesc: {
@@ -849,9 +948,8 @@ const COPY = {
849
948
  d: "Bring your own API or local command; saved providers appear in the vision / image-gen selects above."
850
949
  },
851
950
  modeDesc: {
852
- auto: "auto · prefer hub",
853
- hub: "hub · require hub",
854
- standalone: "standalone"
951
+ auto: "auto · prefer 3210",
952
+ hub: "hub · require 3210"
855
953
  },
856
954
  save: "Save",
857
955
  saved: "Saved",
@@ -1339,12 +1437,20 @@ function WorkersPanel({ ctx }) {
1339
1437
  const [modelCatalog, setModelCatalog] = (0, react.useState)(null);
1340
1438
  const [modelCatalogError, setModelCatalogError] = (0, react.useState)("");
1341
1439
  const [modelCatalogBusy, setModelCatalogBusy] = (0, react.useState)(false);
1440
+ const [providerInventory, setProviderInventory] = (0, react.useState)(null);
1441
+ const [providerHealth, setProviderHealth] = (0, react.useState)([]);
1442
+ const [credentialRefs, setCredentialRefs] = (0, react.useState)([]);
1443
+ const [credentialUnverified, setCredentialUnverified] = (0, react.useState)([]);
1444
+ const [credentialLifecycleBusy, setCredentialLifecycleBusy] = (0, react.useState)(null);
1445
+ const [providerInventoryError, setProviderInventoryError] = (0, react.useState)("");
1446
+ const [providerLifecycleBusy, setProviderLifecycleBusy] = (0, react.useState)(null);
1342
1447
  const [modelPickerTier, setModelPickerTier] = (0, react.useState)(null);
1343
1448
  const [modelQuery, setModelQuery] = (0, react.useState)("");
1344
1449
  const [testResult, setTestResult] = (0, react.useState)(null);
1345
1450
  const [expandedSections, setExpandedSections] = (0, react.useState)(() => readSectionState(sectionStorage()));
1346
1451
  const [surface, setSurface] = (0, react.useState)("detecting");
1347
1452
  const [runtimeInfo, setRuntimeInfo] = (0, react.useState)(void 0);
1453
+ const [readinessSnapshot, setReadinessSnapshot] = (0, react.useState)(void 0);
1348
1454
  const toggleSection = (sectionId) => {
1349
1455
  setExpandedSections((current) => ({
1350
1456
  ...current,
@@ -1362,6 +1468,9 @@ function WorkersPanel({ ctx }) {
1362
1468
  "pro"
1363
1469
  ]));
1364
1470
  }, [modelCatalogError]);
1471
+ (0, react.useEffect)(() => {
1472
+ if (providerInventoryError) setExpandedSections((current) => openSections(current, ["harnessProviders"]));
1473
+ }, [providerInventoryError]);
1365
1474
  (0, react.useEffect)(() => {
1366
1475
  if (testResult && !testResult.busy && testResult.ok === false) setExpandedSections((current) => openSections(current, ["providers"]));
1367
1476
  }, [testResult]);
@@ -1422,11 +1531,15 @@ function WorkersPanel({ ctx }) {
1422
1531
  }, [detectSurface]);
1423
1532
  const refreshAll = (0, react.useCallback)(async () => {
1424
1533
  try {
1425
- const [j, s, c, pr] = await Promise.all([
1534
+ const [j, s, c, pr, pi, ph, cr, ext] = await Promise.all([
1426
1535
  get("/jobs"),
1427
1536
  get("/install/status"),
1428
1537
  get("/config"),
1429
- get("/presets")
1538
+ get("/presets"),
1539
+ get("/providers").catch(() => null),
1540
+ get("/provider-health").catch(() => null),
1541
+ get("/credential-references").catch(() => null),
1542
+ get("/extension").catch(() => null)
1430
1543
  ]);
1431
1544
  if (j.ok) setJobs(j.jobs ?? []);
1432
1545
  if (s.ok) setStatus(s.status);
@@ -1438,14 +1551,22 @@ function WorkersPanel({ ctx }) {
1438
1551
  value: x.id,
1439
1552
  label: x.name ?? x.id
1440
1553
  }))]);
1554
+ if (pi?.ok) setProviderInventory(pi);
1555
+ if (ph?.ok) setProviderHealth(ph.health ?? []);
1556
+ if (cr?.ok) {
1557
+ setCredentialRefs(cr.records ?? []);
1558
+ setCredentialUnverified(cr.unverified_purges ?? []);
1559
+ }
1560
+ if (ext?.ok) setReadinessSnapshot(ext.extension?.readiness_snapshot ?? void 0);
1441
1561
  } catch {}
1442
1562
  }, [get]);
1443
1563
  (0, react.useEffect)(() => {
1444
1564
  if (surface !== CREW_UI_SURFACES.OFFICIAL) return void 0;
1445
1565
  refreshAll();
1446
1566
  const timer = setInterval(() => {
1447
- get("/jobs").then((j) => {
1567
+ Promise.all([get("/jobs"), get("/provider-health").catch(() => null)]).then(([j, health]) => {
1448
1568
  if (j.ok) setJobs(j.jobs ?? []);
1569
+ if (health?.ok) setProviderHealth(health.health ?? []);
1449
1570
  }).catch(() => {});
1450
1571
  }, 3e3);
1451
1572
  return () => clearInterval(timer);
@@ -1467,9 +1588,68 @@ function WorkersPanel({ ctx }) {
1467
1588
  setModelCatalogBusy(false);
1468
1589
  }
1469
1590
  }, [get, copy]);
1591
+ const refreshProviderInventory = (0, react.useCallback)(async () => {
1592
+ setProviderInventoryError("");
1593
+ try {
1594
+ const [result, health, refs] = await Promise.all([
1595
+ get("/providers"),
1596
+ get("/provider-health").catch(() => null),
1597
+ get("/credential-references").catch(() => null)
1598
+ ]);
1599
+ if (!result.ok) throw new Error(result.error ?? copy.providerNoInventory);
1600
+ setProviderInventory(result);
1601
+ if (health?.ok) setProviderHealth(health.health ?? []);
1602
+ if (refs?.ok) {
1603
+ setCredentialRefs(refs.records ?? []);
1604
+ setCredentialUnverified(refs.unverified_purges ?? []);
1605
+ }
1606
+ } catch (error) {
1607
+ setProviderInventoryError(error?.message ?? copy.providerNoInventory);
1608
+ }
1609
+ }, [get, copy]);
1610
+ const purgeCredentialRef = (0, react.useCallback)(async (ref) => {
1611
+ if (ref?.ownership !== "crew-owned" || ref?.orphan !== true || ref?.purge_capability !== "eligible") return;
1612
+ const referenceId = String(ref.reference_id ?? "");
1613
+ if (!referenceId || !window.confirm(copy.credentialPurgeConfirm)) return;
1614
+ setCredentialLifecycleBusy(referenceId);
1615
+ setNotice(copy.working);
1616
+ try {
1617
+ const encoded = encodeURIComponent(referenceId);
1618
+ const planned = await post(`/credential-references/${encoded}/purge-plan`, {});
1619
+ if (!planned.ok || !planned.plan) throw new Error(planned.code ?? copy.credentialPurgeUnavailable);
1620
+ if (!window.confirm(`${copy.credentialPurgeConfirm}\n\n${referenceId}`)) return;
1621
+ const result = await readJson(await fetch(withLang(`/credential-references/${encoded}`), {
1622
+ method: "DELETE",
1623
+ headers: { "content-type": "application/json" },
1624
+ body: JSON.stringify({
1625
+ plan_id: planned.plan.plan_id,
1626
+ expected_revision: planned.plan.expected_revision,
1627
+ confirm: true,
1628
+ lang: locale === "zh" ? "zh" : "en"
1629
+ })
1630
+ }), `/credential-references/${encoded}`);
1631
+ if (!result.ok || result.state !== "VERIFIED") throw new Error(result.code ?? copy.credentialPurgeUnavailable);
1632
+ setNotice(copy.saved);
1633
+ await refreshProviderInventory();
1634
+ } catch (error) {
1635
+ setNotice(String(error?.message ?? error));
1636
+ } finally {
1637
+ setCredentialLifecycleBusy(null);
1638
+ }
1639
+ }, [
1640
+ copy,
1641
+ locale,
1642
+ post,
1643
+ readJson,
1644
+ refreshProviderInventory,
1645
+ withLang
1646
+ ]);
1470
1647
  (0, react.useEffect)(() => {
1471
1648
  if (surface === CREW_UI_SURFACES.OFFICIAL) refreshHarnessModels();
1472
1649
  }, [surface, refreshHarnessModels]);
1650
+ (0, react.useEffect)(() => {
1651
+ if (surface === CREW_UI_SURFACES.OFFICIAL) refreshProviderInventory();
1652
+ }, [surface, refreshProviderInventory]);
1473
1653
  const act = (0, react.useCallback)(async (target, confirmName) => {
1474
1654
  if (confirmName && !window.confirm(copy.confirmRestore(confirmName))) return;
1475
1655
  setBusy(true);
@@ -1524,6 +1704,36 @@ function WorkersPanel({ ctx }) {
1524
1704
  ...c,
1525
1705
  [key]: value
1526
1706
  }));
1707
+ const rolePolicyField = (role, patch) => {
1708
+ const effectivePatch = role === "worker" && (patch.ordering === "manual" || patch.ordering === "health-aware") ? {
1709
+ ...patch,
1710
+ adaptive: {
1711
+ ...normalizeAdaptive(config?.worker?.model_policy?.adaptive),
1712
+ enabled: patch.ordering === "health-aware"
1713
+ }
1714
+ } : patch;
1715
+ setConfig((current) => ({
1716
+ ...current,
1717
+ [role]: {
1718
+ ...current?.[role],
1719
+ model_policy: {
1720
+ ...current?.[role]?.model_policy,
1721
+ ...effectivePatch
1722
+ }
1723
+ }
1724
+ }));
1725
+ applyPatch({ [role]: { model_policy: effectivePatch } });
1726
+ };
1727
+ const reviewField = (patch) => {
1728
+ setConfig((current) => ({
1729
+ ...current,
1730
+ review: {
1731
+ ...current?.review,
1732
+ ...patch
1733
+ }
1734
+ }));
1735
+ applyPatch({ review: patch });
1736
+ };
1527
1737
  const customProviders = config?.custom_providers ?? [];
1528
1738
  const extraModels = config?.extra_models ?? {};
1529
1739
  const RESERVED_IDS = [
@@ -1748,6 +1958,96 @@ function WorkersPanel({ ctx }) {
1748
1958
  }));
1749
1959
  applyPatch(patch);
1750
1960
  };
1961
+ const deleteHarnessProvider = async (record) => {
1962
+ if (!record || record.ownership === "harness" || record.desired_state === "absent" || Number(record.references?.active_jobs ?? 0) > 0) {
1963
+ setNotice(copy.providerDeleteBlocked);
1964
+ return;
1965
+ }
1966
+ let replacementDefault;
1967
+ if (record.references?.harness_default === true) {
1968
+ const candidate = (providerInventory?.records ?? []).find((item) => item.id !== record.id && item.desired_state !== "absent" && item.ownership !== "harness" && (item.models?.length ?? 0) > 0)?.id ?? "";
1969
+ const entered = window.prompt(copy.providerReplacement, candidate);
1970
+ if (!entered?.trim()) return;
1971
+ replacementDefault = entered.trim();
1972
+ }
1973
+ setProviderLifecycleBusy(record.id);
1974
+ setNotice(copy.working);
1975
+ try {
1976
+ const encoded = encodeURIComponent(record.id);
1977
+ const planned = await post(`/providers/${encoded}/delete-plan`, replacementDefault ? { replacement_default: replacementDefault } : {});
1978
+ if (!planned.ok || !planned.plan) throw new Error(planned.code ?? copy.providerLifecycleError);
1979
+ if (!window.confirm(`${copy.providerPlanReady}\n\n${copy.providerDeleteConfirm}\n${(planned.plan.will_remove ?? []).join("\n")}`)) return;
1980
+ const path = `/providers/${encoded}`;
1981
+ const result = await readJson(await fetch(withLang(path), {
1982
+ method: "DELETE",
1983
+ headers: { "content-type": "application/json" },
1984
+ body: JSON.stringify({
1985
+ plan_id: planned.plan.plan_id,
1986
+ expected_revision: planned.plan.expected_revision,
1987
+ confirm: true,
1988
+ lang: locale === "zh" ? "zh" : "en"
1989
+ })
1990
+ }), path);
1991
+ if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
1992
+ if (result.restart_required === true && result.result?.state === "RESTART_PENDING") {
1993
+ const restartPath = "http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart";
1994
+ const restarted = await readJson(await fetch(restartPath, {
1995
+ method: "POST",
1996
+ headers: { "content-type": "application/json" },
1997
+ body: JSON.stringify({ confirm: true })
1998
+ }), restartPath);
1999
+ if (!restarted.ok) throw new Error(restarted.code ?? restarted.error ?? copy.providerLifecycleError);
2000
+ const verified = await post(`/providers/${encoded}/verify-delete`, {
2001
+ transaction_id: planned.plan.plan_id,
2002
+ confirm: true
2003
+ });
2004
+ if (!verified.ok || verified.state !== "VERIFIED") throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
2005
+ }
2006
+ setNotice(copy.saved);
2007
+ await refreshProviderInventory();
2008
+ } catch (error) {
2009
+ setNotice(String(error?.message ?? error));
2010
+ } finally {
2011
+ setProviderLifecycleBusy(null);
2012
+ }
2013
+ };
2014
+ const rollbackHarnessProvider = async (record, transactionId) => {
2015
+ if (!record || !transactionId || record.desired_state !== "absent") {
2016
+ setNotice(copy.providerRollbackUnavailable);
2017
+ return;
2018
+ }
2019
+ if (!window.confirm(copy.providerRollbackConfirm)) return;
2020
+ setProviderLifecycleBusy(`rollback:${record.id}`);
2021
+ setNotice(copy.working);
2022
+ try {
2023
+ const encoded = encodeURIComponent(record.id);
2024
+ const result = await post(`/providers/${encoded}/rollback`, {
2025
+ transaction_id: transactionId,
2026
+ confirm: true
2027
+ });
2028
+ if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
2029
+ if (result.restart_required === true && result.state === "ROLLBACK_PENDING") {
2030
+ const restartPath = "http://127.0.0.1:3080/_dsh/dsh-crew/supervisor/restart";
2031
+ const restarted = await readJson(await fetch(restartPath, {
2032
+ method: "POST",
2033
+ headers: { "content-type": "application/json" },
2034
+ body: JSON.stringify({ confirm: true })
2035
+ }), restartPath);
2036
+ if (!restarted.ok) throw new Error(restarted.code ?? restarted.error ?? copy.providerLifecycleError);
2037
+ const verified = await post(`/providers/${encoded}/verify-rollback`, {
2038
+ transaction_id: transactionId,
2039
+ confirm: true
2040
+ });
2041
+ if (!verified.ok || verified.state !== "ROLLED_BACK") throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
2042
+ } else if (result.state !== "ROLLED_BACK") throw new Error(result.code ?? copy.providerLifecycleError);
2043
+ setNotice(copy.saved);
2044
+ await refreshProviderInventory();
2045
+ } catch (error) {
2046
+ setNotice(String(error?.message ?? error));
2047
+ } finally {
2048
+ setProviderLifecycleBusy(null);
2049
+ }
2050
+ };
1751
2051
  /** Card: title + one-line description header, then its fields. */
1752
2052
  const block = (text, fields, gridded = true) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1753
2053
  style: S.block,
@@ -1818,28 +2118,37 @@ function WorkersPanel({ ctx }) {
1818
2118
  const adaptive = normalizeAdaptive(config?.worker?.model_policy?.adaptive);
1819
2119
  const setAdaptiveLocal = (candidate) => {
1820
2120
  const next = normalizeAdaptive(candidate);
2121
+ const ordering = next.enabled ? "health-aware" : "manual";
1821
2122
  setConfig((current) => ({
1822
2123
  ...current,
1823
2124
  worker: {
1824
2125
  ...current.worker,
1825
2126
  model_policy: {
1826
2127
  ...current.worker?.model_policy,
1827
- adaptive: next
2128
+ adaptive: next,
2129
+ ordering
1828
2130
  }
1829
2131
  }
1830
2132
  }));
1831
- return next;
2133
+ return {
2134
+ next,
2135
+ ordering
2136
+ };
1832
2137
  };
1833
2138
  const saveAdaptive = (candidate) => {
1834
- const next = setAdaptiveLocal(candidate);
1835
- applyPatch({ worker: { model_policy: { adaptive: next } } });
2139
+ const { next, ordering } = setAdaptiveLocal(candidate);
2140
+ applyPatch({ worker: { model_policy: {
2141
+ adaptive: next,
2142
+ ordering
2143
+ } } });
1836
2144
  };
1837
2145
  const modelActivity = aggregateModelInvocations(jobs);
1838
2146
  const currentSurfaceResponsibilities = surfaceResponsibilities(surface);
1839
2147
  const hostReadiness = projectHostReadiness({
1840
2148
  installStatus: status,
1841
2149
  runtime: runtimeInfo,
1842
- surface
2150
+ surface,
2151
+ readinessSnapshot
1843
2152
  });
1844
2153
  if (!currentSurfaceResponsibilities.fullControlPlane) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MinimalCrewPanel, {
1845
2154
  locale,
@@ -2131,6 +2440,9 @@ function WorkersPanel({ ctx }) {
2131
2440
  model: provider?.models?.find((item) => item.id === ref.model)
2132
2441
  };
2133
2442
  };
2443
+ const modelHealth = (ref) => providerHealth.find((entry) => entry.provider === ref.provider && entry.model === ref.model && entry.fresh === true) ?? null;
2444
+ const healthLabel = (health) => health?.state ? String(health.state).toUpperCase().replace(/-/g, " ") : copy.healthUnprobed;
2445
+ const healthAge = (health) => Number.isFinite(health?.observed_at) ? copy.checkedAgo(Math.max(0, Math.round((Date.now() - health.observed_at) / 1e3))) : "";
2134
2446
  const filteredProviders = catalogProviders.map((provider) => ({
2135
2447
  ...provider,
2136
2448
  models: (provider.models ?? []).filter((model) => {
@@ -2187,6 +2499,7 @@ function WorkersPanel({ ctx }) {
2187
2499
  },
2188
2500
  children: priority.map((ref, index) => {
2189
2501
  const meta = modelMeta(ref);
2502
+ const health = modelHealth(ref);
2190
2503
  const warning = !meta.provider ? copy.providerUnavailable : !meta.model ? copy.notAdvertised : "";
2191
2504
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2192
2505
  style: {
@@ -2231,6 +2544,15 @@ function WorkersPanel({ ctx }) {
2231
2544
  ref.model
2232
2545
  ]
2233
2546
  }),
2547
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2548
+ style: {
2549
+ display: "block",
2550
+ fontSize: 10.5,
2551
+ color: health?.state === "callable" ? "#3fb950" : health ? "#c98735" : "inherit",
2552
+ opacity: health ? .9 : .5
2553
+ },
2554
+ children: [healthLabel(health), healthAge(health) ? ` · ${healthAge(health)}` : ""]
2555
+ }),
2234
2556
  warning && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2235
2557
  style: {
2236
2558
  display: "block",
@@ -2536,7 +2858,7 @@ function WorkersPanel({ ctx }) {
2536
2858
  block({
2537
2859
  t: copy.autoReview,
2538
2860
  d: copy.autoReviewHint
2539
- }, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2861
+ }, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2540
2862
  style: {
2541
2863
  ...S.field,
2542
2864
  flexDirection: "row",
@@ -2551,7 +2873,65 @@ function WorkersPanel({ ctx }) {
2551
2873
  style: { fontSize: 12.5 },
2552
2874
  children: copy.autoReview
2553
2875
  })]
2554
- }), false),
2876
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2877
+ style: S.field,
2878
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2879
+ style: S.fieldLabel,
2880
+ children: copy.reviewGate
2881
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CustomSelect, {
2882
+ value: config.review?.gate ?? "required",
2883
+ onChange: (v) => reviewField({ gate: v }),
2884
+ options: [
2885
+ "required",
2886
+ "optional",
2887
+ "off"
2888
+ ].map((value) => ({
2889
+ value,
2890
+ label: copy.reviewGateDesc[value]
2891
+ }))
2892
+ })]
2893
+ })] })),
2894
+ block({
2895
+ t: copy.modelOrdering,
2896
+ d: copy.orderingHint
2897
+ }, /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: ["worker", "review"].map((role) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2898
+ style: { display: "contents" },
2899
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2900
+ style: S.field,
2901
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2902
+ style: S.fieldLabel,
2903
+ children: [
2904
+ role === "worker" ? copy.roleWorker : copy.roleReviewer,
2905
+ " · ",
2906
+ copy.modelOrdering
2907
+ ]
2908
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CustomSelect, {
2909
+ value: config?.[role]?.model_policy?.ordering ?? "manual",
2910
+ onChange: (v) => rolePolicyField(role, { ordering: v }),
2911
+ options: ["manual", "health-aware"].map((value) => ({
2912
+ value,
2913
+ label: copy.orderingDesc[value]
2914
+ }))
2915
+ })]
2916
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2917
+ style: S.field,
2918
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2919
+ style: S.fieldLabel,
2920
+ children: [
2921
+ role === "worker" ? copy.roleWorker : copy.roleReviewer,
2922
+ " · ",
2923
+ copy.healthGate
2924
+ ]
2925
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CustomSelect, {
2926
+ value: config?.[role]?.model_policy?.health_gate ?? "hard-failures",
2927
+ onChange: (v) => rolePolicyField(role, { health_gate: v }),
2928
+ options: ["hard-failures", "off"].map((value) => ({
2929
+ value,
2930
+ label: copy.healthGateDesc[value]
2931
+ }))
2932
+ })]
2933
+ })]
2934
+ }, role)) })),
2555
2935
  block({
2556
2936
  t: copy.isolation,
2557
2937
  d: copy.isolationHint
@@ -2948,11 +3328,7 @@ function WorkersPanel({ ctx }) {
2948
3328
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CustomSelect, {
2949
3329
  value: config.mode,
2950
3330
  onChange: (v) => field("mode", v),
2951
- options: [
2952
- "auto",
2953
- "hub",
2954
- "standalone"
2955
- ].map((m) => ({
3331
+ options: ["auto", "hub"].map((m) => ({
2956
3332
  value: m,
2957
3333
  label: copy.modeDesc[m]
2958
3334
  }))
@@ -3190,6 +3566,167 @@ function WorkersPanel({ ctx }) {
3190
3566
  })
3191
3567
  ] }))
3192
3568
  }),
3569
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(CollapsibleSection, {
3570
+ sectionId: "harnessProviders",
3571
+ title: copy.sectionNames.harnessProviders,
3572
+ summary: sectionSummary(copy.providerCount(providerInventory?.records?.length ?? 0), providerInventoryError ? copy.providerLifecycleError : ""),
3573
+ expanded: !!expandedSections.harnessProviders,
3574
+ onToggle: () => toggleSection("harnessProviders"),
3575
+ children: [
3576
+ providerInventoryError && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3577
+ style: {
3578
+ fontSize: 12,
3579
+ color: "#c55"
3580
+ },
3581
+ children: providerInventoryError
3582
+ }),
3583
+ !providerInventoryError && (providerInventory?.records?.length ?? 0) === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3584
+ style: {
3585
+ fontSize: 12,
3586
+ opacity: .55
3587
+ },
3588
+ children: copy.providerNoInventory
3589
+ }),
3590
+ (providerInventory?.records ?? []).map((record) => {
3591
+ const blocked = record.ownership === "harness" || record.desired_state === "absent" || Number(record.references?.active_jobs ?? 0) > 0;
3592
+ const lifecycle = record.desired_state === "absent" ? "absent" : record.lifecycle?.enabled === false ? "disabled" : record.lifecycle?.catalogued ? "catalogued" : "configured";
3593
+ const rollbackTransaction = (providerInventory?.lifecycle_transactions ?? []).find((entry) => entry.provider_id === record.id && ["VERIFIED", "RESTART_PENDING"].includes(entry.state));
3594
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3595
+ style: {
3596
+ display: "flex",
3597
+ alignItems: "center",
3598
+ gap: 8,
3599
+ fontSize: 12.5,
3600
+ flexWrap: "wrap"
3601
+ },
3602
+ children: [
3603
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3604
+ style: { fontWeight: 600 },
3605
+ children: record.display_name || record.id
3606
+ }),
3607
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3608
+ style: {
3609
+ ...S.chip(record.lifecycle?.enabled === true),
3610
+ ...S.mono
3611
+ },
3612
+ children: record.id
3613
+ }),
3614
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3615
+ style: { opacity: .65 },
3616
+ children: copy.providerState(lifecycle)
3617
+ }),
3618
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3619
+ style: { opacity: .65 },
3620
+ children: copy.providerModels(record.models?.length ?? 0)
3621
+ }),
3622
+ Number(record.references?.active_jobs ?? 0) > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3623
+ style: { color: "#c98735" },
3624
+ children: copy.providerJobs(record.references.active_jobs)
3625
+ }),
3626
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
3627
+ record.ownership !== "harness" && record.desired_state !== "absent" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3628
+ type: "button",
3629
+ style: {
3630
+ ...S.btn,
3631
+ padding: "2px 8px"
3632
+ },
3633
+ disabled: blocked || providerLifecycleBusy !== null,
3634
+ onClick: () => {
3635
+ deleteHarnessProvider(record);
3636
+ },
3637
+ children: providerLifecycleBusy === record.id ? copy.working : copy.providerDeletePlan
3638
+ }),
3639
+ record.desired_state === "absent" && rollbackTransaction && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3640
+ type: "button",
3641
+ style: {
3642
+ ...S.btn,
3643
+ padding: "2px 8px"
3644
+ },
3645
+ disabled: providerLifecycleBusy !== null,
3646
+ onClick: () => {
3647
+ rollbackHarnessProvider(record, rollbackTransaction.transaction_id);
3648
+ },
3649
+ children: providerLifecycleBusy === `rollback:${record.id}` ? copy.working : copy.providerRollback
3650
+ })
3651
+ ]
3652
+ }, record.id);
3653
+ }),
3654
+ (credentialRefs.length > 0 || credentialUnverified.length > 0) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3655
+ style: {
3656
+ marginTop: 10,
3657
+ borderTop: "1px solid rgba(128,128,128,0.16)",
3658
+ paddingTop: 8
3659
+ },
3660
+ children: [
3661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3662
+ style: {
3663
+ fontWeight: 600,
3664
+ fontSize: 12.5
3665
+ },
3666
+ children: copy.credentialReferences
3667
+ }),
3668
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3669
+ style: {
3670
+ fontSize: 11,
3671
+ opacity: .6,
3672
+ margin: "3px 0 7px"
3673
+ },
3674
+ children: copy.credentialReferenceHint
3675
+ }),
3676
+ credentialUnverified.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3677
+ style: {
3678
+ color: "#c55",
3679
+ fontSize: 11.5
3680
+ },
3681
+ children: [
3682
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3683
+ style: S.mono,
3684
+ children: entry.reference_id
3685
+ }),
3686
+ " · ",
3687
+ copy.credentialPurgeUnverified
3688
+ ]
3689
+ }, `unverified:${entry.reference_id}`)),
3690
+ credentialRefs.map((ref) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3691
+ style: {
3692
+ display: "flex",
3693
+ alignItems: "center",
3694
+ gap: 8,
3695
+ fontSize: 11.5,
3696
+ flexWrap: "wrap"
3697
+ },
3698
+ children: [
3699
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3700
+ style: S.mono,
3701
+ children: ref.reference_id
3702
+ }),
3703
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3704
+ style: { opacity: .65 },
3705
+ children: ref.ownership
3706
+ }),
3707
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3708
+ style: { color: ref.orphan ? "#c98735" : void 0 },
3709
+ children: ref.orphan ? copy.credentialOrphan : copy.credentialInUse(ref.consumer_count ?? 0)
3710
+ }),
3711
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { style: { flex: 1 } }),
3712
+ ref.ownership === "crew-owned" && ref.orphan === true && ref.purge_capability === "eligible" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3713
+ type: "button",
3714
+ style: {
3715
+ ...S.btn,
3716
+ padding: "2px 8px"
3717
+ },
3718
+ disabled: credentialLifecycleBusy !== null,
3719
+ onClick: () => {
3720
+ purgeCredentialRef(ref);
3721
+ },
3722
+ children: credentialLifecycleBusy === ref.reference_id ? copy.credentialPurging : copy.credentialPurgePlan
3723
+ })
3724
+ ]
3725
+ }, ref.reference_id))
3726
+ ]
3727
+ })
3728
+ ]
3729
+ }),
3193
3730
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CollapsibleSection, {
3194
3731
  sectionId: "providers",
3195
3732
  title: copy.sectionNames.providers,