@ran-sh/dsh-crew 1.9.0 → 1.10.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 (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +126 -126
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +3 -2
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -340
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +463 -455
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. package/zcode/commands/dsh-status.md +5 -5
@@ -1,1711 +1,1711 @@
1
- // Browser half of dsh-crew: integrations (install/update/restore),
2
- // global worker configuration, and the live jobs table.
3
- // Talks only to the plugin's own loopback routes.
4
-
5
- import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
6
- import { createPortal } from 'react-dom';
7
- import {
8
- openSections,
9
- readSectionState,
10
- setEverySection,
11
- writeSectionState,
12
- } from './collapsible-sections.mjs';
13
- import { ActivationSummary } from './activation-summary';
14
- import { projectHostReadiness, READINESS_STATES } from './host-readiness.mjs';
15
- import { modelCallabilityState } from './model-callability-view.mjs';
16
- import { acceptReadinessResponse, readinessExpiryDelay } from './readiness-envelope.mjs';
17
- import { CREW_UI_SURFACES, classifyCrewSurface, surfaceResponsibilities } from './surface-detection.mjs';
18
- import { aggregateModelInvocations } from './task-telemetry.mjs';
19
- import { PanelHeader, PanelStyles } from './panel-chrome';
20
- import { HistoryPanel } from './history-panel';
21
- // The schedule contract is shared, not mirrored: model-schedule.mjs imports
22
- // nothing, so the panel can use the same normalization and peak test the router
23
- // does. A hand-maintained copy could accept less than the server and then write
24
- // the difference back on save.
25
- import { normalizeModelSchedule, isPeakAt } from '../model-schedule.mjs';
26
-
27
- export const inject = ['slots', 'locale'];
28
-
29
- const API = '/_dsh/dsh-crew';
30
- const CREW_CONTROL_PLANE_URL = 'http://127.0.0.1:3080/';
31
-
32
- type AdaptiveConfig = { enabled: boolean; window_size: number; min_samples: number };
33
- const DEFAULT_ADAPTIVE: AdaptiveConfig = { enabled: false, window_size: 8, min_samples: 2 };
34
-
35
- function clampInt(value: unknown, fallback: number, min: number, max: number) {
36
- const parsed = Number(value);
37
- if (!Number.isInteger(parsed)) return fallback;
38
- return Math.max(min, Math.min(max, parsed));
39
- }
40
-
41
- function normalizeAdaptive(value: any): AdaptiveConfig {
42
- const windowSize = clampInt(value?.window_size, DEFAULT_ADAPTIVE.window_size, 1, 32);
43
- return {
44
- enabled: value?.enabled === true,
45
- window_size: windowSize,
46
- min_samples: clampInt(value?.min_samples, DEFAULT_ADAPTIVE.min_samples, 1, windowSize),
47
- };
48
- }
49
-
50
-
51
- const COPY = {
52
- zh: {
53
- label: 'DSH Crew',
54
- title: 'DSH Crew',
55
- openOfficial: '打开 3080 官方界面 →',
56
- intro: '管理工作流、模型路由与宿主集成。展开分区查看和修改设置。',
57
- surfaceLabel: '3210 / 高级配置',
58
- integrations: '集成',
59
- installed: '已安装', notInstalled: '未安装', ready: '集成就绪', integrationReady: '集成就绪', modelCallable: '模型可调用', modelNotCallable: '模型未验证', modelUnknown: '模型未知', notReady: '未就绪', hud: 'HUD 段',
60
- install: '安装', update: '更新', restore: '还原',
61
- confirmRestore: (name: string) => `确定从 ${name} 移除 dsh-crew 集成?(settings 会先备份)`,
62
- globalConfig: '全局配置',
63
- expandAll: '全部展开', collapseAll: '全部折叠',
64
- modelCount: (count: number) => `${count} 个模型`, providerCount: (count: number) => `${count} 个 Provider`,
65
- jobCount: (count: number) => `${count} 个任务`, runningCount: (count: number) => `${count} 个运行中`,
66
- sectionNames: { integrations: 'Codex / Claude / ZCode 集成状态', workflow: 'Crew 工作流设置', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: '模型优先级与派发', schedule: '波峰波谷调度', adaptive: '自适应路由', runtime: '运行 / 生效边界', harnessProviders: 'Harness Providers', jobs: '任务状态' },
67
- harnessHint: 'DeepSeek 官方 Harness 的 dsh-crew profile:Provider、Harness Models 与运行时配置',
68
- hostReadiness: '宿主集成就绪度', hostReadinessHint: '只使用结构化安装与运行时证据;缺少证据不会显示 READY。',
69
- readinessLabels: { codex_mcp: 'Codex MCP', ds_worker: 'ds-worker', ds_reviewer: 'ds-reviewer', claude_plugin: 'Claude plugin', zcode_mcp: 'ZCode MCP', crew_harness: 'Crew plugin profile', official_bridge: 'Official bridge' },
70
- readinessStates: { READY: 'READY', DEGRADED: 'DEGRADED', UNAVAILABLE: 'UNAVAILABLE', UNKNOWN: 'UNKNOWN', NOT_APPLICABLE: 'N/A' },
71
- globalHint: '修改即时保存到 ~/.config/dsh-crew/config.json;CC / Codex 的新会话自动读取为默认值(会话内可用 /dsh-crew:config 临时覆盖)。',
72
- orchestration: 'Agent 编排',
73
- enableSubagents: '启用子 Agent',
74
- enableSubagentsHint: '总开关。关闭后 dsh_run_worker / dsh_spawn_worker 在工具层拒绝派发(配置保留,重新开启即恢复)。',
75
- subagentsKept: '配置已保留',
76
- mainAgentMode: '主 Agent 模式',
77
- mainAgentModeDesc: { 'direct-allowed': '直接允许 · 可自己做,也可委派', 'coordinator-first': '协调优先 · 先规划、拆分、委派,再检查验证', 'dispatcher-only': '仅派发 · 尽量只做规划、派发、审查与整合' },
78
- mainAgentModeHint: '这是宿主路由指引,不是对宿主自身工具的硬限制。',
79
- collaborationMode: '协作模式',
80
- collaborationModeDesc: { 'flash-only': 'Flash Only · 只用 Flash', 'pro-only': 'Pro Only · 只用 Pro', balanced: 'Balanced · 双 Auto', 'review-pipeline': 'Review Pipeline · Flash 实现 + Pro 复审', custom: 'Custom · 完全手动' },
81
- tierCardFlash: 'Flash', tierCardPro: 'Pro',
82
- tierState: '状态',
83
- tierStateDesc: { disabled: '禁用', manual: '手动(仅用户点名时可用)', auto: '自动(orchestrator 可自动委派)' },
84
- stateManagedByPreset: '由协作模式管理',
85
- statePresetHint: '切到 Custom 可单独配置各档状态。',
86
- workerProvider: 'Worker Provider',
87
- workerProviderDesc: { 'follow-dsh': '跟随 Harness · 使用各档独立的模型优先级', 'deepseek-official': 'DeepSeek Official · 使用旧版固定模型路由' },
88
- workerProviderHint: '仅作用于 Hub 模式;Standalone 仅保留为旧版迁移元数据,不是生产执行路径。',
89
- workerModels: 'Worker 模型', refreshHarnessModels: '刷新 Harness 模型', refreshingModels: '刷新中…',
90
- modelPriority: '模型优先级', addPriorityModel: '+ 添加优先模型', modelSearch: '搜索 Provider 或 Model…',
91
- harnessDefault: 'Harness 默认模型', providerUnavailable: 'Provider 不可用', notAdvertised: '当前未列出',
92
- noPriority: '未配置优先项', modelPoolSummary: (p: number, m: number) => `Providers: ${p} · Models: ${m}`,
93
- healthUnprobed: 'UNPROBED', checkedAgo: (s: number) => `${s} 秒前检查`,
94
- partialCatalog: '部分 Provider 读取失败', catalogFailed: '无法读取 Harness 模型目录', removePriority: '移除', moveUp: '上移', moveDown: '下移',
95
- needsModelSelection: '多个 Provider 提供默认偏好模型,且 Harness Default 无法消歧;当前将回退到 Harness Default,请手动选择。',
96
- responsibilities: '职责',
97
- responsibilitiesHint: '路由指引,仅用于描述分工;只剩一个 Auto 档时它承担全部可委派工作。',
98
- roleLabels: { implementation: '代码实现', simple_fix: '简单修复', tests: '测试', search_inspection: '搜索 / 检查', architecture: '架构', complex_debugging: '复杂调试', refactor: '重构', code_review: '代码审查' },
99
- routing: '路由',
100
- proReviewsFlash: 'Pro 复审 Flash 改动',
101
- proReviewsFlashHint: 'Flash 成功后自动追加一次 Pro 复审(仅 blocking 派发)',
102
- proReviewsLocked: 'Review Pipeline 模式固定开启',
103
- tier: '默认档位', effort: '默认推理', mode: '执行模式', timeout: '默认超时(秒)', hubUrl: 'Hub 地址',
104
- tierPolicy: '档位策略', escalate: '失败升档',
105
- tierPolicyDesc: { auto: 'auto · orchestrator 自选', 'flash-only': '只用 flash', 'pro-only': '只用 pro' },
106
- escalateHint: 'flash 失败自动用 pro 重试一次',
107
- presetFlash: 'flash 模式', presetPro: 'pro 模式',
108
- harnessProviders: 'Harness Providers', harnessProviderHint: 'DeepSeek 官方 Harness 的 3210 dsh-crew profile 中的真实 Provider 注册与生命周期;不是视觉 / 生图适配器。',
109
- providerState: (state: string) => `状态:${state}`, providerModels: (count: number) => `${count} 个模型`, providerJobs: (count: number) => `${count} 个运行任务`,
110
- providerOfficialBuiltin: '· 官方内置(不可删除)', providerSourceUnresolved: '· 来源未解析(安全锁定)',
111
- providerLayerMigration: '检测到旧版 profile Provider', providerLayerMigrationHint: '这些 Provider 仍在 Crew profile/base 层,所以 3210 原生页面不会显示删除。请先按迁移计划提升到 Harness 用户设置层;不会复制密钥。', providerLayerMigrationPlan: '迁移计划', providerLayerMigrate: '迁移到用户层', providerLayerMigrating: '迁移中…', providerLayerMigrationConfirm: '确认把这个 Provider 移到 Harness 用户设置层?会先保存可回滚快照,不会复制密钥值。', providerLayerRollback: '回滚迁移',
112
- providerDeletePlan: '生成删除计划', providerDelete: '确认删除', providerPlanReady: '计划已生成;再次确认后才会修改 3210 配置。', providerRollback: '回滚', providerRollbackConfirm: '按已保存事务恢复这个 Harness Provider 及原有路由?', providerRollbackUnavailable: '没有可验证的回滚事务',
113
- providerReplacement: '替换 Harness 默认 Provider(输入 Provider id)', providerDeleteConfirm: '确认按计划删除这个 Harness Provider?会清理路由引用并重启 3210。',
114
- providerDeleteBlocked: '当前 Provider 不能删除(内置、在用、已删除或缺少可用替换)。', providerLifecycleError: 'Provider 生命周期操作失败', providerDeletePending: '配置可能已变更,事务仍待重启/验证;请刷新状态后选择回滚。', providerRollbackContinue: '继续回滚验证', recoveryUnresolved: '发现不可自动恢复的事务', recoveryStorage: '存储项', recoveryQuarantine: '隔离', recoveryQuarantineConfirm: '该事务清单无法安全恢复。将其移入隔离区并阻止自动使用,继续?',
115
- providerNoInventory: '暂时无法读取 3210 Provider inventory。',
116
- credentialReferences: 'Credential 引用', credentialReferenceHint: '仅显示引用名、归属和孤儿状态;不会读取或删除密钥。',
117
- credentialOrphan: '孤儿 · 可单独申请清理', credentialInUse: (count: number) => `使用中 · ${count} 个 Provider`, credentialPurgePlan: '申请清理', credentialPurgeConfirm: '这是不可恢复的 Crew-owned 凭据清理,确认继续?', credentialPurging: '清理中…', credentialPurgeUnavailable: '凭据清理不可用', credentialPurgeUnverified: '已执行清理,但验证失败;引用仍保留用于恢复检查',
118
- staleHub: '本 DSH 实例还在跑旧版插件,缺少该接口 —— 重启 DSH 后再试',
119
- emptyResponse: (path: string, status: number) => `${path} → HTTP ${status},响应为空`,
120
- badJson: (path: string, body: string) => `${path} → 非 JSON 响应: ${body}`,
121
- presetDefault: (id?: string) => `default · 跟随 DSH${id ? ` (${id})` : ''}`,
122
- progressTip: (t: string, turn: number, step: number, calls: number | string) => `耗时 ${t} · 第${turn}轮第${step}步 · ${calls} 次工具调用`,
123
- groupDispatch: '派发', groupRuntime: '运行', groupMultimodal: '多模态',
124
- groupWorkflow: '工作流 / 执行(v0.2)',
125
- roleWorker: 'Worker 角色(执行)', roleReviewer: 'Reviewer 角色(独立审查)',
126
- workerStateLabel: 'Worker 状态', reviewStateLabel: 'Reviewer 状态',
127
- roleStateHint: 'auto:orchestrator 可自动派发;manual:仅显式点名;disabled:禁用。未显式设置时由协作模式推导。',
128
- autoReview: '自动复审', autoReviewHint: 'worker 成功后自动运行一次 reviewer(独立模型策略,只读)',
129
- modelOrdering: '候选排序', orderingDesc: { manual: '手动顺序', 'health-aware': '健康感知' },
130
- orderingHint: '手动 priority 始终保持顺序;健康感知仅重排未显式锁定的自动候选。',
131
- healthGate: '健康门', healthGateDesc: { 'hard-failures': '跳过明确不可调用', off: '关闭健康门' },
132
- reviewGate: 'Reviewer 验收门', reviewGateDesc: { required: 'required · 未批准则失败', optional: 'optional · 记录但不阻断', off: 'off · 不自动复审' },
133
- isolation: '工作区隔离', isolationHint: 'worktree 为默认;非 Git 仓库时需显式 shared,否则任务以 NOT_GIT_REPOSITORY 失败。',
134
- isolationDesc: { worktree: 'worktree · 每个 coding worker 独立临时 git worktree(主工作区不动)', shared: 'shared · 旧版就地执行' },
135
- maxParallel: '最大并行', maxParallelHint: '同时运行的工作流上限(1–16);超出进入队列。',
136
- legacyCompatibility: '↓ 以下为兼容配置(legacy,Flash/Pro 仍可编辑)',
137
- cardDispatch: { t: '派发策略', d: 'orchestrator 未指定时的档位与推理强度,以及档位约束、失败升档与两档各自挂载的 Agent 预设。' },
138
- cardRuntime: { t: '执行与连接', d: 'worker 会话跑在本实例内还是独立进程,单个任务的超时上限,以及 CC / Codex 探测本实例的地址。' },
139
- modeDesc: { auto: 'auto · 优先 3210', hub: 'hub · 必须 3210' },
140
- save: '保存', saved: '已保存', jobs: 'Worker 任务', empty: '当前没有 Worker / Reviewer 任务。',
141
- adaptiveTitle: '自适应模型路由(实验)', adaptiveHint: '默认关闭;只重排系统自动候选,手动模型优先级始终保持原序。健康信号仅来自本进程已观察到的成功、失败、超时与粗粒度延迟,重启后清空。',
142
- adaptiveEnabled: '启用自适应路由', adaptiveWindow: '健康窗口', adaptiveSamples: '最少样本', adaptiveBoundary: '下一个工作流生效',
143
- scheduleTitle: '波峰波谷调度', scheduleHint: '按本地时间限制指定模型:波峰时被“阻止”的模型会被跳过,由下一个候选接替任务;“提醒”的仍会使用并在选择轨迹中标记。未列出的模型不受限制。默认时段为 DeepSeek 公布的波峰时间(工作日 09:00-12:00、14:00-18:00)。',
144
- scheduleTimezone: '时区(相对 UTC)', scheduleWindows: '波峰时段', schedulePeak: '现在处于波峰', scheduleOffPeak: '现在处于波谷',
145
- scheduleAddWindow: '添加时段', scheduleStart: '开始', scheduleEnd: '结束',
146
- scheduleBlock: '阻止', scheduleWarn: '提醒', scheduleOff: '关闭',
147
- scheduleRestricted: '受限模型', scheduleNoRestricted: '还没有受限模型;未列出的模型不受时间限制。',
148
- scheduleWeekdays: '生效工作日', scheduleBoundary: '下次派发生效',
149
- modelActivity: '模型活动概览', modelActivityHint: '聚合本 Hub 内存中最近 500 个含真实调用证据的任务;每个模型按任务计数(不是推测底层轮次),最多显示 50 个模型。不保存提示词、结果或凭据。', noModelActivity: '还没有真实模型活动。',
150
- calls: '任务数', invocationSource: '任务来源', routingSource: '路由来源', lastCalled: '最近调用', role: '角色', model: '模型', never: '—',
151
- col: { id: '任务', role: '角色', source: '来源', model: '模型', tier: '档位', status: '状态', progress: '进度', tokens: 'tokens ⇅', task: '内容' },
152
- working: '处理中…',
153
- tips: {
154
- claude: {
155
- install: '一键装进 Claude Code:注册本地 marketplace、claude plugin install、写入工具权限白名单、接线 claude-hud worker 段;修改 settings.json 前自动备份',
156
- update: '重跑安装流程:刷新注册与权限、迁移旧格式配置、更新 HUD 接线;幂等,可放心点',
157
- restore: '从 Claude Code 移除集成:清除 marketplace 注册、插件启用项与权限规则(settings 先备份);不删除插件源码与已跑任务',
158
- },
159
- codex: {
160
- install: '把 ds-flash / ds-pro 角色和 /dsh-config、/dsh-status 命令复制到 ~/.codex/(MCP 路径按本机自动渲染,含 approve 审批与超时配置)',
161
- update: '重新渲染并覆盖角色文件与命令(插件路径或配置变更后用);原文件先备份',
162
- restore: '删除 ~/.codex/ 下的 ds-flash / ds-pro 角色与 dsh 命令文件(角色文件先备份)',
163
- },
164
- zcode: {
165
- install: '安装 ZCode 全局规则、ds-worker / ds-reviewer 角色、命令和按来源选择的 MCP 配置到 ~/.zcode/',
166
- update: '重新渲染 ZCode 角色、命令和 MCP 条目;保留个人文件与其他 MCP 服务',
167
- restore: '仅移除 dsh-crew 管理的 ZCode 文件和 MCP 条目(保留备份)',
168
- },
169
- },
170
- },
171
- en: {
172
- label: 'DSH Crew',
173
- title: 'DSH Crew',
174
- intro: 'Manage workflows, model routing and host integrations. Expand a section to edit its settings.',
175
- surfaceLabel: '3210 / ADVANCED SETTINGS',
176
- openOfficial: 'Open official frontend (3080) →',
177
- integrations: 'Integrations',
178
- installed: 'installed', notInstalled: 'not installed', ready: 'integration ready', integrationReady: 'integration ready', modelCallable: 'model callable', modelNotCallable: 'model not verified', modelUnknown: 'model unknown', notReady: 'not ready', hud: 'HUD segment',
179
- install: 'Install', update: 'Update', restore: 'Restore',
180
- confirmRestore: (name: string) => `Remove the dsh-crew integration from ${name}? (settings are backed up first)`,
181
- globalConfig: 'Global configuration',
182
- expandAll: 'Expand all', collapseAll: 'Collapse all',
183
- modelCount: (count: number) => `${count} models`, providerCount: (count: number) => `${count} providers`,
184
- jobCount: (count: number) => `${count} jobs`, runningCount: (count: number) => `${count} running`,
185
- sectionNames: { integrations: 'Codex / Claude / ZCode integration status', workflow: 'Crew workflow settings', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: 'Model priority & dispatch', schedule: 'Peak / off-peak scheduling', adaptive: 'Adaptive routing', runtime: 'Runtime / activation boundaries', harnessProviders: 'Harness Providers', jobs: 'Task status' },
186
- harnessHint: 'The official DeepSeek Harness dsh-crew profile: providers, Harness Models, and runtime configuration',
187
- hostReadiness: 'Host integration readiness', hostReadinessHint: 'Uses structured installer and runtime evidence only; missing evidence is never READY.',
188
- readinessLabels: { codex_mcp: 'Codex MCP', ds_worker: 'ds-worker', ds_reviewer: 'ds-reviewer', claude_plugin: 'Claude plugin', zcode_mcp: 'ZCode MCP', crew_harness: 'Crew plugin profile', official_bridge: 'Official bridge' },
189
- readinessStates: { READY: 'READY', DEGRADED: 'DEGRADED', UNAVAILABLE: 'UNAVAILABLE', UNKNOWN: 'UNKNOWN', NOT_APPLICABLE: 'N/A' },
190
- globalHint: 'Changes save instantly to ~/.config/dsh-crew/config.json; new CC / Codex sessions pick them up as defaults (override per session with /dsh-crew:config).',
191
- orchestration: 'Agent orchestration',
192
- enableSubagents: 'Enable Subagents',
193
- enableSubagentsHint: 'Master switch. When off, dsh_run_worker / dsh_spawn_worker refuse dispatch at the tool layer (the configuration is kept; re-enable to restore).',
194
- subagentsKept: 'Configuration kept',
195
- mainAgentMode: 'Main Agent Mode',
196
- mainAgentModeDesc: { 'direct-allowed': 'Direct Allowed · implement directly or delegate', 'coordinator-first': 'Coordinator First · plan, decompose, delegate, verify', 'dispatcher-only': 'Dispatcher Only · plan, dispatch, review, integrate' },
197
- mainAgentModeHint: 'Host routing guidance — not a hard restriction on the host\'s own tools.',
198
- collaborationMode: 'Collaboration Mode',
199
- collaborationModeDesc: { 'flash-only': 'Flash Only', 'pro-only': 'Pro Only', balanced: 'Balanced', 'review-pipeline': 'Review Pipeline · implement + review', custom: 'Custom' },
200
- tierCardFlash: 'Flash', tierCardPro: 'Pro',
201
- tierState: 'State',
202
- tierStateDesc: { disabled: 'Disabled', manual: 'Manual (only when the user names this tier)', auto: 'Auto (the orchestrator may delegate to it)' },
203
- stateManagedByPreset: 'managed by the collaboration mode',
204
- statePresetHint: 'Switch to Custom to configure each tier state separately.',
205
- workerProvider: 'Worker Provider',
206
- workerProviderDesc: { 'follow-dsh': 'Follow Harness · use each tier\'s ordered model priority', 'deepseek-official': 'DeepSeek Official · use legacy fixed model routing' },
207
- workerProviderHint: 'Applies to Hub workers only; Standalone is legacy migration metadata only and is not a production execution path.',
208
- workerModels: 'Worker Models', refreshHarnessModels: 'Refresh Harness Models', refreshingModels: 'Refreshing…',
209
- modelPriority: 'Model Priority', addPriorityModel: '+ Add Priority Model', modelSearch: 'Search Provider or Model…',
210
- harnessDefault: 'Harness Default', providerUnavailable: 'Provider unavailable', notAdvertised: 'Currently not advertised',
211
- noPriority: 'No priority models configured', modelPoolSummary: (p: number, m: number) => `Providers: ${p} · Models: ${m}`,
212
- healthUnprobed: 'UNPROBED', checkedAgo: (s: number) => `checked ${s}s ago`,
213
- partialCatalog: 'Some providers failed to load', catalogFailed: 'Unable to read Harness model catalog', removePriority: 'Remove', moveUp: 'Move up', moveDown: 'Move down',
214
- needsModelSelection: 'Multiple providers advertise the preferred model and Harness Default cannot disambiguate them. Falling back to Harness Default; choose a priority model explicitly.',
215
- responsibilities: 'Responsibilities',
216
- responsibilitiesHint: 'Routing guidance describing the split of work; with a single Auto tier left, that tier carries all delegatable coding work.',
217
- roleLabels: { implementation: 'Code implementation', simple_fix: 'Simple fixes', tests: 'Tests', search_inspection: 'Search / inspection', architecture: 'Architecture', complex_debugging: 'Complex debugging', refactor: 'Refactoring', code_review: 'Code review' },
218
- routing: 'Routing',
219
- proReviewsFlash: 'Pro reviews Flash changes',
220
- proReviewsFlashHint: 'Automatically follow a successful Flash run with one Pro review (blocking dispatch only)',
221
- proReviewsLocked: 'always on in Review Pipeline mode',
222
- tier: 'Default tier', effort: 'Default effort', mode: 'Mode', timeout: 'Timeout (s)', hubUrl: 'Hub URL',
223
- tierPolicy: 'Tier policy', escalate: 'Escalate on failure',
224
- tierPolicyDesc: { auto: 'auto · orchestrator picks', 'flash-only': 'flash only', 'pro-only': 'pro only' },
225
- escalateHint: 'retry a failed flash run once on pro',
226
- presetFlash: 'flash preset', presetPro: 'pro preset',
227
- harnessProviders: 'Harness Providers', harnessProviderHint: 'Live Provider registration and lifecycle in the official DeepSeek Harness dsh-crew profile on 3210.',
228
- providerState: (state: string) => `State: ${state}`, providerModels: (count: number) => `${count} models`, providerJobs: (count: number) => `${count} running jobs`,
229
- providerOfficialBuiltin: '· official built-in (immutable)', providerSourceUnresolved: '· source unresolved (locked)',
230
- providerLayerMigration: 'Legacy profile Providers detected', providerLayerMigrationHint: 'These Providers still exist in the Crew profile/base layer, so the native 3210 page cannot show Delete. Promote them to Harness user settings with an explicit plan; keys are never copied.', providerLayerMigrationPlan: 'Migration plan', providerLayerMigrate: 'Migrate to user layer', providerLayerMigrating: 'Migrating…', providerLayerMigrationConfirm: 'Move this Provider into Harness user settings? A rollback snapshot is saved first; credential values are never copied.', providerLayerRollback: 'Rollback migration',
231
- providerDeletePlan: 'Plan deletion', providerDelete: 'Confirm delete', providerPlanReady: 'Plan ready; a second confirmation is required before changing 3210 config.', providerRollback: 'Rollback', providerRollbackConfirm: 'Restore this Harness Provider and its previous routing from the saved transaction?', providerRollbackUnavailable: 'No verified rollback transaction is available',
232
- providerReplacement: 'Replacement Harness Default provider id', providerDeleteConfirm: 'Delete this Harness Provider per the plan? Routing refs will be scrubbed and 3210 restarted.',
233
- providerDeleteBlocked: 'This Provider cannot be deleted (built-in, in use, already absent, or missing a valid replacement).', providerLifecycleError: 'Provider lifecycle operation failed', providerDeletePending: 'Configuration may already be changed; the transaction still needs restart/verification. Refresh status and use rollback if needed.', providerRollbackContinue: 'Continue rollback verification', recoveryUnresolved: 'An unrecoverable recovery transaction is blocking deletion', recoveryStorage: 'storage entry', recoveryQuarantine: 'Quarantine', recoveryQuarantineConfirm: 'This recovery manifest cannot be safely reopened. Move it into the quarantine area and keep it out of automatic recovery?',
234
- providerNoInventory: '3210 Provider inventory is temporarily unavailable.',
235
- credentialReferences: 'Credential references', credentialReferenceHint: 'Names, ownership, and orphan status only; values are never read or deleted.',
236
- credentialOrphan: 'orphan · separate purge approval required', credentialInUse: (count: number) => `in use · ${count} provider(s)`, credentialPurgePlan: 'Purge…', credentialPurgeConfirm: 'This irreversibly removes a Crew-owned credential. Continue?', credentialPurging: 'Purging…', credentialPurgeUnavailable: 'Credential purge unavailable', credentialPurgeUnverified: 'Purge ran but verification failed; the reference remains visible for recovery',
237
- staleHub: 'This DSH instance is still running an older build of the plugin and lacks this route — restart DSH and retry',
238
- emptyResponse: (path: string, status: number) => `${path} → HTTP ${status}, empty response`,
239
- badJson: (path: string, body: string) => `${path} → non-JSON response: ${body}`,
240
- presetDefault: (id?: string) => `default · follow DSH${id ? ` (${id})` : ''}`,
241
- progressTip: (t: string, turn: number, step: number, calls: number | string) => `${t} elapsed · turn ${turn}, step ${step} · ${calls} tool calls`,
242
- groupDispatch: 'Dispatch', groupRuntime: 'Runtime', groupMultimodal: 'Multimodal',
243
- groupWorkflow: 'Workflow / Execution (v0.2)',
244
- roleWorker: 'Worker role (execution)', roleReviewer: 'Reviewer role (independent review)',
245
- workerStateLabel: 'Worker state', reviewStateLabel: 'Reviewer state',
246
- roleStateHint: 'auto = the orchestrator may dispatch automatically; manual = only when explicitly named; disabled = off. When unset, derived from the collaboration mode.',
247
- autoReview: 'Automatic review', autoReviewHint: 'Run one reviewer pass (independent model policy, read-only) after a successful worker run',
248
- modelOrdering: 'Candidate ordering', orderingDesc: { manual: 'Manual order', 'health-aware': 'Health-aware' },
249
- orderingHint: 'Explicit priority always keeps its order; health-aware mode only reorders automatic, unlocked candidates.',
250
- healthGate: 'Health gate', healthGateDesc: { 'hard-failures': 'Skip known hard failures', off: 'Health gate off' },
251
- reviewGate: 'Reviewer acceptance gate', reviewGateDesc: { required: 'required · fail unless approved', optional: 'optional · record without blocking', off: 'off · no automatic review' },
252
- isolation: 'Workspace isolation', isolationHint: 'worktree is the default; non-git workspaces require explicit shared, otherwise jobs fail with NOT_GIT_REPOSITORY.',
253
- isolationDesc: { worktree: 'worktree · each coding worker runs in its own temporary git worktree (primary untouched)', shared: 'shared · legacy in-place execution' },
254
- maxParallel: 'Max parallel', maxParallelHint: 'Upper bound of concurrently running workflows (1–16); extra ones queue.',
255
- legacyCompatibility: '↓ Legacy compatibility controls below (Flash/Pro still editable)',
256
- cardDispatch: { t: 'Dispatch policy', d: 'Tier and effort used when the orchestrator names none, plus tier clamping, failure escalation and the Agent preset mounted per tier.' },
257
- cardRuntime: { t: 'Execution & connection', d: 'Whether worker sessions run inside this instance or a separate process, the per-job timeout, and the address CC / Codex probes.' },
258
- modeDesc: { auto: 'auto · prefer 3210', hub: 'hub · require 3210' },
259
- save: 'Save', saved: 'Saved', jobs: 'Worker jobs', empty: 'No Worker / Reviewer jobs yet.',
260
- adaptiveTitle: 'Adaptive model routing (experimental)', adaptiveHint: 'Off by default. Only system-derived candidates may be reordered; explicit model priorities always keep their order. Health uses only process-local success, failure, timeout, and coarse latency observations and resets on restart.',
261
- adaptiveEnabled: 'Enable adaptive routing', adaptiveWindow: 'Health window', adaptiveSamples: 'Minimum samples', adaptiveBoundary: 'Effective for the next workflow',
262
- scheduleTitle: 'Peak / off-peak scheduling', scheduleHint: 'Restricts chosen models by local wall clock. During peak, a "block" model is skipped and the next candidate serves the job; a "warn" model is still used and flagged in the selection trace. Models not listed are unrestricted. Defaults are DeepSeek\'s published peak hours (weekdays 09:00-12:00 and 14:00-18:00).',
263
- scheduleTimezone: 'Timezone (relative to UTC)', scheduleWindows: 'Peak windows', schedulePeak: 'Peak hours now', scheduleOffPeak: 'Off-peak now',
264
- scheduleAddWindow: 'Add window', scheduleStart: 'Start', scheduleEnd: 'End',
265
- scheduleBlock: 'Block', scheduleWarn: 'Warn', scheduleOff: 'Off',
266
- scheduleRestricted: 'Restricted models', scheduleNoRestricted: 'No restricted models yet; unlisted models have no time limit.',
267
- scheduleWeekdays: 'Days the windows apply', scheduleBoundary: 'Effective for the next dispatch',
268
- modelActivity: 'Model activity overview', modelActivityHint: 'Aggregates the latest 500 in-memory Hub jobs with real model-activity evidence; each model is counted once per qualifying job (not an inferred turn count). Shows at most 50 models; prompts, results, and credentials are never stored here.', noModelActivity: 'No real model activity yet.',
269
- calls: 'Jobs', invocationSource: 'Task source', routingSource: 'Routing source', lastCalled: 'Last called', role: 'Role', model: 'Model', never: '—',
270
- col: { id: 'job', role: 'role', source: 'source', model: 'model', tier: 'tier', status: 'status', progress: 'progress', tokens: 'tokens ⇅', task: 'task' },
271
- working: 'Working…',
272
- tips: {
273
- claude: {
274
- install: 'One-click install into Claude Code: register local marketplace, claude plugin install, permission allowlist, claude-hud segment wiring; settings.json backed up first',
275
- update: 'Re-run the installer: refresh registration & permissions, migrate legacy config, update HUD wiring; idempotent',
276
- restore: 'Remove the integration from Claude Code: marketplace registration, plugin enablement and permission rules (settings backed up); plugin source and past jobs untouched',
277
- },
278
- codex: {
279
- install: 'Copy ds-flash / ds-pro roles and /dsh-config, /dsh-status prompts into ~/.codex/ (MCP paths rendered for this machine, approve mode + timeout included)',
280
- update: 'Re-render and overwrite role files and prompts (after path/config changes); originals backed up',
281
- restore: 'Delete ds-flash / ds-pro roles and dsh prompts from ~/.codex/ (roles backed up first)',
282
- },
283
- zcode: {
284
- install: 'Install the ZCode global policy, ds-worker / ds-reviewer agents, commands and source-aware MCP config under ~/.zcode/',
285
- update: 'Re-render ZCode agents, commands and MCP entry; user files and unrelated MCP servers are preserved',
286
- restore: 'Remove only dsh-crew-owned ZCode files and MCP entry (backups kept)',
287
- },
288
- },
289
- },
290
- };
291
-
292
- const S = {
293
- // Margins are tuned against the page container's 8px flex gap: the gap alone
294
- // sits below each heading, the margin only adds separation above it.
295
- section: { margin: '8px 0 -2px', fontSize: 13, fontWeight: 600 as const, opacity: 0.9 },
296
- // Small muted group label, matching the host's Agent-preset page (内置 / 自定义).
297
- group: { margin: '4px 0 -3px', fontSize: 12, opacity: 0.55 },
298
- // Card with a title + description header above its fields.
299
- block: { border: '1px solid rgba(128,128,128,0.22)', borderRadius: 10, padding: '11px 14px 13px', display: 'flex', flexDirection: 'column' as const, gap: 10 },
300
- blockGrid: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '12px 14px', alignItems: 'end' as const },
301
- settingTitle: { fontWeight: 600 as const, fontSize: 13 },
302
- settingDesc: { fontSize: 11.5, opacity: 0.6, marginTop: 2, lineHeight: 1.5 },
303
- card: { border: '1px solid rgba(128,128,128,0.22)', borderRadius: 8, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' as const },
304
- grid: {
305
- border: '1px solid rgba(128,128,128,0.22)', borderRadius: 8, padding: '12px 14px',
306
- display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '12px 14px', alignItems: 'end' as const,
307
- },
308
- btn: { padding: '4px 12px', borderRadius: 6, border: '1px solid rgba(128,128,128,0.35)', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 12.5 },
309
- sectionShell: { border: '1px solid rgba(128,128,128,0.24)', borderRadius: 11, overflow: 'hidden', background: 'rgba(128,128,128,0.025)' },
310
- sectionButton: { width: '100%', padding: '10px 13px', border: 0, background: 'transparent', color: 'inherit', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 9, textAlign: 'left' as const, font: 'inherit' },
311
- sectionBody: { padding: '2px 11px 12px', display: 'flex', flexDirection: 'column' as const, gap: 8 },
312
- chip: (on: boolean) => ({ fontSize: 11.5, padding: '1px 8px', borderRadius: 99, border: '1px solid', borderColor: on ? 'rgba(63,185,80,0.5)' : 'rgba(128,128,128,0.35)', color: on ? '#3fb950' : 'inherit', opacity: on ? 1 : 0.55 }),
313
- field: { display: 'flex', flexDirection: 'column' as const, gap: 3 },
314
- fieldLabel: { fontSize: 11, opacity: 0.6 },
315
- input: { padding: '3px 8px', borderRadius: 5, border: '1px solid rgba(128,128,128,0.35)', background: 'transparent', color: 'inherit', fontSize: 12.5 },
316
- cell: { padding: '5px 10px 5px 0', borderBottom: '1px solid rgba(128,128,128,0.16)', textAlign: 'left' as const, verticalAlign: 'top' as const },
317
- tight: {
318
- padding: '5px 8px 5px 0', borderBottom: '1px solid rgba(128,128,128,0.16)', textAlign: 'left' as const,
319
- verticalAlign: 'top' as const, whiteSpace: 'nowrap' as const, overflow: 'hidden', textOverflow: 'ellipsis',
320
- },
321
- mono: { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12, fontVariantNumeric: 'tabular-nums' as const },
322
- };
323
-
324
- const VISION_MODELS: Record<string, string[]> = {
325
- 'claude-code': ['haiku', 'sonnet', 'opus'],
326
- codex: ['default', 'gpt-5.4-mini', 'gpt-5.6'],
327
- grok: ['default'],
328
- agy: ['default'],
329
- };
330
-
331
- function CustomSelect({ value, options, onChange, minWidth }: {
332
- value: string; options: Array<{ value: string; label?: string }>;
333
- onChange: (v: string) => void; minWidth?: number;
334
- }) {
335
- const [open, setOpen] = useState<{ left: number; top: number; bottom: number; width: number; up: boolean } | null>(null);
336
- useEffect(() => {
337
- if (!open) return;
338
- const close = () => setOpen(null);
339
- const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
340
- const t = setTimeout(() => document.addEventListener('mousedown', close), 0);
341
- document.addEventListener('keydown', onKey);
342
- return () => { clearTimeout(t); document.removeEventListener('mousedown', close); document.removeEventListener('keydown', onKey); };
343
- }, [open]);
344
- const current = options.find((o) => o.value === value);
345
- return (<>
346
- <button type="button"
347
- style={{ ...S.input, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', minWidth: minWidth ?? 92, width: '100%', boxSizing: 'border-box' as const, justifyContent: 'space-between' }}
348
- onClick={(e: any) => {
349
- const r = e.currentTarget.getBoundingClientRect();
350
- const up = window.innerHeight - r.bottom < 240;
351
- setOpen({ left: r.left, top: r.bottom + 4, bottom: window.innerHeight - r.top + 4, width: Math.max(r.width, 130), up });
352
- }}>
353
- <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label ?? value}</span>
354
- <span style={{ opacity: 0.5, fontSize: 10 }}>▾</span>
355
- </button>
356
- {open && createPortal(
357
- <div
358
- style={{
359
- position: 'fixed', left: open.left, ...(open.up ? { bottom: open.bottom } : { top: open.top }),
360
- minWidth: open.width, zIndex: 9999, background: 'Canvas', color: 'CanvasText',
361
- border: '1px solid rgba(128,128,128,0.3)', borderRadius: 8,
362
- boxShadow: '0 8px 28px rgba(0,0,0,0.22)', padding: 4, maxHeight: 240, overflowY: 'auto',
363
- }}
364
- onMouseDown={(e: any) => e.stopPropagation()}>
365
- {options.map((o) => (
366
- <div key={o.value}
367
- onClick={() => { onChange(o.value); setOpen(null); }}
368
- style={{
369
- padding: '5px 10px', borderRadius: 5, cursor: 'pointer', fontSize: 12.5, whiteSpace: 'nowrap',
370
- background: o.value === value ? 'rgba(128,128,128,0.16)' : 'transparent',
371
- display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center',
372
- }}
373
- onMouseEnter={(e: any) => { e.currentTarget.style.background = 'rgba(128,128,128,0.24)'; }}
374
- onMouseLeave={(e: any) => { e.currentTarget.style.background = o.value === value ? 'rgba(128,128,128,0.16)' : 'transparent'; }}>
375
- <span>{o.label ?? o.value}</span>{o.value === value && <span style={{ opacity: 0.6, fontSize: 11 }}>✓</span>}
376
- </div>
377
- ))}
378
- </div>, document.body)}
379
- </>);
380
- }
381
-
382
- function sectionSummary(...parts: Array<string | number | false | null | undefined>) {
383
- return parts.filter((part) => part !== false && part !== null && part !== undefined && part !== '').join(' · ');
384
- }
385
-
386
- function sectionStorage() {
387
- if (typeof window === 'undefined') return null;
388
- try { return window.localStorage; } catch { return null; }
389
- }
390
-
391
- function CollapsibleSection({ sectionId, title, summary, expanded, onToggle, children }: {
392
- sectionId: string; title: string; summary: string; expanded: boolean;
393
- onToggle: () => void; children: any;
394
- }) {
395
- const bodyId = `dsh-crew-section-${sectionId}`;
396
- return (
397
- <section style={S.sectionShell} data-section-id={sectionId}>
398
- <button type="button" className="crew-section-trigger" style={S.sectionButton} aria-expanded={expanded} aria-controls={bodyId} onClick={onToggle}>
399
- <span aria-hidden="true" style={{ width: 14, opacity: 0.62, transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 140ms ease' }}>›</span>
400
- <span className="crew-section-heading">
401
- <span className="crew-section-title">{title}</span>
402
- <span className="crew-section-summary">{summary}</span>
403
- </span>
404
- </button>
405
- <div id={bodyId} hidden={!expanded} style={expanded ? S.sectionBody : undefined}>{children}</div>
406
- </section>
407
- );
408
- }
409
-
410
- function sourceLabel(source?: string) {
411
- if (source === 'claude-code') return 'CC';
412
- if (source === 'codex') return 'Codex';
413
- if (source === 'api' || source === undefined) return 'API';
414
- return source;
415
- }
416
-
417
- function elapsed(startedAt: string, endedAt?: string | null) {
418
- const s = Math.max(0, Math.round(((endedAt ? +new Date(endedAt) : Date.now()) - +new Date(startedAt)) / 1000));
419
- return s >= 60 ? `${Math.floor(s / 60)}m${s % 60}s` : `${s}s`;
420
- }
421
- function ktok(n: number) { return n >= 1000 ? `${Math.round(n / 100) / 10}k` : `${n}`; }
422
-
423
- function formatTimestamp(value: string | null, locale: string) {
424
- if (!value || !Number.isFinite(Date.parse(value))) return '—';
425
- return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en', {
426
- month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
427
- }).format(new Date(value));
428
- }
429
-
430
- function readinessChip(state: string) {
431
- const color = state === READINESS_STATES.READY ? '#3fb950'
432
- : state === READINESS_STATES.DEGRADED ? '#c98735'
433
- : state === READINESS_STATES.UNAVAILABLE ? '#f85149'
434
- : state === READINESS_STATES.NOT_APPLICABLE ? '#7d8590' : 'inherit';
435
- return {
436
- fontSize: 10.5, fontWeight: 650, padding: '1px 7px', borderRadius: 99,
437
- border: `1px solid ${state === READINESS_STATES.UNKNOWN ? 'rgba(128,128,128,0.35)' : color}`,
438
- color, opacity: state === READINESS_STATES.UNKNOWN ? 0.58 : 1,
439
- };
440
- }
441
-
442
- function MinimalCrewPanel({ locale, surface, runtime }: { locale: string; surface: string; runtime: any }) {
443
- const zh = locale === 'zh';
444
- const native = surface === CREW_UI_SURFACES.NATIVE;
445
- const title = native ? (zh ? 'DSH Crew Runtime' : 'DSH Crew Runtime') : (zh ? 'DSH Crew Surface 未确认' : 'DSH Crew surface not verified');
446
- const hint = native
447
- ? (zh
448
- ? '这里是 DeepSeek 官方 Harness 的专用 dsh-crew profile,DSH Crew 作为插件加载其中。请使用 Harness 原生菜单管理 Provider、Harness Models、Agent 预设与底层配置。'
449
- : 'This is the dedicated dsh-crew profile of the official DeepSeek Harness, with DSH Crew loaded as a plugin. Use the native Harness menus for Providers, Harness Models, Agent presets, and low-level settings.')
450
- : (zh
451
- ? '无法用结构化后端证据确认当前界面,因此已保守隐藏 Crew 编排、任务与宿主集成功能。'
452
- : 'Structured backend evidence could not identify this surface, so Crew orchestration, jobs, and host integrations are conservatively hidden.');
453
- const runtimeReady = runtime?.ok === true && runtime?.service === 'dsh-crew-hub';
454
- return (
455
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 13, lineHeight: 1.55 }}>
456
- <div style={{ ...S.block, background: 'linear-gradient(135deg, rgba(74,158,255,0.08), rgba(128,128,128,0.02))' }}>
457
- <div>
458
- <div style={{ fontSize: 19, fontWeight: 680 }}>{title}</div>
459
- <div style={{ fontSize: 12, opacity: 0.68, marginTop: 3 }}>{hint}</div>
460
- </div>
461
- <div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
462
- <span style={readinessChip(runtimeReady ? READINESS_STATES.READY : READINESS_STATES.UNKNOWN)}>
463
- {runtimeReady ? `Runtime ${runtime.runtime_version ?? 'READY'}` : 'Runtime UNKNOWN'}
464
- </span>
465
- <span style={readinessChip(native ? READINESS_STATES.READY : READINESS_STATES.UNKNOWN)}>
466
- {native ? '3210 · official profile + Crew plugin' : 'surface · unknown'}
467
- </span>
468
- </div>
469
- <a href={CREW_CONTROL_PLANE_URL} target="_blank" rel="noopener noreferrer" style={{
470
- ...S.btn, alignSelf: 'flex-start', textDecoration: 'none', fontWeight: 650, padding: '7px 12px',
471
- borderColor: 'rgba(74,158,255,0.55)', background: 'rgba(74,158,255,0.08)',
472
- }}>{zh ? '打开官方 Harness 界面(3080) →' : 'Open official Harness (3080) →'}</a>
473
- </div>
474
- </div>
475
- );
476
- }
477
-
478
- function WorkersPanel({ ctx }: { ctx: any }) {
479
- const locale = useSyncExternalStore(
480
- (notify: () => void) => ctx.on('locale/change', notify),
481
- () => ctx.locale.getLocale().active,
482
- () => ctx.locale.getLocale().active,
483
- );
484
- const copy = COPY[locale === 'zh' ? 'zh' : 'en'];
485
-
486
- const [jobs, setJobs] = useState<any[]>([]);
487
- const [hoverTask, setHoverTask] = useState<{ id: string; text: string; right: number; top: number; bottom: number; flip: boolean } | null>(null);
488
- const [status, setStatus] = useState<any>(null);
489
- const [config, setConfig] = useState<any>(null);
490
- const [dynModels, setDynModels] = useState<Record<string, Array<{ value: string; label?: string }>>>({});
491
- const [presetOptions, setPresetOptions] = useState<Array<{ value: string; label?: string }>>([{ value: 'default' }]);
492
- const [notice, setNotice] = useState('');
493
- const [busy, setBusy] = useState(false);
494
- const [modelAdd, setModelAdd] = useState<string | null>(null);
495
- const [modelCatalog, setModelCatalog] = useState<any>(null);
496
- const [modelCatalogError, setModelCatalogError] = useState('');
497
- const [modelCatalogBusy, setModelCatalogBusy] = useState(false);
498
- const [providerInventory, setProviderInventory] = useState<any>(null);
499
- const [providerHealth, setProviderHealth] = useState<any[]>([]);
500
- const [credentialRefs, setCredentialRefs] = useState<any[]>([]);
501
- const [credentialUnverified, setCredentialUnverified] = useState<any[]>([]);
502
- const [credentialLifecycleBusy, setCredentialLifecycleBusy] = useState<string | null>(null);
503
- const [providerInventoryError, setProviderInventoryError] = useState('');
504
- const [providerLifecycleBusy, setProviderLifecycleBusy] = useState<string | null>(null);
505
- const [modelPickerTier, setModelPickerTier] = useState<'flash' | 'pro' | null>(null);
506
- const [modelQuery, setModelQuery] = useState('');
507
- const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>(() => readSectionState(sectionStorage()));
508
- const [surface, setSurface] = useState<string>('detecting');
509
- const [readinessEnvelope, setReadinessEnvelope] = useState<{ runtime?: any; snapshot?: any; expiresAt?: number | null }>({});
510
- const readinessGeneration = useRef(0);
511
- const runtimeInfo = readinessEnvelope.runtime;
512
- const readinessSnapshot = readinessEnvelope.snapshot;
513
-
514
- useEffect(() => {
515
- const delay = readinessExpiryDelay(readinessEnvelope.expiresAt);
516
- if (delay === null) return undefined;
517
- const expected = readinessEnvelope;
518
- const timer = setTimeout(() => setReadinessEnvelope((current) => current === expected ? {} : current), delay);
519
- return () => clearTimeout(timer);
520
- }, [readinessEnvelope]);
521
-
522
- const toggleSection = (sectionId: string) => {
523
- setExpandedSections((current) => ({ ...current, [sectionId]: !current[sectionId] }));
524
- };
525
- const toggleAllSections = (expanded: boolean) => setExpandedSections(setEverySection(expanded));
526
-
527
- useEffect(() => {
528
- writeSectionState(sectionStorage(), expandedSections);
529
- }, [expandedSections]);
530
-
531
- useEffect(() => {
532
- if (modelCatalogError) setExpandedSections((current) => openSections(current, ['workflow', 'flash', 'pro']));
533
- }, [modelCatalogError]);
534
-
535
- useEffect(() => {
536
- if (providerInventoryError) setExpandedSections((current) => openSections(current, ['harnessProviders']));
537
- }, [providerInventoryError]);
538
- useEffect(() => {
539
- if (jobs.some((job) => job.status === 'running')) {
540
- setExpandedSections((current) => openSections(current, ['jobs']));
541
- }
542
- }, [jobs]);
543
-
544
- /**
545
- * The panel is served from disk on every load, but the hub's routes are
546
- * registered at instance boot — after an upgrade the new UI can call a route
547
- * the running instance does not have yet, which answers 404/405 with an empty
548
- * body. Report that as "restart DSH" instead of a JSON parse error.
549
- */
550
- const readJson = useCallback(async (res: Response, path: string) => {
551
- const text = await res.text();
552
- if (text === '') {
553
- throw new Error(res.status === 404 || res.status === 405
554
- ? `${copy.staleHub}(${path} → HTTP ${res.status})`
555
- : copy.emptyResponse(path, res.status));
556
- }
557
- try { return JSON.parse(text); } catch { throw new Error(copy.badJson(path, text.slice(0, 160))); }
558
- }, [copy]);
559
- /** Every request carries the active locale: the panel is the authority on it
560
- * (DSH's setting may be unset), and the hub localizes its own strings from it. */
561
- const withLang = useCallback((path: string) => `${API}${path}${path.includes('?') ? '&' : '?'}lang=${locale === 'zh' ? 'zh' : 'en'}`, [locale]);
562
- const get = useCallback(async (path: string, timeoutMs = 8_000) => readJson(await fetch(withLang(path), { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) }), path), [readJson, withLang]);
563
- const post = useCallback(async (path: string, body: any) => readJson(await fetch(withLang(path), {
564
- method: 'POST', headers: { 'content-type': 'application/json' },
565
- body: JSON.stringify({ ...body, lang: locale === 'zh' ? 'zh' : 'en' }),
566
- }), path), [readJson, withLang, locale]);
567
-
568
- const detectSurface = useCallback(async () => {
569
- const readOptional = async (path: string) => {
570
- try {
571
- const response = await fetch(`${API}${path}`, { cache: 'no-store' });
572
- if (!response.ok) return null;
573
- const body = await response.json();
574
- return body && typeof body === 'object' ? body : null;
575
- } catch { return null; }
576
- };
577
- const [bridgeStatus, runtime] = await Promise.all([
578
- readOptional('/bridge-status'),
579
- readOptional('/runtime'),
580
- ]);
581
- setSurface(classifyCrewSurface({ bridgeStatus, runtime }));
582
- }, []);
583
-
584
- useEffect(() => { void detectSurface(); }, [detectSurface]);
585
-
586
- const refreshAll = useCallback(async () => {
587
- const generation = ++readinessGeneration.current;
588
- setReadinessEnvelope({});
589
- try {
590
- const [j, s, c, pr, pi, ph, cr] = await Promise.all([
591
- get('/jobs'), get('/install/status'), get('/config'), get('/presets'),
592
- get('/providers').catch(() => null), get('/provider-health').catch(() => null), get('/credential-references').catch(() => null),
593
- ]);
594
- if (j.ok) setJobs(j.jobs ?? []);
595
- if (s.ok) setStatus(s.status);
596
- if (c.ok) setConfig((prev: any) => prev ?? c.config);
597
- if (pr.ok) setPresetOptions([
598
- { value: 'default', label: copy.presetDefault(pr.defaultId) },
599
- ...(pr.presets ?? []).map((x: any) => ({ value: x.id, label: x.name ?? x.id })),
600
- ]);
601
- if (pi?.ok) setProviderInventory(pi);
602
- if (ph?.ok) setProviderHealth(ph.health ?? []);
603
- if (cr?.ok) { setCredentialRefs(cr.records ?? []); setCredentialUnverified(cr.unverified_purges ?? []); }
604
- } catch {
605
- if (generation === readinessGeneration.current) setReadinessEnvelope({});
606
- }
607
- }, [get]);
608
-
609
- useEffect(() => {
610
- const responsibilities = surfaceResponsibilities(surface);
611
- if (!responsibilities.fullControlPlane) return undefined;
612
- void refreshAll();
613
- let cancelled = false;
614
- let timer: ReturnType<typeof setTimeout> | undefined;
615
- const poll = async () => {
616
- const generation = ++readinessGeneration.current;
617
- try {
618
- const ext = await get('/extension', 5_000);
619
- if (!cancelled && generation === readinessGeneration.current) {
620
- const accepted = acceptReadinessResponse(ext, { generation, latestGeneration: readinessGeneration.current });
621
- if (accepted.accepted) setReadinessEnvelope(accepted.envelope);
622
- }
623
- } catch {
624
- if (!cancelled && generation === readinessGeneration.current) setReadinessEnvelope({});
625
- }
626
- void Promise.all([get('/jobs', 5_000).catch(() => null), get('/provider-health', 5_000).catch(() => null)]).then(([j, health]) => {
627
- if (cancelled) return;
628
- if (j?.ok) setJobs(j.jobs ?? []);
629
- if (health?.ok) setProviderHealth(health.health ?? []);
630
- }).catch(() => {});
631
- if (!cancelled) timer = setTimeout(() => { void poll(); }, 3_000);
632
- };
633
- void poll();
634
- return () => { cancelled = true; readinessGeneration.current += 1; if (timer) clearTimeout(timer); };
635
- }, [surface, refreshAll, get]);
636
-
637
- const refreshHarnessModels = useCallback(async () => {
638
- setModelCatalogBusy(true);
639
- setModelCatalogError('');
640
- try {
641
- const result = await get('/models');
642
- if (!result.ok) throw new Error(result.error ?? copy.catalogFailed);
643
- setModelCatalog(result);
644
- } catch (error: any) {
645
- setModelCatalogError(error?.message ?? copy.catalogFailed);
646
- } finally {
647
- setModelCatalogBusy(false);
648
- }
649
- }, [get, copy]);
650
-
651
- const refreshProviderInventory = useCallback(async () => {
652
- setProviderInventoryError('');
653
- try {
654
- const [result, health, refs] = await Promise.all([get('/providers'), get('/provider-health').catch(() => null), get('/credential-references').catch(() => null)]);
655
- if (!result.ok) throw new Error(result.error ?? copy.providerNoInventory);
656
- setProviderInventory(result);
657
- if (health?.ok) setProviderHealth(health.health ?? []);
658
- if (refs?.ok) { setCredentialRefs(refs.records ?? []); setCredentialUnverified(refs.unverified_purges ?? []); }
659
- } catch (error: any) {
660
- setProviderInventoryError(error?.message ?? copy.providerNoInventory);
661
- }
662
- }, [get, copy]);
663
-
664
- const purgeCredentialRef = useCallback(async (ref: any) => {
665
- if (ref?.ownership !== 'crew-owned' || ref?.orphan !== true || ref?.purge_capability !== 'eligible') return;
666
- const referenceId = String(ref.reference_id ?? '');
667
- if (!referenceId || !window.confirm(copy.credentialPurgeConfirm)) return;
668
- setCredentialLifecycleBusy(referenceId);
669
- setNotice(copy.working);
670
- try {
671
- const encoded = encodeURIComponent(referenceId);
672
- const planned = await post(`/credential-references/${encoded}/purge-plan`, {});
673
- if (!planned.ok || !planned.plan) throw new Error(planned.code ?? copy.credentialPurgeUnavailable);
674
- if (!window.confirm(`${copy.credentialPurgeConfirm}\n\n${referenceId}`)) return;
675
- const result = await readJson(await fetch(withLang(`/credential-references/${encoded}`), {
676
- method: 'DELETE', headers: { 'content-type': 'application/json' },
677
- body: JSON.stringify({ plan_id: planned.plan.plan_id, expected_revision: planned.plan.expected_revision, confirm: true, lang: locale === 'zh' ? 'zh' : 'en' }),
678
- }), `/credential-references/${encoded}`);
679
- if (!result.ok || result.state !== 'VERIFIED') throw new Error(result.code ?? copy.credentialPurgeUnavailable);
680
- setNotice(copy.saved);
681
- await refreshProviderInventory();
682
- } catch (error: any) {
683
- setNotice(String(error?.message ?? error));
684
- } finally {
685
- setCredentialLifecycleBusy(null);
686
- }
687
- }, [copy, locale, post, readJson, refreshProviderInventory, withLang]);
688
-
689
- useEffect(() => {
690
- if (surfaceResponsibilities(surface).fullControlPlane) void refreshHarnessModels();
691
- }, [surface, refreshHarnessModels]);
692
-
693
- useEffect(() => {
694
- if (surfaceResponsibilities(surface).fullControlPlane) void refreshProviderInventory();
695
- }, [surface, refreshProviderInventory]);
696
-
697
- const act = useCallback(async (target: string, confirmName?: string) => {
698
- if (confirmName && !window.confirm(copy.confirmRestore(confirmName))) return;
699
- setBusy(true); setNotice(copy.working);
700
- try {
701
- const r = await post('/install', { target });
702
- if (r.ok === false) throw new Error(r.error);
703
- setNotice((r.actions ?? []).join(';'));
704
- const s = await get('/install/status'); if (s.ok) setStatus(s.status);
705
- } catch (e: any) { setNotice(String(e?.message ?? e)); } finally { setBusy(false); }
706
- }, [post, get, copy]);
707
-
708
- const applyPatch = useCallback(async (patch: Record<string, any>) => {
709
- try {
710
- const result = await post('/config', patch);
711
- if (!result.ok) throw new Error(result.error ?? 'Configuration save failed');
712
- setConfig(result.config);
713
- setNotice(copy.saved);
714
- setTimeout(() => setNotice(''), 1500);
715
- return true;
716
- } catch (error: any) {
717
- setNotice(String(error?.message ?? error));
718
- try {
719
- const authoritative = await get('/config');
720
- if (authoritative.ok) setConfig(authoritative.config);
721
- } catch { /* keep the error visible while the Hub is unavailable */ }
722
- return false;
723
- }
724
- }, [post, get, copy]);
725
-
726
- /** Request a Crew 3210 restart through the durable supervisor channel
727
- * (hub writes the request; the Windows watcher executes it). Polls until
728
- * VERIFIED or a terminal failure. Same-origin against the 3210 API — the
729
- * legacy 3080 supervisor endpoint is retired. */
730
- const requestRestart = useCallback(async (reason?: string): Promise<any> => {
731
- const created = await post('/runtime/restart-request', { confirm: true, reason: reason ?? 'configuration change' });
732
- if (!created.ok) throw new Error(created.code ?? created.error ?? copy.providerLifecycleError);
733
- const requestId = created.request_id;
734
- const deadline = Date.now() + 90_000;
735
- for (;;) {
736
- await new Promise((resolve) => setTimeout(resolve, 1000));
737
- if (Date.now() > deadline) throw new Error('Crew restart timed out');
738
- const status = await get(`/runtime/restart-status?id=${encodeURIComponent(requestId)}`);
739
- if (!status.ok) continue; // 404 while the watcher has not picked it up
740
- if (status.state === 'VERIFIED') return { ok: true, ...status };
741
- if (status.state !== 'RESTART_REQUESTED') throw new Error(status.state ?? 'Crew restart failed');
742
- }
743
- }, [post, get, copy]);
744
- /** Selects & checkboxes: apply immediately. */
745
- const field = (key: string, value: any) => {
746
- setConfig((c: any) => ({ ...c, [key]: value }));
747
- void applyPatch({ [key]: value });
748
- };
749
- /** Text/number inputs: track locally, apply on blur. */
750
- const fieldLocal = (key: string, value: any) => setConfig((c: any) => ({ ...c, [key]: value }));
751
- const rolePolicyField = (role: 'worker' | 'review', patch: Record<string, any>) => {
752
- const effectivePatch = role === 'worker' && (patch.ordering === 'manual' || patch.ordering === 'health-aware')
753
- ? { ...patch, adaptive: { ...normalizeAdaptive(config?.worker?.model_policy?.adaptive), enabled: patch.ordering === 'health-aware' } }
754
- : patch;
755
- setConfig((current: any) => ({
756
- ...current,
757
- [role]: {
758
- ...current?.[role],
759
- model_policy: { ...current?.[role]?.model_policy, ...effectivePatch },
760
- },
761
- }));
762
- void applyPatch({ [role]: { model_policy: effectivePatch } });
763
- };
764
- const reviewField = (patch: Record<string, any>) => {
765
- setConfig((current: any) => ({ ...current, review: { ...current?.review, ...patch } }));
766
- void applyPatch({ review: patch });
767
- };
768
-
769
- // --- custom providers & user-added models -------------------------------
770
-
771
- const deleteHarnessProvider = async (record: any) => {
772
- if (!record || record.delete_capability !== 'supported' || record.desired_state === 'absent' || Number(record.references?.active_jobs ?? 0) > 0) {
773
- setNotice(copy.providerDeleteBlocked);
774
- return;
775
- }
776
- let replacementDefault: string | undefined;
777
- if (record.references?.harness_default === true) {
778
- const candidate = (providerInventory?.records ?? []).find((item: any) => item.id === 'deepseek-official'
779
- && item.id !== record.id && item.desired_state !== 'absent' && (item.models?.length ?? 0) > 0)?.id
780
- ?? (providerInventory?.records ?? []).find((item: any) => item.id !== record.id
781
- && item.desired_state !== 'absent' && item.delete_capability !== 'source-unresolved' && (item.models?.length ?? 0) > 0)?.id ?? '';
782
- const entered = window.prompt(copy.providerReplacement, candidate);
783
- if (!entered?.trim()) return;
784
- replacementDefault = entered.trim();
785
- }
786
- setProviderLifecycleBusy(record.id);
787
- setNotice(copy.working);
788
- try {
789
- const encoded = encodeURIComponent(record.id);
790
- const planned = await post(`/providers/${encoded}/delete-plan`, replacementDefault ? { replacement_default: replacementDefault } : {});
791
- if (!planned.ok || !planned.plan) throw new Error(planned.code ?? copy.providerLifecycleError);
792
- if (!window.confirm(`${copy.providerPlanReady}\n\n${copy.providerDeleteConfirm}\n${(planned.plan.will_remove ?? []).join('\n')}`)) return;
793
- const path = `/providers/${encoded}`;
794
- const result = await readJson(await fetch(withLang(path), {
795
- method: 'DELETE', headers: { 'content-type': 'application/json' },
796
- body: JSON.stringify({ plan_id: planned.plan.plan_id, expected_revision: planned.plan.expected_revision, confirm: true, lang: locale === 'zh' ? 'zh' : 'en' }),
797
- }), path);
798
- if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
799
- if (result.restart_required === true && result.result?.state === 'RESTART_PENDING') {
800
- await requestRestart('provider delete');
801
- const verified = await post(`/providers/${encoded}/verify-delete`, { transaction_id: planned.plan.plan_id, confirm: true });
802
- if (!verified.ok || verified.state !== 'VERIFIED') throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
803
- }
804
- setNotice(copy.saved);
805
- await refreshProviderInventory();
806
- } catch (error: any) {
807
- try { await refreshProviderInventory(); } catch { /* preserve the original lifecycle error */ }
808
- setNotice(`${String(error?.message ?? error)} · ${copy.providerDeletePending}`);
809
- } finally {
810
- setProviderLifecycleBusy(null);
811
- }
812
- };
813
-
814
- const migrateHarnessProvider = async (entry: any) => {
815
- if (!entry || !['promote-existing-user', 'materialize-user'].includes(entry.action)) {
816
- setNotice(entry?.blocked_reason ?? copy.providerLayerMigrationHint);
817
- return;
818
- }
819
- if (!window.confirm(copy.providerLayerMigrationConfirm)) return;
820
- const id = String(entry.provider_id ?? '');
821
- if (!id) return;
822
- setProviderLifecycleBusy(`migrate:${id}`);
823
- setNotice(copy.providerLayerMigrating);
824
- try {
825
- const encoded = encodeURIComponent(id);
826
- const planned = await post(`/providers/${encoded}/migrate-plan`, {});
827
- if (!planned.ok || !planned.plan) throw new Error(planned.code ?? copy.providerLifecycleError);
828
- if (!window.confirm(`${copy.providerLayerMigrationConfirm}\n\n${id}`)) return;
829
- const result = await post(`/providers/${encoded}/migrate`, { plan_id: planned.plan.plan_id, confirm: true });
830
- if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
831
- if (result.restart_required === true && result.result?.state === 'RESTART_PENDING') {
832
- await requestRestart('provider migrate');
833
- const verified = await post(`/providers/${encoded}/verify-migration`, { transaction_id: planned.plan.plan_id, confirm: true });
834
- if (!verified.ok || verified.state !== 'VERIFIED') throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
835
- }
836
- setNotice(copy.saved);
837
- await refreshProviderInventory();
838
- } catch (error: any) {
839
- try { await refreshProviderInventory(); } catch { /* preserve the original migration error */ }
840
- setNotice(String(error?.message ?? error));
841
- } finally {
842
- setProviderLifecycleBusy(null);
843
- }
844
- };
845
-
846
- const rollbackHarnessMigration = async (record: any, transactionId: string) => {
847
- if (!record?.id || !transactionId || !window.confirm(copy.providerRollbackConfirm)) return;
848
- setProviderLifecycleBusy(`rollback-migrate:${record.id}`);
849
- setNotice(copy.working);
850
- try {
851
- const encoded = encodeURIComponent(record.id);
852
- const result = await post(`/providers/${encoded}/rollback-migration`, { transaction_id: transactionId, confirm: true });
853
- if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
854
- if (result.restart_required === true) {
855
- await requestRestart('provider migrate');
856
- }
857
- setNotice(copy.saved);
858
- await refreshProviderInventory();
859
- } catch (error: any) {
860
- setNotice(String(error?.message ?? error));
861
- try { await refreshProviderInventory(); } catch { /* preserve the original rollback error */ }
862
- } finally { setProviderLifecycleBusy(null); }
863
- };
864
-
865
- const rollbackHarnessProvider = async (record: any, transactionId: string, transactionState: string = 'VERIFIED') => {
866
- const continuing = ['ROLLBACK_PENDING', 'ROLLBACK_RESTART_PENDING'].includes(transactionState);
867
- const recoverable = ['PLANNED', 'TOMBSTONE_APPLYING', 'TOMBSTONE_APPLIED', 'REFERENCES_APPLYING', 'REFERENCES_APPLIED', 'DECLARATIONS_APPLYING', 'DECLARATIONS_APPLIED', 'DELETE_RESTART_PENDING', 'ROLLBACK_APPLYING', 'ROLLBACK_RESTORED'].includes(transactionState);
868
- if (!record || !transactionId || (!continuing && !recoverable && record.desired_state !== 'absent')) {
869
- setNotice(copy.providerRollbackUnavailable);
870
- return;
871
- }
872
- if (!window.confirm(copy.providerRollbackConfirm)) return;
873
- setProviderLifecycleBusy(`rollback:${record.id}`);
874
- setNotice(copy.working);
875
- try {
876
- const encoded = encodeURIComponent(record.id);
877
- const result = continuing
878
- ? { ok: true, restart_required: true, state: 'ROLLBACK_PENDING' }
879
- : await post(`/providers/${encoded}/rollback`, { transaction_id: transactionId, confirm: true });
880
- if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
881
- if (result.restart_required === true && result.state === 'ROLLBACK_PENDING') {
882
- await requestRestart('provider migrate');
883
- const verified = await post(`/providers/${encoded}/verify-rollback`, { transaction_id: transactionId, confirm: true });
884
- if (!verified.ok || verified.state !== 'ROLLED_BACK') throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
885
- } else if (result.state !== 'ROLLED_BACK') {
886
- throw new Error(result.code ?? copy.providerLifecycleError);
887
- }
888
- setNotice(copy.saved);
889
- await refreshProviderInventory();
890
- } catch (error: any) {
891
- try { await refreshProviderInventory(); } catch { /* preserve the original lifecycle error */ }
892
- setNotice(`${String(error?.message ?? error)} · ${copy.providerDeletePending}`);
893
- } finally {
894
- setProviderLifecycleBusy(null);
895
- }
896
- };
897
-
898
- const quarantineRecoveryTransaction = async (entry: any) => {
899
- const actionId = typeof entry?.action_id === 'string' ? entry.action_id : '';
900
- if (!actionId || entry?.recoverable === true) return;
901
- if (!window.confirm(copy.recoveryQuarantineConfirm)) return;
902
- setProviderLifecycleBusy(`quarantine:${actionId}`);
903
- setNotice(copy.working);
904
- try {
905
- const result = await post('/providers/_recovery/quarantine', { action_id: actionId, confirm: true });
906
- if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
907
- setNotice(copy.saved);
908
- await refreshProviderInventory();
909
- } catch (error: any) {
910
- try { await refreshProviderInventory(); } catch { /* preserve the original recovery error */ }
911
- setNotice(String(error?.message ?? error));
912
- } finally {
913
- setProviderLifecycleBusy(null);
914
- }
915
- };
916
-
917
- /** Card: title + one-line description header, then its fields. */
918
- const block = (text: { t: string; d: string }, fields: any, gridded = true) => (
919
- <div style={S.block}>
920
- <div>
921
- <div style={S.settingTitle}>{text.t}</div>
922
- <div style={S.settingDesc}>{text.d}</div>
923
- </div>
924
- {gridded ? <div style={S.blockGrid}>{fields}</div> : fields}
925
- </div>
926
- );
927
-
928
- const integrationRow = (name: string, installed: boolean, ready: boolean, extra: any, installTarget: string, uninstallTarget: string, tips: any) => (
929
- <div style={S.card}>
930
- <span style={{ fontWeight: 600, fontSize: 13, minWidth: 92 }}>{name}</span>
931
- <span style={S.chip(installed)}>{installed ? `● ${copy.installed}` : `○ ${copy.notInstalled}`}</span>
932
- <span style={S.chip(ready)}>{ready ? `● ${copy.integrationReady}` : `○ ${copy.notReady}`}</span>
933
- {extra}
934
- <span style={{ flex: 1 }} />
935
- {!installed && <button style={S.btn} title={tips.install} disabled={busy} onClick={() => { void act(installTarget); }}>{copy.install}</button>}
936
- {installed && <button style={S.btn} title={tips.update} disabled={busy} onClick={() => { void act(installTarget); }}>{copy.update}</button>}
937
- {installed && <button style={{ ...S.btn, opacity: 0.75 }} title={tips.restore} disabled={busy} onClick={() => { void act(uninstallTarget, name); }}>{copy.restore}</button>}
938
- </div>
939
- );
940
-
941
- const adaptive = normalizeAdaptive(config?.worker?.model_policy?.adaptive);
942
- const setAdaptiveLocal = (candidate: AdaptiveConfig) => {
943
- const next = normalizeAdaptive(candidate);
944
- const ordering = next.enabled ? 'health-aware' : 'manual';
945
- setConfig((current: any) => ({
946
- ...current,
947
- worker: {
948
- ...current.worker,
949
- model_policy: { ...current.worker?.model_policy, adaptive: next, ordering },
950
- },
951
- }));
952
- return { next, ordering };
953
- };
954
- const saveAdaptive = (candidate: AdaptiveConfig) => {
955
- const { next, ordering } = setAdaptiveLocal(candidate);
956
- void applyPatch({ worker: { model_policy: { adaptive: next, ordering } } });
957
- };
958
- // ---- per-model peak/off-peak scheduling ----
959
- // The schedule replaces as a whole (windows and model modes only mean anything
960
- // together), so every edit saves the complete object.
961
- const schedule = normalizeModelSchedule(config?.model_schedule);
962
- const saveSchedule = (candidate: any) => {
963
- const next = normalizeModelSchedule(candidate);
964
- setConfig((current: any) => ({ ...current, model_schedule: next }));
965
- void applyPatch({ model_schedule: next });
966
- };
967
- const schedulePeakNow = isPeakAt(schedule);
968
- const scheduleModeOf = (ref: any) => schedule.models.find(
969
- (entry: any) => entry.provider === ref.provider && entry.model === ref.model,
970
- )?.mode ?? 'off';
971
- const setScheduleMode = (ref: any, mode: string) => {
972
- const others = schedule.models.filter(
973
- (entry: any) => !(entry.provider === ref.provider && entry.model === ref.model),
974
- );
975
- saveSchedule({ ...schedule, models: mode === 'off' ? others : [...others, { ...ref, mode }] });
976
- };
977
- // Every model the router can actually select, not just what the flat tier
978
- // mirrors happen to expose. The Pro mirror collapses worker-escalation and
979
- // reviewer priority into one list — it shows whichever is set and hides the
980
- // other — so a reviewer-only model would be routable yet impossible to
981
- // restrict. Harness Default and the catalog are included for the same reason:
982
- // routing can land on a model that is in no priority list at all.
983
- const scheduleModelRefs = (() => {
984
- const seen = new Map<string, any>();
985
- const add = (ref: any) => {
986
- if (ref?.provider && ref?.model) seen.set(`${ref.provider}\0${ref.model}`, { provider: ref.provider, model: ref.model });
987
- };
988
- for (const ref of config?.flash_model_priority ?? []) add(ref);
989
- for (const ref of config?.pro_model_priority ?? []) add(ref);
990
- for (const role of ['worker', 'review'] as const) {
991
- const policy = config?.[role]?.model_policy;
992
- for (const ref of policy?.priority ?? []) add(ref);
993
- for (const ref of policy?.escalation_priority ?? []) add(ref);
994
- }
995
- add(modelCatalog?.harness_default);
996
- for (const provider of modelCatalog?.providers ?? []) {
997
- for (const model of provider?.models ?? []) add({ provider: provider.id, model: model?.id });
998
- }
999
- for (const entry of schedule.models) add(entry);
1000
- return [...seen.values()];
1001
- })();
1002
- const scheduleWeekdayNames = locale === 'zh'
1003
- ? ['日', '一', '二', '三', '四', '五', '六']
1004
- : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1005
- const toggleScheduleWeekday = (day: number) => {
1006
- const days = schedule.weekdays.includes(day)
1007
- ? schedule.weekdays.filter((value: number) => value !== day)
1008
- : [...schedule.weekdays, day].sort((a: number, b: number) => a - b);
1009
- saveSchedule({ ...schedule, weekdays: days });
1010
- };
1011
- const modelActivity = aggregateModelInvocations(jobs);
1012
- const modelState = modelCallabilityState(readinessSnapshot?.model_callability, runtimeInfo);
1013
- const currentSurfaceResponsibilities = surfaceResponsibilities(surface);
1014
- const hostReadiness = projectHostReadiness({ installStatus: status, runtime: runtimeInfo, surface, readinessSnapshot });
1015
-
1016
- if (!currentSurfaceResponsibilities.fullControlPlane) {
1017
- return <MinimalCrewPanel locale={locale} surface={surface} runtime={runtimeInfo} />;
1018
- }
1019
-
1020
- return (
1021
- <div className="dsh-crew-ui" style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13, lineHeight: 1.55 }}>
1022
- <PanelStyles />
1023
- <PanelHeader title={copy.title} eyebrow={copy.surfaceLabel} description={copy.intro} href={CREW_CONTROL_PLANE_URL} linkText={copy.openOfficial}>
1024
- <span style={S.chip(status?.codex?.installed === true)}>{`Codex ${status?.codex?.installed === true ? copy.installed : copy.notInstalled}`}</span>
1025
- <span style={S.chip(status?.claude?.installed === true)}>{`Claude ${status?.claude?.installed === true ? copy.installed : copy.notInstalled}`}</span>
1026
- <span style={S.chip(status?.zcode?.installed === true)}>{`ZCode ${status?.zcode?.installed === true ? copy.installed : copy.notInstalled}`}</span>
1027
- <span style={readinessChip(modelState)}>{modelState === READINESS_STATES.READY ? copy.modelCallable : modelState === READINESS_STATES.UNKNOWN ? copy.modelUnknown : copy.modelNotCallable}</span>
1028
- <span style={S.chip(jobs.some((job) => job.status === 'running'))}>{copy.runningCount(jobs.filter((job) => job.status === 'running').length)}</span>
1029
- </PanelHeader>
1030
- <HistoryPanel locale={locale} />
1031
-
1032
- <div className="crew-config-toolbar">
1033
- <div style={{ ...S.section, margin: 0, flex: 1 }}>{copy.globalConfig}</div>
1034
- <button type="button" style={{ ...S.btn, padding: '3px 9px' }} onClick={() => toggleAllSections(true)}>{copy.expandAll}</button>
1035
- <button type="button" style={{ ...S.btn, padding: '3px 9px' }} onClick={() => toggleAllSections(false)}>{copy.collapseAll}</button>
1036
- </div>
1037
-
1038
- <CollapsibleSection sectionId="integrations" title={copy.sectionNames.integrations}
1039
- summary={sectionSummary(...['Codex', 'Claude', 'ZCode'].map((name) => `${name} ${status?.[name.toLowerCase()]?.installed ? copy.installed : copy.notInstalled}`), modelState === READINESS_STATES.READY ? copy.modelCallable : copy.modelNotCallable)}
1040
- expanded={!!expandedSections.integrations} onToggle={() => toggleSection('integrations')}>
1041
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
1042
- <div style={S.block}>
1043
- <div>
1044
- <div style={S.settingTitle}>{copy.hostReadiness}</div>
1045
- <div style={S.settingDesc}>{copy.hostReadinessHint}</div>
1046
- </div>
1047
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '6px 10px' }}>
1048
- {hostReadiness.map((row) => (
1049
- <div key={row.id} style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, padding: '4px 7px', border: '1px solid rgba(128,128,128,0.16)', borderRadius: 7 }}>
1050
- <span style={{ flex: 1, minWidth: 0, fontSize: 11.5 }}>{(copy as any).readinessLabels[row.id]}</span>
1051
- {row.detail && <span style={{ ...S.mono, opacity: 0.55 }}>{row.detail}</span>}
1052
- <span style={readinessChip(row.state)}>{(copy as any).readinessStates[row.state]}</span>
1053
- </div>
1054
- ))}
1055
- </div>
1056
- </div>
1057
- {integrationRow('Claude Code', !!status?.claude?.installed, !!status?.claude?.ready,
1058
- status?.claude?.installed ? <span style={S.chip(!!status?.claude?.hud)}>{copy.hud}</span> : null,
1059
- 'claude', 'claude-uninstall', (copy as any).tips.claude)}
1060
- {integrationRow('Codex', !!status?.codex?.installed, !!status?.codex?.ready,
1061
- status?.codex?.ready ? null : <span title={(status?.codex?.missing ?? []).join(', ')} style={{ fontSize: 11, opacity: 0.6 }}>{(status?.codex?.missing ?? []).join(' · ')}</span>,
1062
- 'codex', 'codex-uninstall', (copy as any).tips.codex)}
1063
- {integrationRow('ZCode', !!status?.zcode?.installed, !!status?.zcode?.ready,
1064
- status?.zcode?.ready ? null : <span title={(status?.zcode?.missing ?? []).join(', ')} style={{ fontSize: 11, opacity: 0.6 }}>{(status?.zcode?.missing ?? []).join(' · ')}</span>,
1065
- 'zcode', 'zcode-uninstall', (copy as any).tips.zcode)}
1066
- </div>
1067
- </CollapsibleSection>
1068
-
1069
- {config && (<>
1070
- {(() => {
1071
- const clampNum = (v: any) => {
1072
- const n = Number(v);
1073
- if (Number.isNaN(n)) return 3;
1074
- return Math.max(1, Math.min(16, n));
1075
- };
1076
- const mode = config.collaboration_mode ?? 'flash-only';
1077
- const nonCustom = mode !== 'custom';
1078
- const presetStates = { 'flash-only': { flash: 'auto', pro: 'disabled' }, 'pro-only': { flash: 'disabled', pro: 'auto' }, balanced: { flash: 'auto', pro: 'auto' }, 'review-pipeline': { flash: 'auto', pro: 'auto' }, custom: null } as Record<string, { flash: string; pro: string } | null>;
1079
- const states = nonCustom ? presetStates[mode] : null;
1080
- const effState = (tier: string) => states ? states[tier] : (tier === 'flash' ? config.flash_state : config.pro_state) ?? 'auto';
1081
- const roleOptions = ['implementation', 'simple_fix', 'tests', 'search_inspection', 'architecture', 'complex_debugging', 'refactor', 'code_review'] as const;
1082
- const roleKeys = (tier: string) => {
1083
- const arr = tier === 'flash' ? (config.flash_roles ?? []) : (config.pro_roles ?? []);
1084
- const set = new Set(arr);
1085
- return { arr: roleOptions.filter((r) => set.has(r)), set, save: (list: string[]) => field(tier === 'flash' ? 'flash_roles' : 'pro_roles', list) };
1086
- };
1087
- const catalogProviders: any[] = modelCatalog?.providers ?? [];
1088
- const refKey = (ref: any) => `${ref?.provider ?? ''}\0${ref?.model ?? ''}`;
1089
- const priorityFor = (tier: string): any[] => Array.isArray(config[`${tier}_model_priority`]) ? config[`${tier}_model_priority`] : [];
1090
- const savePriority = (tier: string, list: any[]) => {
1091
- const key = `${tier}_model_priority`;
1092
- setConfig((current: any) => ({ ...current, [key]: list, [`${tier}_model_priority_configured`]: true }));
1093
- void applyPatch({ [key]: list });
1094
- };
1095
- const modelMeta = (ref: any) => {
1096
- const provider = catalogProviders.find((item) => item.id === ref.provider);
1097
- const model = provider?.models?.find((item: any) => item.id === ref.model);
1098
- return { provider, model };
1099
- };
1100
- const modelHealth = (ref: any) => providerHealth.find((entry: any) => entry.provider === ref.provider && entry.model === ref.model && entry.fresh === true) ?? null;
1101
- const healthLabel = (health: any) => health?.state ? String(health.state).toUpperCase().replace(/-/g, ' ') : copy.healthUnprobed;
1102
- const healthAge = (health: any) => Number.isFinite(health?.observed_at)
1103
- ? copy.checkedAgo(Math.max(0, Math.round((Date.now() - health.observed_at) / 1000)))
1104
- : '';
1105
- const filteredProviders = catalogProviders.map((provider) => ({
1106
- ...provider,
1107
- models: (provider.models ?? []).filter((model: any) => {
1108
- const query = modelQuery.trim().toLowerCase();
1109
- return !query || [provider.id, provider.name, model.id, model.name].some((value) => String(value ?? '').toLowerCase().includes(query));
1110
- }),
1111
- })).filter((provider) => provider.models.length > 0);
1112
- const modelPriorityPanel = (tier: 'flash' | 'pro') => {
1113
- const priority = priorityFor(tier);
1114
- const selected = new Set(priority.map(refKey));
1115
- const preferredId = tier === 'flash' ? 'deepseek-v4-flash' : 'deepseek-v4-pro';
1116
- const preferredProviders = catalogProviders.filter((provider) => (provider.models ?? []).some((model: any) => model.id === preferredId));
1117
- const ambiguousPreferred = priority.length === 0 && config[`${tier}_model_priority_configured`] !== true
1118
- && preferredProviders.length > 1
1119
- && !preferredProviders.some((provider) => provider.id === modelCatalog?.harness_default?.provider);
1120
- return (
1121
- <div style={{ gridColumn: '1 / -1', borderTop: '1px solid rgba(128,128,128,0.18)', paddingTop: 9 }}>
1122
- <div style={{ ...S.fieldLabel, marginBottom: 5 }}>{copy.modelPriority}</div>
1123
- {priority.length === 0 && <div style={{ fontSize: 12, opacity: 0.55, marginBottom: 6 }}>{copy.noPriority}</div>}
1124
- {ambiguousPreferred && <div style={{ fontSize: 11.5, color: '#c98735', marginBottom: 6 }}>{copy.needsModelSelection}</div>}
1125
- <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
1126
- {priority.map((ref, index) => {
1127
- const meta = modelMeta(ref);
1128
- const health = modelHealth(ref);
1129
- const warning = !meta.provider ? copy.providerUnavailable : !meta.model ? copy.notAdvertised : '';
1130
- return (
1131
- <div key={refKey(ref)} style={{ display: 'flex', gap: 7, alignItems: 'center', padding: '6px 8px', border: '1px solid rgba(128,128,128,0.2)', borderRadius: 6 }}>
1132
- <span style={{ opacity: 0.55, width: 18 }}>{index + 1}.</span>
1133
- <span style={{ flex: 1, minWidth: 0 }}>
1134
- <span style={{ display: 'block', fontSize: 12.5 }}>{meta.model?.name ?? ref.model}</span>
1135
- <span style={{ display: 'block', fontSize: 10.5, opacity: 0.58, fontFamily: 'monospace' }}>{ref.provider} / {ref.model}</span>
1136
- <span style={{ display: 'block', fontSize: 10.5, color: health?.state === 'callable' ? '#3fb950' : health ? '#c98735' : 'inherit', opacity: health ? 0.9 : 0.5 }}>
1137
- {healthLabel(health)}{healthAge(health) ? ` · ${healthAge(health)}` : ''}
1138
- </span>
1139
- {warning && <span style={{ display: 'block', fontSize: 10.5, color: '#c98735' }}>{warning}</span>}
1140
- </span>
1141
- <button type="button" style={{ ...S.btn, padding: '2px 7px' }} title={copy.moveUp} disabled={index === 0}
1142
- onClick={() => { const next = [...priority]; [next[index - 1], next[index]] = [next[index], next[index - 1]]; savePriority(tier, next); }}>↑</button>
1143
- <button type="button" style={{ ...S.btn, padding: '2px 7px' }} title={copy.moveDown} disabled={index === priority.length - 1}
1144
- onClick={() => { const next = [...priority]; [next[index], next[index + 1]] = [next[index + 1], next[index]]; savePriority(tier, next); }}>↓</button>
1145
- <button type="button" style={{ ...S.btn, padding: '2px 7px' }} title={copy.removePriority}
1146
- onClick={() => savePriority(tier, priority.filter((_, itemIndex) => itemIndex !== index))}>×</button>
1147
- </div>
1148
- );
1149
- })}
1150
- </div>
1151
- <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 7 }}>
1152
- <button type="button" style={S.btn} disabled={!modelCatalog || modelCatalogBusy}
1153
- onClick={() => { setModelPickerTier(modelPickerTier === tier ? null : tier); setModelQuery(''); }}>{copy.addPriorityModel}</button>
1154
- <span style={{ fontSize: 11, opacity: 0.58 }}>{copy.harnessDefault}: {modelCatalog?.harness_default ? `${modelCatalog.harness_default.provider} / ${modelCatalog.harness_default.model}` : '—'}</span>
1155
- </div>
1156
- {modelPickerTier === tier && (
1157
- <div style={{ marginTop: 8, border: '1px solid rgba(128,128,128,0.22)', borderRadius: 7, padding: 8, maxHeight: 260, overflow: 'auto' }}>
1158
- <input autoFocus value={modelQuery} placeholder={copy.modelSearch} onChange={(event) => setModelQuery(event.target.value)}
1159
- style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, marginBottom: 7 }} />
1160
- {filteredProviders.map((provider) => (
1161
- <div key={provider.id} style={{ marginBottom: 8 }}>
1162
- <div style={{ fontSize: 11.5, fontWeight: 600, marginBottom: 3 }}>{provider.name} <span style={{ opacity: 0.5, fontFamily: 'monospace' }}>{provider.id}</span></div>
1163
- {(provider.models ?? []).map((model: any) => {
1164
- const ref = { provider: provider.id, model: model.id };
1165
- const duplicate = selected.has(refKey(ref));
1166
- return <button key={model.id} type="button" disabled={duplicate} style={{ ...S.btn, display: 'block', width: '100%', textAlign: 'left', marginBottom: 3, opacity: duplicate ? 0.45 : 1 }}
1167
- onClick={() => { savePriority(tier, [...priority, ref]); setModelPickerTier(null); }}>{model.name} <span style={{ opacity: 0.55, fontFamily: 'monospace' }}>{model.id}</span></button>;
1168
- })}
1169
- </div>
1170
- ))}
1171
- </div>
1172
- )}
1173
- </div>
1174
- );
1175
- };
1176
- const tierCard = (tier: string, card: { t: string; d: string }) => (
1177
- <div style={S.block}>
1178
- <div>
1179
- <div style={S.settingTitle}>{card.t}</div>
1180
- <div style={S.settingDesc}>{card.d}</div>
1181
- </div>
1182
- <div style={S.blockGrid}>
1183
- <label style={S.field}><span style={S.fieldLabel}>{copy.tierState}</span>
1184
- {nonCustom ? (
1185
- <div style={{ fontSize: 12.5, padding: '3px 8px', borderRadius: 5, border: '1px solid rgba(128,128,128,0.35)', opacity: 0.8 }}>
1186
- {effState(tier) === 'auto' ? copy.tierStateDesc.auto : copy.tierStateDesc.disabled}({copy.stateManagedByPreset})
1187
- </div>
1188
- ) : (
1189
- <CustomSelect value={tier === 'flash' ? (config.flash_state ?? 'auto') : (config.pro_state ?? 'auto')}
1190
- onChange={(v) => field(tier === 'flash' ? 'flash_state' : 'pro_state', v)}
1191
- options={(['disabled', 'manual', 'auto'] as const).map((s) => ({ value: s, label: copy.tierStateDesc[s] }))} />
1192
- )}
1193
- </label>
1194
- <label style={S.field}><span style={S.fieldLabel}>{tier === 'flash' ? copy.presetFlash : copy.presetPro}</span>
1195
- <CustomSelect value={tier === 'flash' ? (config.preset_flash ?? 'default') : (config.preset_pro ?? 'default')}
1196
- onChange={(v) => field(tier === 'flash' ? 'preset_flash' : 'preset_pro', v)} options={presetOptions} /></label>
1197
- </div>
1198
- <div style={{ fontSize: 11, opacity: 0.6, marginTop: 2 }}>
1199
- {copy.statePresetHint}{' '}{copy.responsibilitiesHint}
1200
- </div>
1201
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '3px 14px' }}>
1202
- {roleOptions.map((r) => {
1203
- const k = roleKeys(tier);
1204
- const on = k.set.has(r);
1205
- return (
1206
- <label key={r} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
1207
- <input type="checkbox" checked={on} onChange={() => {
1208
- k.save(on ? k.arr.filter((x) => x !== r) : [...k.arr, r]);
1209
- }} />
1210
- <span>{copy.roleLabels[r]}</span>
1211
- </label>
1212
- );
1213
- })}
1214
- </div>
1215
- {modelPriorityPanel(tier as 'flash' | 'pro')}
1216
- </div>
1217
- );
1218
- return (<>
1219
- <CollapsibleSection sectionId="workflow" title={copy.sectionNames.workflow}
1220
- summary={sectionSummary(config.worker_state ?? 'auto', config.review_state ?? 'auto', mode, config.isolation ?? 'worktree', `×${config.max_parallel ?? 3}`)}
1221
- expanded={!!expandedSections.workflow} onToggle={() => toggleSection('workflow')}>
1222
- {block({ t: copy.roleWorker, d: copy.roleStateHint }, (<>
1223
- <label style={S.field}><span style={S.fieldLabel}>{copy.workerStateLabel}</span>
1224
- <CustomSelect value={config.worker_state ?? 'auto'} onChange={(v) => field('worker_state', v)}
1225
- options={(['auto', 'manual', 'disabled'] as const).map((s) => ({ value: s, label: copy.tierStateDesc[s] }))} /></label>
1226
- <label style={S.field}><span style={S.fieldLabel}>{copy.reviewStateLabel}</span>
1227
- <CustomSelect value={config.review_state ?? 'auto'} onChange={(v) => field('review_state', v)}
1228
- options={(['auto', 'manual', 'disabled'] as const).map((s) => ({ value: s, label: copy.tierStateDesc[s] }))} /></label>
1229
- </>))}
1230
- {block({ t: copy.autoReview, d: copy.autoReviewHint }, (
1231
- <>
1232
- <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6 }}>
1233
- <input type="checkbox" checked={config.auto_review ?? !!config.pro_reviews_flash} onChange={(e) => field('auto_review', e.target.checked)} />
1234
- <span style={{ fontSize: 12.5 }}>{copy.autoReview}</span>
1235
- </label>
1236
- <label style={S.field}><span style={S.fieldLabel}>{copy.reviewGate}</span>
1237
- <CustomSelect value={config.review?.gate ?? 'required'} onChange={(v) => reviewField({ gate: v })}
1238
- options={(['required', 'optional', 'off'] as const).map((value) => ({ value, label: copy.reviewGateDesc[value] }))} /></label>
1239
- </>
1240
- ))}
1241
- {block({ t: copy.modelOrdering, d: copy.orderingHint }, (<>
1242
- {(['worker', 'review'] as const).map((role) => (
1243
- <div key={role} style={{ display: 'contents' }}>
1244
- <label style={S.field}><span style={S.fieldLabel}>{role === 'worker' ? copy.roleWorker : copy.roleReviewer} · {copy.modelOrdering}</span>
1245
- <CustomSelect value={config?.[role]?.model_policy?.ordering ?? 'manual'}
1246
- onChange={(v) => rolePolicyField(role, { ordering: v })}
1247
- options={(['manual', 'health-aware'] as const).map((value) => ({ value, label: copy.orderingDesc[value] }))} /></label>
1248
- <label style={S.field}><span style={S.fieldLabel}>{role === 'worker' ? copy.roleWorker : copy.roleReviewer} · {copy.healthGate}</span>
1249
- <CustomSelect value={config?.[role]?.model_policy?.health_gate ?? 'hard-failures'}
1250
- onChange={(v) => rolePolicyField(role, { health_gate: v })}
1251
- options={(['hard-failures', 'off'] as const).map((value) => ({ value, label: copy.healthGateDesc[value] }))} /></label>
1252
- </div>
1253
- ))}
1254
- </>))}
1255
- {block({ t: copy.isolation, d: copy.isolationHint }, (<>
1256
- <label style={S.field}><span style={S.fieldLabel}>{copy.isolation}</span>
1257
- <CustomSelect value={config.isolation ?? 'worktree'} onChange={(v) => field('isolation', v)}
1258
- options={(['worktree', 'shared'] as const).map((s) => ({ value: s, label: copy.isolationDesc[s] }))} /></label>
1259
- <label style={S.field}><span style={S.fieldLabel}>{copy.maxParallel}</span>
1260
- <input type="number" min={1} max={16} value={config.max_parallel ?? 3}
1261
- onChange={(e) => fieldLocal('max_parallel', clampNum(e.target.value))}
1262
- onBlur={() => field('max_parallel', clampNum(config.max_parallel))} style={S.input} /></label>
1263
- </>))}
1264
- <div style={{ fontSize: 11, opacity: 0.6, marginTop: 3 }}>{copy.legacyCompatibility}</div>
1265
- {block({ t: copy.orchestration, d: copy.enableSubagentsHint }, (
1266
- <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6 }} title={copy.enableSubagentsHint}>
1267
- <input type="checkbox" checked={!!config.subagents_enabled} onChange={(e) => field('subagents_enabled', e.target.checked)} />
1268
- <span style={{ fontSize: 12.5 }}>{config.subagents_enabled === false ? copy.subagentsKept : copy.enableSubagents}</span>
1269
- </label>
1270
- ), false)}
1271
- {block({ t: copy.mainAgentMode, d: copy.mainAgentModeHint }, (<>
1272
- <label style={S.field}><span style={S.fieldLabel}>{copy.mainAgentMode}</span>
1273
- <CustomSelect value={config.main_agent_mode ?? 'direct-allowed'} onChange={(v) => field('main_agent_mode', v)}
1274
- options={(['direct-allowed', 'coordinator-first', 'dispatcher-only'] as const).map((m) => ({ value: m, label: copy.mainAgentModeDesc[m] }))} /></label>
1275
- <label style={S.field}><span style={S.fieldLabel}>{copy.collaborationMode}</span>
1276
- <CustomSelect value={mode} onChange={(v) => field('collaboration_mode', v)}
1277
- options={(['flash-only', 'pro-only', 'balanced', 'review-pipeline', 'custom'] as const).map((m) => ({ value: m, label: copy.collaborationModeDesc[m] }))} /></label>
1278
- <label style={S.field} title={copy.workerProviderHint}><span style={S.fieldLabel}>{copy.workerProvider}</span>
1279
- <CustomSelect value={config.worker_provider_mode ?? 'deepseek-official'} onChange={(v) => field('worker_provider_mode', v)}
1280
- options={(['follow-dsh', 'deepseek-official'] as const).map((m) => ({ value: m, label: copy.workerProviderDesc[m] }))} /></label>
1281
- </>))}
1282
- {block({ t: copy.workerModels, d: modelCatalog ? copy.modelPoolSummary(modelCatalog.provider_count ?? 0, modelCatalog.model_count ?? 0) : copy.catalogFailed }, (<>
1283
- <button type="button" style={S.btn} disabled={modelCatalogBusy} onClick={() => { void refreshHarnessModels(); }}>
1284
- {modelCatalogBusy ? copy.refreshingModels : copy.refreshHarnessModels}
1285
- </button>
1286
- {modelCatalog?.partial && <span style={{ fontSize: 11.5, color: '#c98735' }}>{copy.partialCatalog}</span>}
1287
- {modelCatalogError && <span style={{ fontSize: 11.5, color: '#c55' }}>{modelCatalogError}</span>}
1288
- </>), false)}
1289
- {block({ t: copy.routing, d: '' }, (<>
1290
- <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, paddingBottom: 5 }} title={copy.escalateHint}>
1291
- <input type="checkbox" checked={!!config.escalate_on_failure} onChange={(e) => field('escalate_on_failure', e.target.checked)} />
1292
- <span style={{ fontSize: 12 }}>{copy.escalate}</span></label>
1293
- <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, paddingBottom: 5 }} title={mode === 'review-pipeline' ? copy.proReviewsLocked : copy.proReviewsFlashHint}>
1294
- <input type="checkbox" disabled={mode === 'review-pipeline'} checked={mode === 'review-pipeline' ? true : !!config.pro_reviews_flash}
1295
- onChange={(e) => field('pro_reviews_flash', e.target.checked)} />
1296
- <span style={{ fontSize: 12 }}>{copy.proReviewsFlash}{mode === 'review-pipeline' ? `(${copy.proReviewsLocked})` : ''}</span></label>
1297
- </>))}
1298
- </CollapsibleSection>
1299
- <CollapsibleSection sectionId="flash" title={copy.sectionNames.flash}
1300
- summary={sectionSummary(effState('flash'), copy.modelCount(priorityFor('flash').length), priorityFor('flash')[0]?.model ?? copy.harnessDefault)}
1301
- expanded={!!expandedSections.flash} onToggle={() => toggleSection('flash')}>
1302
- {tierCard('flash', { t: copy.tierCardFlash, d: 'implementation · direct validation · Delivery Report' })}
1303
- </CollapsibleSection>
1304
- <CollapsibleSection sectionId="pro" title={copy.sectionNames.pro}
1305
- summary={sectionSummary(effState('pro'), copy.modelCount(priorityFor('pro').length), priorityFor('pro')[0]?.model ?? copy.harnessDefault)}
1306
- expanded={!!expandedSections.pro} onToggle={() => toggleSection('pro')}>
1307
- {tierCard('pro', { t: copy.tierCardPro, d: 'manual advanced work · review pipeline' })}
1308
- </CollapsibleSection>
1309
- </>);
1310
- })()}
1311
-
1312
- <CollapsibleSection sectionId="dispatch" title={copy.sectionNames.dispatch}
1313
- summary={sectionSummary(config.default_tier, config.default_effort, config.tier_policy, config.escalate_on_failure && copy.escalate)}
1314
- expanded={!!expandedSections.dispatch} onToggle={() => toggleSection('dispatch')}>
1315
- {block(copy.cardDispatch, (<>
1316
- <label style={S.field}><span style={S.fieldLabel}>{copy.tier}</span>
1317
- <CustomSelect value={config.default_tier} onChange={(v) => field('default_tier', v)}
1318
- options={[{ value: 'flash' }, { value: 'pro' }]} /></label>
1319
- <label style={S.field}><span style={S.fieldLabel}>{copy.effort}</span>
1320
- <CustomSelect value={config.default_effort} onChange={(v) => field('default_effort', v)}
1321
- options={[{ value: 'off' }, { value: 'high' }, { value: 'max' }]} /></label>
1322
- <label style={S.field}><span style={S.fieldLabel}>{copy.tierPolicy}</span>
1323
- <CustomSelect value={config.tier_policy} onChange={(v) => field('tier_policy', v)}
1324
- options={(['auto', 'flash-only', 'pro-only'] as const).map((p) => ({ value: p, label: copy.tierPolicyDesc[p] }))} /></label>
1325
- <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, paddingBottom: 5 }} title={copy.escalateHint}>
1326
- <input type="checkbox" checked={!!config.escalate_on_failure} onChange={(e) => field('escalate_on_failure', e.target.checked)} />
1327
- <span style={{ fontSize: 12 }}>{copy.escalate}</span></label>
1328
- <label style={S.field}><span style={S.fieldLabel}>{copy.presetFlash}</span>
1329
- <CustomSelect value={config.preset_flash ?? 'default'} onChange={(v) => field('preset_flash', v)} options={presetOptions} /></label>
1330
- <label style={S.field}><span style={S.fieldLabel}>{copy.presetPro}</span>
1331
- <CustomSelect value={config.preset_pro ?? 'default'} onChange={(v) => field('preset_pro', v)} options={presetOptions} /></label>
1332
- </>))}
1333
- </CollapsibleSection>
1334
-
1335
- <CollapsibleSection sectionId="adaptive" title={copy.sectionNames.adaptive}
1336
- summary={sectionSummary(adaptive.enabled ? copy.adaptiveEnabled : 'off', `${adaptive.min_samples}/${adaptive.window_size}`, copy.adaptiveBoundary)}
1337
- expanded={!!expandedSections.adaptive} onToggle={() => toggleSection('adaptive')}>
1338
- {block({ t: copy.adaptiveTitle, d: copy.adaptiveHint }, (<>
1339
- <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 7, gridColumn: '1 / -1' }}>
1340
- <input type="checkbox" checked={adaptive.enabled}
1341
- onChange={(event) => saveAdaptive({ ...adaptive, enabled: event.target.checked })} />
1342
- <span style={{ fontSize: 12.5 }}>{copy.adaptiveEnabled}</span>
1343
- <span style={{ fontSize: 11, opacity: 0.55 }}>· {copy.adaptiveBoundary}</span>
1344
- </label>
1345
- <label style={S.field}><span style={S.fieldLabel}>{copy.adaptiveWindow}</span>
1346
- <input type="number" min={1} max={32} value={adaptive.window_size} style={S.input}
1347
- onChange={(event) => setAdaptiveLocal({ ...adaptive, window_size: clampInt(event.target.value, adaptive.window_size, 1, 32) })}
1348
- onBlur={() => saveAdaptive(adaptive)} /></label>
1349
- <label style={S.field}><span style={S.fieldLabel}>{copy.adaptiveSamples}</span>
1350
- <input type="number" min={1} max={adaptive.window_size} value={adaptive.min_samples} style={S.input}
1351
- onChange={(event) => setAdaptiveLocal({ ...adaptive, min_samples: clampInt(event.target.value, adaptive.min_samples, 1, adaptive.window_size) })}
1352
- onBlur={() => saveAdaptive(adaptive)} /></label>
1353
- </>))}
1354
- </CollapsibleSection>
1355
-
1356
- <CollapsibleSection sectionId="schedule" title={copy.sectionNames.schedule}
1357
- summary={sectionSummary(schedulePeakNow ? copy.schedulePeak : copy.scheduleOffPeak,
1358
- `UTC${schedule.timezone_offset_minutes >= 0 ? '+' : '-'}${Math.abs(schedule.timezone_offset_minutes) / 60}`,
1359
- config?.model_schedule === undefined ? copy.scheduleOff : `${schedule.models.length} · ${copy.scheduleRestricted}`)}
1360
- expanded={!!expandedSections.schedule} onToggle={() => toggleSection('schedule')}>
1361
- {block({ t: copy.scheduleTitle, d: copy.scheduleHint }, (<>
1362
- <label style={S.field}><span style={S.fieldLabel}>{copy.scheduleTimezone}</span>
1363
- <CustomSelect value={String(schedule.timezone_offset_minutes)}
1364
- onChange={(v) => saveSchedule({ ...schedule, timezone_offset_minutes: Number(v) })}
1365
- options={(() => {
1366
- // Real-world fixed offsets, not just whole hours: a stored
1367
- // minute-level offset with no matching option would render as
1368
- // a different value than it holds.
1369
- const offsets = new Set<number>();
1370
- for (let h = -14; h <= 14; h++) offsets.add(h * 60);
1371
- for (const minutes of [330, 345, 570, -210, -570]) offsets.add(minutes);
1372
- offsets.add(schedule.timezone_offset_minutes);
1373
- return [...offsets].sort((a, b) => a - b).map((minutes) => ({
1374
- value: String(minutes),
1375
- label: `UTC${minutes >= 0 ? '+' : '-'}${Math.floor(Math.abs(minutes) / 60)}`
1376
- + (Math.abs(minutes) % 60 ? `:${String(Math.abs(minutes) % 60).padStart(2, '0')}` : ''),
1377
- }));
1378
- })()} /></label>
1379
- <div style={{ gridColumn: '1 / -1' }}>
1380
- <span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleWeekdays}</span>
1381
- <div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
1382
- {scheduleWeekdayNames.map((name: string, day: number) => (
1383
- <button key={day} type="button"
1384
- style={{ ...S.btn, padding: '3px 9px', opacity: schedule.weekdays.includes(day) ? 1 : 0.45 }}
1385
- onClick={() => toggleScheduleWeekday(day)}>{name}</button>
1386
- ))}
1387
- </div>
1388
- </div>
1389
- <div style={{ gridColumn: '1 / -1' }}>
1390
- <span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleWindows}</span>
1391
- <div style={{ display: 'flex', flexDirection: 'column' as const, gap: 6 }}>
1392
- {schedule.peak_windows.map((window: any, index: number) => (
1393
- <div key={`${window.start}-${window.end}-${index}`} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
1394
- <input type="time" value={window.start} style={S.input}
1395
- onChange={(event) => {
1396
- const next = schedule.peak_windows.map((row: any, i: number) => i === index ? { ...row, start: event.target.value } : row);
1397
- saveSchedule({ ...schedule, peak_windows: next });
1398
- }} />
1399
- <span style={{ opacity: 0.6 }}>–</span>
1400
- <input type="time" value={window.end} style={S.input}
1401
- onChange={(event) => {
1402
- const next = schedule.peak_windows.map((row: any, i: number) => i === index ? { ...row, end: event.target.value } : row);
1403
- saveSchedule({ ...schedule, peak_windows: next });
1404
- }} />
1405
- <button type="button" style={{ ...S.btn, padding: '2px 8px' }}
1406
- onClick={() => saveSchedule({ ...schedule, peak_windows: schedule.peak_windows.filter((_: any, i: number) => i !== index) })}>×</button>
1407
- </div>
1408
- ))}
1409
- <button type="button" style={{ ...S.btn, alignSelf: 'flex-start' }}
1410
- onClick={() => saveSchedule({ ...schedule, peak_windows: [...schedule.peak_windows, { start: '09:00', end: '12:00' }] })}>
1411
- {copy.scheduleAddWindow}</button>
1412
- </div>
1413
- </div>
1414
- <div style={{ gridColumn: '1 / -1' }}>
1415
- <span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleRestricted}</span>
1416
- {scheduleModelRefs.length === 0
1417
- ? <span style={{ fontSize: 11.5, opacity: 0.6 }}>{copy.scheduleNoRestricted}</span>
1418
- : <div style={{ display: 'flex', flexDirection: 'column' as const, gap: 5 }}>
1419
- {scheduleModelRefs.map((ref: any) => (
1420
- <div key={`${ref.provider}\0${ref.model}`} style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '5px 8px', border: '1px solid rgba(128,128,128,0.2)', borderRadius: 6 }}>
1421
- <span style={{ flex: 1, minWidth: 0, fontSize: 11, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ref.provider} / {ref.model}</span>
1422
- <CustomSelect value={scheduleModeOf(ref)}
1423
- onChange={(v) => setScheduleMode(ref, v)}
1424
- options={[
1425
- { value: 'off', label: copy.scheduleOff },
1426
- { value: 'warn', label: copy.scheduleWarn },
1427
- { value: 'block', label: copy.scheduleBlock },
1428
- ]} />
1429
- </div>
1430
- ))}
1431
- </div>}
1432
- <span style={{ display: 'block', fontSize: 11, opacity: 0.55, marginTop: 5 }}>{copy.scheduleBoundary}</span>
1433
- </div>
1434
- </>))}
1435
- </CollapsibleSection>
1436
-
1437
- <CollapsibleSection sectionId="runtime" title={copy.sectionNames.runtime}
1438
- summary={sectionSummary(config.mode, `${config.default_timeout_seconds}s`, config.hub_url)}
1439
- expanded={!!expandedSections.runtime} onToggle={() => toggleSection('runtime')}>
1440
- {block(copy.cardRuntime, (<>
1441
- <label style={S.field}><span style={S.fieldLabel}>{copy.mode}</span>
1442
- <CustomSelect value={config.mode} onChange={(v) => field('mode', v)}
1443
- options={(['auto', 'hub'] as const).map((m) => ({ value: m, label: copy.modeDesc[m] }))} /></label>
1444
- <label style={S.field}><span style={S.fieldLabel}>{copy.timeout}</span>
1445
- <input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const }} type="number" min={60} max={7200} value={config.default_timeout_seconds}
1446
- onChange={(e) => fieldLocal('default_timeout_seconds', Number(e.target.value))}
1447
- onBlur={() => { void applyPatch({ default_timeout_seconds: config.default_timeout_seconds }); }} /></label>
1448
- <label style={S.field}><span style={S.fieldLabel}>{copy.hubUrl}</span>
1449
- <input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const }} value={config.hub_url}
1450
- onChange={(e) => fieldLocal('hub_url', e.target.value)}
1451
- onBlur={() => { void applyPatch({ hub_url: config.hub_url }); }}
1452
- onKeyDown={(e: any) => { if (e.key === 'Enter') e.currentTarget.blur(); }} /></label>
1453
- </>))}
1454
- <ActivationSummary activation={config.config_activation} locale={locale} />
1455
- </CollapsibleSection>
1456
-
1457
- <CollapsibleSection sectionId="harnessProviders" title={copy.sectionNames.harnessProviders}
1458
- summary={sectionSummary(copy.providerCount(providerInventory?.records?.length ?? 0), providerInventoryError ? copy.providerLifecycleError : '')}
1459
- expanded={!!expandedSections.harnessProviders} onToggle={() => toggleSection('harnessProviders')}>
1460
- {providerInventoryError && <div style={{ fontSize: 12, color: '#c55' }}>{providerInventoryError}</div>}
1461
- {!providerInventoryError && (providerInventory?.migration?.providers?.length ?? 0) > 0 && (
1462
- <div style={{ border: '1px solid rgba(201,135,53,0.45)', borderRadius: 7, padding: '8px 10px', marginBottom: 8 }}>
1463
- <div style={{ fontWeight: 650, fontSize: 12.5 }}>{copy.providerLayerMigration}</div>
1464
- <div style={{ fontSize: 11.5, opacity: 0.7, marginTop: 3 }}>{copy.providerLayerMigrationHint}</div>
1465
- <div style={{ fontSize: 11, marginTop: 6, fontFamily: 'monospace' }}>
1466
- {(providerInventory.migration.providers ?? []).map((entry: any) => (
1467
- <div key={entry.provider_id} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3 }}>
1468
- {entry.provider_id} · {entry.action}{entry.harness_default ? ' · Harness Default' : ''}
1469
- <span style={{ flex: 1 }} />
1470
- {['promote-existing-user', 'materialize-user'].includes(entry.action) && <button type="button" style={{ ...S.btn, padding: '2px 7px' }} disabled={providerLifecycleBusy !== null}
1471
- onClick={() => { void migrateHarnessProvider(entry); }}>
1472
- {providerLifecycleBusy === `migrate:${entry.provider_id}` ? copy.providerLayerMigrating : copy.providerLayerMigrate}
1473
- </button>}
1474
- </div>
1475
- ))}
1476
- </div>
1477
- <a href="http://127.0.0.1:3210/" target="_blank" rel="noopener noreferrer" style={{ ...S.btn, display: 'inline-block', textDecoration: 'none', marginTop: 7, padding: '3px 8px' }}>
1478
- {copy.providerLayerMigrationPlan} → 3210
1479
- </a>
1480
- </div>
1481
- )}
1482
- {!providerInventoryError && (providerInventory?.records?.length ?? 0) === 0 && (
1483
- <div style={{ fontSize: 12, opacity: 0.55 }}>{copy.providerNoInventory}</div>
1484
- )}
1485
- {(providerInventory?.records ?? []).map((record: any) => {
1486
- const immutable = record.delete_capability === 'immutable-builtin';
1487
- const unresolved = record.delete_capability === 'source-unresolved';
1488
- const blocked = immutable || unresolved || record.desired_state === 'absent' || Number(record.references?.active_jobs ?? 0) > 0;
1489
- const lifecycle = record.desired_state === 'absent' ? 'absent' : record.lifecycle?.enabled === false ? 'disabled' : record.lifecycle?.catalogued ? 'catalogued' : 'configured';
1490
- const lifecycleTransactions = providerInventory?.lifecycle_transactions ?? [];
1491
- const recoveryTransactions = (providerInventory?.recovery_transactions ?? []).map((entry: any) => ({ ...entry, state: entry.phase }));
1492
- const rollbackTransaction = [...lifecycleTransactions, ...recoveryTransactions]
1493
- .filter((entry: any) => entry.provider_id === record.id && ['VERIFIED', 'RESTART_PENDING', 'ROLLBACK_PENDING', 'PLANNED', 'TOMBSTONE_APPLYING', 'TOMBSTONE_APPLIED', 'REFERENCES_APPLYING', 'REFERENCES_APPLIED', 'DECLARATIONS_APPLYING', 'DECLARATIONS_APPLIED', 'DELETE_RESTART_PENDING', 'ROLLBACK_RESTART_PENDING', 'ROLLBACK_RESTORED', 'ROLLBACK_APPLYING', 'PREPARED', 'USER_MATERIALIZED', 'BASE_REMOVED'].includes(entry.state))
1494
- .sort((a: any, b: any) => String(a.updated_at ?? '').localeCompare(String(b.updated_at ?? ''))).at(-1);
1495
- return (
1496
- <div key={record.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, flexWrap: 'wrap' as const }}>
1497
- <span style={{ fontWeight: 600 }}>{record.display_name || record.id}</span>
1498
- <span style={{ ...S.chip(record.lifecycle?.enabled === true), ...S.mono }}>{record.id}</span>
1499
- <span style={{ opacity: 0.65 }}>{copy.providerState(lifecycle)}</span>
1500
- <span style={{ opacity: 0.65 }}>{copy.providerModels(record.models?.length ?? 0)}</span>
1501
- {immutable && <span style={{ opacity: 0.65 }}>{copy.providerOfficialBuiltin}</span>}
1502
- {unresolved && <span style={{ color: '#c98735' }}>{copy.providerSourceUnresolved}</span>}
1503
- {Number(record.references?.active_jobs ?? 0) > 0 && <span style={{ color: '#c98735' }}>{copy.providerJobs(record.references.active_jobs)}</span>}
1504
- <span style={{ flex: 1 }} />
1505
- {record.delete_capability === 'supported' && record.desired_state !== 'absent' && (
1506
- <button type="button" style={{ ...S.btn, padding: '2px 8px' }} disabled={blocked || providerLifecycleBusy !== null}
1507
- onClick={() => { void deleteHarnessProvider(record); }}>
1508
- {providerLifecycleBusy === record.id ? copy.working : copy.providerDeletePlan}
1509
- </button>
1510
- )}
1511
- {(record.desired_state === 'absent' || ['PLANNED', 'TOMBSTONE_APPLYING', 'TOMBSTONE_APPLIED', 'REFERENCES_APPLYING', 'REFERENCES_APPLIED', 'DECLARATIONS_APPLYING', 'DECLARATIONS_APPLIED', 'DELETE_RESTART_PENDING', 'ROLLBACK_PENDING', 'ROLLBACK_RESTART_PENDING', 'ROLLBACK_RESTORED', 'ROLLBACK_APPLYING'].includes(rollbackTransaction?.state)) && rollbackTransaction && rollbackTransaction.source !== 'provider-layer-migration' && (
1512
- <button type="button" style={{ ...S.btn, padding: '2px 8px' }} disabled={providerLifecycleBusy !== null}
1513
- onClick={() => { void rollbackHarnessProvider(record, rollbackTransaction.transaction_id, rollbackTransaction.state); }}>
1514
- {providerLifecycleBusy === `rollback:${record.id}` ? copy.working : rollbackTransaction.state === 'ROLLBACK_PENDING' ? copy.providerRollbackContinue : copy.providerRollback}
1515
- </button>
1516
- )}
1517
- {rollbackTransaction?.source === 'provider-layer-migration' && rollbackTransaction.transaction_id && (
1518
- <button type="button" style={{ ...S.btn, padding: '2px 8px' }} disabled={providerLifecycleBusy !== null}
1519
- onClick={() => { void rollbackHarnessMigration(record, rollbackTransaction.transaction_id); }}>
1520
- {providerLifecycleBusy === `rollback-migrate:${record.id}` ? copy.working : copy.providerLayerRollback}
1521
- </button>
1522
- )}
1523
- </div>
1524
- );
1525
- })}
1526
- {(providerInventory?.recovery_transactions ?? []).filter((entry: any) => entry?.unresolved === true || !entry?.provider_id).map((entry: any) => (
1527
- <div key={`recovery:${entry.storage_id ?? entry.transaction_id ?? 'unknown'}`} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, flexWrap: 'wrap' as const, color: '#c98735', marginTop: 6 }}>
1528
- <span style={{ fontWeight: 600 }}>{copy.recoveryUnresolved}</span>
1529
- <span style={S.mono}>{entry.storage_id ?? entry.transaction_id ?? 'unknown'}</span>
1530
- <span style={{ opacity: 0.7 }}>{entry.phase}</span>
1531
- <span style={{ flex: 1 }} />
1532
- {entry.action_id && <button type="button" style={{ ...S.btn, padding: '2px 8px' }} disabled={providerLifecycleBusy !== null}
1533
- onClick={() => { void quarantineRecoveryTransaction(entry); }}>
1534
- {providerLifecycleBusy === `quarantine:${entry.action_id}` ? copy.working : copy.recoveryQuarantine}
1535
- </button>}
1536
- </div>
1537
- ))}
1538
- {(credentialRefs.length > 0 || credentialUnverified.length > 0) && (
1539
- <div style={{ marginTop: 10, borderTop: '1px solid rgba(128,128,128,0.16)', paddingTop: 8 }}>
1540
- <div style={{ fontWeight: 600, fontSize: 12.5 }}>{copy.credentialReferences}</div>
1541
- <div style={{ fontSize: 11, opacity: 0.6, margin: '3px 0 7px' }}>{copy.credentialReferenceHint}</div>
1542
- {credentialUnverified.map((entry: any) => (
1543
- <div key={`unverified:${entry.reference_id}`} style={{ color: '#c55', fontSize: 11.5 }}>
1544
- <span style={S.mono}>{entry.reference_id}</span> · {copy.credentialPurgeUnverified}
1545
- </div>
1546
- ))}
1547
- {credentialRefs.map((ref: any) => (
1548
- <div key={ref.reference_id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11.5, flexWrap: 'wrap' as const }}>
1549
- <span style={S.mono}>{ref.reference_id}</span>
1550
- <span style={{ opacity: 0.65 }}>{ref.ownership}</span>
1551
- <span style={{ color: ref.orphan ? '#c98735' : undefined }}>{ref.orphan ? copy.credentialOrphan : copy.credentialInUse(ref.consumer_count ?? 0)}</span>
1552
- <span style={{ flex: 1 }} />
1553
- {ref.ownership === 'crew-owned' && ref.orphan === true && ref.purge_capability === 'eligible' && (
1554
- <button type="button" style={{ ...S.btn, padding: '2px 8px' }}
1555
- disabled={credentialLifecycleBusy !== null}
1556
- onClick={() => { void purgeCredentialRef(ref); }}>
1557
- {credentialLifecycleBusy === ref.reference_id ? copy.credentialPurging : copy.credentialPurgePlan}
1558
- </button>
1559
- )}
1560
- </div>
1561
- ))}
1562
- </div>
1563
- )}
1564
- </CollapsibleSection>
1565
-
1566
- </>)}
1567
- <div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 2 }}>{copy.globalHint}</div>
1568
-
1569
- {notice !== '' && <div style={{ fontSize: 12, opacity: 0.8, whiteSpace: 'pre-wrap' }}>{notice}</div>}
1570
-
1571
- <CollapsibleSection sectionId="jobs" title={copy.sectionNames.jobs}
1572
- summary={sectionSummary(copy.jobCount(jobs.length), jobs.some((job) => job.status === 'running') && copy.runningCount(jobs.filter((job) => job.status === 'running').length))}
1573
- expanded={!!expandedSections.jobs} onToggle={() => toggleSection('jobs')}>
1574
- {jobs.length === 0 ? (
1575
- <div style={{ opacity: 0.55 }}>{copy.empty}</div>
1576
- ) : (
1577
- <div style={{ overflowX: 'auto', border: '1px solid rgba(128,128,128,0.20)', borderRadius: 8, padding: '0 9px' }}>
1578
- <table style={{ borderCollapse: 'collapse', width: '100%', tableLayout: 'fixed', minWidth: 1020 }}>
1579
- <colgroup>
1580
- <col style={{ width: 68 }} /><col style={{ width: 72 }} /><col style={{ width: 58 }} />
1581
- <col style={{ width: 190 }} /><col style={{ width: 70 }} /><col style={{ width: 70 }} />
1582
- <col style={{ width: 105 }} /><col style={{ width: 84 }} /><col style={{ width: 300 }} />
1583
- </colgroup>
1584
- <thead><tr>
1585
- {[copy.col.id, copy.col.role, copy.col.source, copy.col.model, copy.col.tier, copy.col.status, copy.col.progress, copy.col.tokens, copy.col.task].map((heading) => (
1586
- <th key={heading} style={{ ...S.tight, fontSize: 10.5, opacity: 0.55, fontWeight: 500 }}>{heading}</th>
1587
- ))}
1588
- </tr></thead>
1589
- <tbody>
1590
- {jobs.map((job) => {
1591
- const color = job.status === 'running' ? '#4a9eff' : job.status === 'done' ? '#3fb950' : '#f85149';
1592
- return (
1593
- <tr key={job.id}>
1594
- <td style={{ ...S.tight, ...S.mono, fontWeight: 600 }} title={job.id}>{String(job.id).replace(/-[a-z0-9]+$/, '')}</td>
1595
- <td style={S.tight}><span style={S.chip(job.role === 'reviewer')}>{job.role === 'reviewer' ? 'Reviewer' : 'Worker'}</span></td>
1596
- <td style={S.tight}><span style={S.chip(job.source === 'claude-code' || job.source === 'codex')}>{sourceLabel(job.source)}</span></td>
1597
- <td style={{ ...S.tight, ...S.mono }} title={`${job.provider ?? '—'} / ${job.model ?? '—'}`}>{job.provider ?? '—'} / {job.model ?? '—'}</td>
1598
- <td style={S.tight}>{job.tier}/{job.effort}</td>
1599
- <td style={S.tight} title={`${job.status}${job.currentTool ? ` · ${job.currentTool}` : ''}`}>
1600
- <span style={{ color }} aria-hidden="true">●</span> <span style={{ color, fontSize: 10.5 }}>{job.status}</span>
1601
- </td>
1602
- <td style={{ ...S.tight, ...S.mono }} title={copy.progressTip(elapsed(job.startedAt, job.endedAt), job.turn, job.step, job.toolCalls ?? '-')}>{elapsed(job.startedAt, job.endedAt)} · {job.turn}.{job.step} · {job.toolCalls ?? '-'}t</td>
1603
- <td style={{ ...S.tight, ...S.mono }}>{job.tokens ? `${ktok(job.tokens.input)}/${ktok(job.tokens.output)}` : '—'}</td>
1604
- <td
1605
- style={{ ...S.cell, position: 'relative' }}
1606
- onMouseEnter={(event: any) => {
1607
- const rect = event.currentTarget.getBoundingClientRect();
1608
- setHoverTask({ id: job.id, text: job.task, right: Math.max(8, window.innerWidth - rect.right), top: rect.bottom + 4, bottom: window.innerHeight - rect.top + 4, flip: rect.top < 240 });
1609
- }}
1610
- onMouseLeave={() => setHoverTask(null)}>
1611
- <div style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', lineHeight: 1.45, fontSize: 12 }}>
1612
- {job.task}
1613
- </div>
1614
- <div style={{ fontSize: 10, opacity: 0.5 }}>{job.selection_source ?? 'unknown'}</div>
1615
- </td>
1616
- </tr>
1617
- );
1618
- })}
1619
- </tbody>
1620
- </table>
1621
- </div>
1622
- )}
1623
-
1624
- <div style={{ ...S.block, marginTop: 2 }}>
1625
- <div>
1626
- <div style={S.settingTitle}>{copy.modelActivity}</div>
1627
- <div style={S.settingDesc}>{copy.modelActivityHint}</div>
1628
- </div>
1629
- {modelActivity.length === 0 ? <div style={{ opacity: 0.55 }}>{copy.noModelActivity}</div> : (
1630
- <div style={{ overflowX: 'auto' }}>
1631
- <div role="table" style={{ minWidth: 620 }}>
1632
- <div role="row" style={{ display: 'grid', gridTemplateColumns: 'minmax(190px, 1.35fr) 72px minmax(170px, 1fr) 125px', gap: 10, padding: '0 6px 5px', fontSize: 10.5, opacity: 0.55 }}>
1633
- <span role="columnheader">{copy.model}</span><span role="columnheader">{copy.calls}</span><span role="columnheader">{copy.invocationSource} / {copy.routingSource}</span><span role="columnheader">{copy.lastCalled}</span>
1634
- </div>
1635
- {modelActivity.map((entry) => (
1636
- <div role="row" key={`${entry.provider}/${entry.model}`} style={{ display: 'grid', gridTemplateColumns: 'minmax(190px, 1.35fr) 72px minmax(170px, 1fr) 125px', gap: 10, alignItems: 'center', padding: '7px 6px', borderTop: '1px solid rgba(128,128,128,0.16)' }}>
1637
- <span role="cell" style={{ minWidth: 0 }}><strong style={{ display: 'block', fontSize: 12 }}>{entry.model}</strong><span style={{ ...S.mono, opacity: 0.55 }}>{entry.provider}</span></span>
1638
- <span role="cell" style={{ ...S.mono, fontSize: 15, fontWeight: 650 }}>{entry.count}</span>
1639
- <span role="cell" style={{ minWidth: 0, fontSize: 11.5 }}><span style={{ display: 'block' }}>{entry.task_sources.map(sourceLabel).join(' · ')}</span><span style={{ display: 'block', opacity: 0.55 }}>{entry.selection_sources.join(' · ')} · {entry.roles.length > 0 ? entry.roles.join(' / ') : copy.never}</span></span>
1640
- <span role="cell" style={{ ...S.mono, fontSize: 10.5 }}>{formatTimestamp(entry.last_called_at, locale)}</span>
1641
- </div>
1642
- ))}
1643
- </div>
1644
- </div>
1645
- )}
1646
- </div>
1647
- </CollapsibleSection>
1648
- {hoverTask && createPortal(
1649
- <div style={{
1650
- position: 'fixed', right: hoverTask.right, zIndex: 9999,
1651
- ...(hoverTask.flip ? { top: hoverTask.top } : { bottom: hoverTask.bottom }),
1652
- minWidth: 260, maxWidth: 420, maxHeight: 180, overflowY: 'auto',
1653
- padding: '8px 12px', borderRadius: 8,
1654
- border: '1px solid rgba(128,128,128,0.35)',
1655
- background: 'Canvas', color: 'CanvasText',
1656
- boxShadow: '0 8px 28px rgba(0,0,0,0.22)',
1657
- whiteSpace: 'pre-wrap', fontSize: 12, lineHeight: 1.5,
1658
- }}>{hoverTask.text}</div>,
1659
- document.body,
1660
- )}
1661
- </div>
1662
- );
1663
- }
1664
-
1665
- export function apply(ctx: any): void {
1666
- let runningCount = 0;
1667
- let badgeSurface: boolean | null = null;
1668
- ctx.slots.inject('settings.section', () => {
1669
- let dispose = register();
1670
- function register() {
1671
- return ctx.slots.register(
1672
- {
1673
- name: 'settings.section',
1674
- id: 'dsh-crew',
1675
- order: 65,
1676
- label: () => {
1677
- const base = COPY[ctx.locale.getLocale().active === 'zh' ? 'zh' : 'en'].label;
1678
- if (runningCount <= 0) return base;
1679
- // String-only slot labels: filled circled digits are the closest
1680
- // thing to a badge the contract allows.
1681
- const CIRCLED = ['❶', '❷', '❸', '❹', '❺', '❻', '❼', '❽', '❾', '❿'];
1682
- return `${base} ${CIRCLED[runningCount - 1] ?? `(${runningCount})`}`;
1683
- },
1684
- },
1685
- () => <WorkersPanel ctx={ctx} />,
1686
- );
1687
- }
1688
- const poll = async () => {
1689
- if (document.visibilityState !== 'visible') return;
1690
- try {
1691
- if (badgeSurface === null) {
1692
- const [bridgeStatus, runtime] = await Promise.all([
1693
- fetch(`${API}/bridge-status`, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
1694
- fetch(`${API}/runtime`, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
1695
- ]);
1696
- badgeSurface = surfaceResponsibilities(classifyCrewSurface({ bridgeStatus, runtime })).fullControlPlane;
1697
- }
1698
- if (!badgeSurface) return;
1699
- const r = await (await fetch(`${API}/jobs`, { cache: 'no-store' })).json();
1700
- const n = r.ok ? (r.jobs ?? []).filter((j: any) => j.status === 'running').length : 0;
1701
- if (n !== runningCount) {
1702
- runningCount = n;
1703
- dispose(); dispose = register(); // slots/changed → nav re-renders the label
1704
- }
1705
- } catch { /* hub restarting */ }
1706
- };
1707
- void poll();
1708
- const timer = setInterval(() => { void poll(); }, 10_000);
1709
- return () => { clearInterval(timer); dispose(); };
1710
- });
1711
- }
1
+ // Browser half of dsh-crew: integrations (install/update/restore),
2
+ // global worker configuration, and the live jobs table.
3
+ // Talks only to the plugin's own loopback routes.
4
+
5
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
6
+ import { createPortal } from 'react-dom';
7
+ import {
8
+ openSections,
9
+ readSectionState,
10
+ setEverySection,
11
+ writeSectionState,
12
+ } from './collapsible-sections.mjs';
13
+ import { ActivationSummary } from './activation-summary';
14
+ import { projectHostReadiness, READINESS_STATES } from './host-readiness.mjs';
15
+ import { modelCallabilityState } from './model-callability-view.mjs';
16
+ import { acceptReadinessResponse, readinessExpiryDelay } from './readiness-envelope.mjs';
17
+ import { CREW_UI_SURFACES, classifyCrewSurface, surfaceResponsibilities } from './surface-detection.mjs';
18
+ import { aggregateModelInvocations } from './task-telemetry.mjs';
19
+ import { PanelHeader, PanelStyles } from './panel-chrome';
20
+ import { HistoryPanel } from './history-panel';
21
+ // The schedule contract is shared, not mirrored: model-schedule.mjs imports
22
+ // nothing, so the panel can use the same normalization and peak test the router
23
+ // does. A hand-maintained copy could accept less than the server and then write
24
+ // the difference back on save.
25
+ import { normalizeModelSchedule, isPeakAt } from '../model-schedule.mjs';
26
+
27
+ export const inject = ['slots', 'locale'];
28
+
29
+ const API = '/_dsh/dsh-crew';
30
+ const CREW_CONTROL_PLANE_URL = 'http://127.0.0.1:3080/';
31
+
32
+ type AdaptiveConfig = { enabled: boolean; window_size: number; min_samples: number };
33
+ const DEFAULT_ADAPTIVE: AdaptiveConfig = { enabled: false, window_size: 8, min_samples: 2 };
34
+
35
+ function clampInt(value: unknown, fallback: number, min: number, max: number) {
36
+ const parsed = Number(value);
37
+ if (!Number.isInteger(parsed)) return fallback;
38
+ return Math.max(min, Math.min(max, parsed));
39
+ }
40
+
41
+ function normalizeAdaptive(value: any): AdaptiveConfig {
42
+ const windowSize = clampInt(value?.window_size, DEFAULT_ADAPTIVE.window_size, 1, 32);
43
+ return {
44
+ enabled: value?.enabled === true,
45
+ window_size: windowSize,
46
+ min_samples: clampInt(value?.min_samples, DEFAULT_ADAPTIVE.min_samples, 1, windowSize),
47
+ };
48
+ }
49
+
50
+
51
+ const COPY = {
52
+ zh: {
53
+ label: 'DSH Crew',
54
+ title: 'DSH Crew',
55
+ openOfficial: '打开 3080 官方界面 →',
56
+ intro: '管理工作流、模型路由与宿主集成。展开分区查看和修改设置。',
57
+ surfaceLabel: '3210 / 高级配置',
58
+ integrations: '集成',
59
+ installed: '已安装', notInstalled: '未安装', ready: '集成就绪', integrationReady: '集成就绪', modelCallable: '模型可调用', modelNotCallable: '模型未验证', modelUnknown: '模型未知', notReady: '未就绪', hud: 'HUD 段',
60
+ install: '安装', update: '更新', restore: '还原',
61
+ confirmRestore: (name: string) => `确定从 ${name} 移除 dsh-crew 集成?(settings 会先备份)`,
62
+ globalConfig: '全局配置',
63
+ expandAll: '全部展开', collapseAll: '全部折叠',
64
+ modelCount: (count: number) => `${count} 个模型`, providerCount: (count: number) => `${count} 个 Provider`,
65
+ jobCount: (count: number) => `${count} 个任务`, runningCount: (count: number) => `${count} 个运行中`,
66
+ sectionNames: { integrations: 'Codex / Claude / ZCode 集成状态', workflow: 'Crew 工作流设置', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: '模型优先级与派发', schedule: '波峰波谷调度', adaptive: '自适应路由', runtime: '运行 / 生效边界', harnessProviders: 'Harness Providers', jobs: '任务状态' },
67
+ harnessHint: 'DeepSeek 官方 Harness 的 dsh-crew profile:Provider、Harness Models 与运行时配置',
68
+ hostReadiness: '宿主集成就绪度', hostReadinessHint: '只使用结构化安装与运行时证据;缺少证据不会显示 READY。',
69
+ readinessLabels: { codex_mcp: 'Codex MCP', ds_worker: 'ds-worker', ds_reviewer: 'ds-reviewer', claude_plugin: 'Claude plugin', zcode_mcp: 'ZCode MCP', crew_harness: 'Crew plugin profile', official_bridge: 'Official bridge' },
70
+ readinessStates: { READY: 'READY', DEGRADED: 'DEGRADED', UNAVAILABLE: 'UNAVAILABLE', UNKNOWN: 'UNKNOWN', NOT_APPLICABLE: 'N/A' },
71
+ globalHint: '修改即时保存到 ~/.config/dsh-crew/config.json;CC / Codex 的新会话自动读取为默认值(会话内可用 /dsh-crew:config 临时覆盖)。',
72
+ orchestration: 'Agent 编排',
73
+ enableSubagents: '启用子 Agent',
74
+ enableSubagentsHint: '总开关。关闭后 dsh_run_worker / dsh_spawn_worker 在工具层拒绝派发(配置保留,重新开启即恢复)。',
75
+ subagentsKept: '配置已保留',
76
+ mainAgentMode: '主 Agent 模式',
77
+ mainAgentModeDesc: { 'direct-allowed': '直接允许 · 可自己做,也可委派', 'coordinator-first': '协调优先 · 先规划、拆分、委派,再检查验证', 'dispatcher-only': '仅派发 · 尽量只做规划、派发、审查与整合' },
78
+ mainAgentModeHint: '这是宿主路由指引,不是对宿主自身工具的硬限制。',
79
+ collaborationMode: '协作模式',
80
+ collaborationModeDesc: { 'flash-only': 'Flash Only · 只用 Flash', 'pro-only': 'Pro Only · 只用 Pro', balanced: 'Balanced · 双 Auto', 'review-pipeline': 'Review Pipeline · Flash 实现 + Pro 复审', custom: 'Custom · 完全手动' },
81
+ tierCardFlash: 'Flash', tierCardPro: 'Pro',
82
+ tierState: '状态',
83
+ tierStateDesc: { disabled: '禁用', manual: '手动(仅用户点名时可用)', auto: '自动(orchestrator 可自动委派)' },
84
+ stateManagedByPreset: '由协作模式管理',
85
+ statePresetHint: '切到 Custom 可单独配置各档状态。',
86
+ workerProvider: 'Worker Provider',
87
+ workerProviderDesc: { 'follow-dsh': '跟随 Harness · 使用各档独立的模型优先级', 'deepseek-official': 'DeepSeek Official · 使用旧版固定模型路由' },
88
+ workerProviderHint: '仅作用于 Hub 模式;Standalone 仅保留为旧版迁移元数据,不是生产执行路径。',
89
+ workerModels: 'Worker 模型', refreshHarnessModels: '刷新 Harness 模型', refreshingModels: '刷新中…',
90
+ modelPriority: '模型优先级', addPriorityModel: '+ 添加优先模型', modelSearch: '搜索 Provider 或 Model…',
91
+ harnessDefault: 'Harness 默认模型', providerUnavailable: 'Provider 不可用', notAdvertised: '当前未列出',
92
+ noPriority: '未配置优先项', modelPoolSummary: (p: number, m: number) => `Providers: ${p} · Models: ${m}`,
93
+ healthUnprobed: 'UNPROBED', checkedAgo: (s: number) => `${s} 秒前检查`,
94
+ partialCatalog: '部分 Provider 读取失败', catalogFailed: '无法读取 Harness 模型目录', removePriority: '移除', moveUp: '上移', moveDown: '下移',
95
+ needsModelSelection: '多个 Provider 提供默认偏好模型,且 Harness Default 无法消歧;当前将回退到 Harness Default,请手动选择。',
96
+ responsibilities: '职责',
97
+ responsibilitiesHint: '路由指引,仅用于描述分工;只剩一个 Auto 档时它承担全部可委派工作。',
98
+ roleLabels: { implementation: '代码实现', simple_fix: '简单修复', tests: '测试', search_inspection: '搜索 / 检查', architecture: '架构', complex_debugging: '复杂调试', refactor: '重构', code_review: '代码审查' },
99
+ routing: '路由',
100
+ proReviewsFlash: 'Pro 复审 Flash 改动',
101
+ proReviewsFlashHint: 'Flash 成功后自动追加一次 Pro 复审(仅 blocking 派发)',
102
+ proReviewsLocked: 'Review Pipeline 模式固定开启',
103
+ tier: '默认档位', effort: '默认推理', mode: '执行模式', timeout: '默认超时(秒)', hubUrl: 'Hub 地址',
104
+ tierPolicy: '档位策略', escalate: '失败升档',
105
+ tierPolicyDesc: { auto: 'auto · orchestrator 自选', 'flash-only': '只用 flash', 'pro-only': '只用 pro' },
106
+ escalateHint: 'flash 失败自动用 pro 重试一次',
107
+ presetFlash: 'flash 模式', presetPro: 'pro 模式',
108
+ harnessProviders: 'Harness Providers', harnessProviderHint: 'DeepSeek 官方 Harness 的 3210 dsh-crew profile 中的真实 Provider 注册与生命周期;不是视觉 / 生图适配器。',
109
+ providerState: (state: string) => `状态:${state}`, providerModels: (count: number) => `${count} 个模型`, providerJobs: (count: number) => `${count} 个运行任务`,
110
+ providerOfficialBuiltin: '· 官方内置(不可删除)', providerSourceUnresolved: '· 来源未解析(安全锁定)',
111
+ providerLayerMigration: '检测到旧版 profile Provider', providerLayerMigrationHint: '这些 Provider 仍在 Crew profile/base 层,所以 3210 原生页面不会显示删除。请先按迁移计划提升到 Harness 用户设置层;不会复制密钥。', providerLayerMigrationPlan: '迁移计划', providerLayerMigrate: '迁移到用户层', providerLayerMigrating: '迁移中…', providerLayerMigrationConfirm: '确认把这个 Provider 移到 Harness 用户设置层?会先保存可回滚快照,不会复制密钥值。', providerLayerRollback: '回滚迁移',
112
+ providerDeletePlan: '生成删除计划', providerDelete: '确认删除', providerPlanReady: '计划已生成;再次确认后才会修改 3210 配置。', providerRollback: '回滚', providerRollbackConfirm: '按已保存事务恢复这个 Harness Provider 及原有路由?', providerRollbackUnavailable: '没有可验证的回滚事务',
113
+ providerReplacement: '替换 Harness 默认 Provider(输入 Provider id)', providerDeleteConfirm: '确认按计划删除这个 Harness Provider?会清理路由引用并重启 3210。',
114
+ providerDeleteBlocked: '当前 Provider 不能删除(内置、在用、已删除或缺少可用替换)。', providerLifecycleError: 'Provider 生命周期操作失败', providerDeletePending: '配置可能已变更,事务仍待重启/验证;请刷新状态后选择回滚。', providerRollbackContinue: '继续回滚验证', recoveryUnresolved: '发现不可自动恢复的事务', recoveryStorage: '存储项', recoveryQuarantine: '隔离', recoveryQuarantineConfirm: '该事务清单无法安全恢复。将其移入隔离区并阻止自动使用,继续?',
115
+ providerNoInventory: '暂时无法读取 3210 Provider inventory。',
116
+ credentialReferences: 'Credential 引用', credentialReferenceHint: '仅显示引用名、归属和孤儿状态;不会读取或删除密钥。',
117
+ credentialOrphan: '孤儿 · 可单独申请清理', credentialInUse: (count: number) => `使用中 · ${count} 个 Provider`, credentialPurgePlan: '申请清理', credentialPurgeConfirm: '这是不可恢复的 Crew-owned 凭据清理,确认继续?', credentialPurging: '清理中…', credentialPurgeUnavailable: '凭据清理不可用', credentialPurgeUnverified: '已执行清理,但验证失败;引用仍保留用于恢复检查',
118
+ staleHub: '本 DSH 实例还在跑旧版插件,缺少该接口 —— 重启 DSH 后再试',
119
+ emptyResponse: (path: string, status: number) => `${path} → HTTP ${status},响应为空`,
120
+ badJson: (path: string, body: string) => `${path} → 非 JSON 响应: ${body}`,
121
+ presetDefault: (id?: string) => `default · 跟随 DSH${id ? ` (${id})` : ''}`,
122
+ progressTip: (t: string, turn: number, step: number, calls: number | string) => `耗时 ${t} · 第${turn}轮第${step}步 · ${calls} 次工具调用`,
123
+ groupDispatch: '派发', groupRuntime: '运行', groupMultimodal: '多模态',
124
+ groupWorkflow: '工作流 / 执行(v0.2)',
125
+ roleWorker: 'Worker 角色(执行)', roleReviewer: 'Reviewer 角色(独立审查)',
126
+ workerStateLabel: 'Worker 状态', reviewStateLabel: 'Reviewer 状态',
127
+ roleStateHint: 'auto:orchestrator 可自动派发;manual:仅显式点名;disabled:禁用。未显式设置时由协作模式推导。',
128
+ autoReview: '自动复审', autoReviewHint: 'worker 成功后自动运行一次 reviewer(独立模型策略,只读)',
129
+ modelOrdering: '候选排序', orderingDesc: { manual: '手动顺序', 'health-aware': '健康感知' },
130
+ orderingHint: '手动 priority 始终保持顺序;健康感知仅重排未显式锁定的自动候选。',
131
+ healthGate: '健康门', healthGateDesc: { 'hard-failures': '跳过明确不可调用', off: '关闭健康门' },
132
+ reviewGate: 'Reviewer 验收门', reviewGateDesc: { required: 'required · 未批准则失败', optional: 'optional · 记录但不阻断', off: 'off · 不自动复审' },
133
+ isolation: '工作区隔离', isolationHint: 'worktree 为默认;非 Git 仓库时需显式 shared,否则任务以 NOT_GIT_REPOSITORY 失败。',
134
+ isolationDesc: { worktree: 'worktree · 每个 coding worker 独立临时 git worktree(主工作区不动)', shared: 'shared · 旧版就地执行' },
135
+ maxParallel: '最大并行', maxParallelHint: '同时运行的工作流上限(1–16);超出进入队列。',
136
+ legacyCompatibility: '↓ 以下为兼容配置(legacy,Flash/Pro 仍可编辑)',
137
+ cardDispatch: { t: '派发策略', d: 'orchestrator 未指定时的档位与推理强度,以及档位约束、失败升档与两档各自挂载的 Agent 预设。' },
138
+ cardRuntime: { t: '执行与连接', d: 'worker 会话跑在本实例内还是独立进程,单个任务的超时上限,以及 CC / Codex 探测本实例的地址。' },
139
+ modeDesc: { auto: 'auto · 优先 3210', hub: 'hub · 必须 3210' },
140
+ save: '保存', saved: '已保存', jobs: 'Worker 任务', empty: '当前没有 Worker / Reviewer 任务。',
141
+ adaptiveTitle: '自适应模型路由(实验)', adaptiveHint: '默认关闭;只重排系统自动候选,手动模型优先级始终保持原序。健康信号仅来自本进程已观察到的成功、失败、超时与粗粒度延迟,重启后清空。',
142
+ adaptiveEnabled: '启用自适应路由', adaptiveWindow: '健康窗口', adaptiveSamples: '最少样本', adaptiveBoundary: '下一个工作流生效',
143
+ scheduleTitle: '波峰波谷调度', scheduleHint: '按本地时间限制指定模型:波峰时被“阻止”的模型会被跳过,由下一个候选接替任务;“提醒”的仍会使用并在选择轨迹中标记。未列出的模型不受限制。默认时段为 DeepSeek 公布的波峰时间(工作日 09:00-12:00、14:00-18:00)。',
144
+ scheduleTimezone: '时区(相对 UTC)', scheduleWindows: '波峰时段', schedulePeak: '现在处于波峰', scheduleOffPeak: '现在处于波谷',
145
+ scheduleAddWindow: '添加时段', scheduleStart: '开始', scheduleEnd: '结束',
146
+ scheduleBlock: '阻止', scheduleWarn: '提醒', scheduleOff: '关闭',
147
+ scheduleRestricted: '受限模型', scheduleNoRestricted: '还没有受限模型;未列出的模型不受时间限制。',
148
+ scheduleWeekdays: '生效工作日', scheduleBoundary: '下次派发生效',
149
+ modelActivity: '模型活动概览', modelActivityHint: '聚合本 Hub 内存中最近 500 个含真实调用证据的任务;每个模型按任务计数(不是推测底层轮次),最多显示 50 个模型。不保存提示词、结果或凭据。', noModelActivity: '还没有真实模型活动。',
150
+ calls: '任务数', invocationSource: '任务来源', routingSource: '路由来源', lastCalled: '最近调用', role: '角色', model: '模型', never: '—',
151
+ col: { id: '任务', role: '角色', source: '来源', model: '模型', tier: '档位', status: '状态', progress: '进度', tokens: 'tokens ⇅', task: '内容' },
152
+ working: '处理中…',
153
+ tips: {
154
+ claude: {
155
+ install: '一键装进 Claude Code:注册本地 marketplace、claude plugin install、写入工具权限白名单、接线 claude-hud worker 段;修改 settings.json 前自动备份',
156
+ update: '重跑安装流程:刷新注册与权限、迁移旧格式配置、更新 HUD 接线;幂等,可放心点',
157
+ restore: '从 Claude Code 移除集成:清除 marketplace 注册、插件启用项与权限规则(settings 先备份);不删除插件源码与已跑任务',
158
+ },
159
+ codex: {
160
+ install: '把 ds-flash / ds-pro 角色和 /dsh-config、/dsh-status 命令复制到 ~/.codex/(MCP 路径按本机自动渲染,含 approve 审批与超时配置)',
161
+ update: '重新渲染并覆盖角色文件与命令(插件路径或配置变更后用);原文件先备份',
162
+ restore: '删除 ~/.codex/ 下的 ds-flash / ds-pro 角色与 dsh 命令文件(角色文件先备份)',
163
+ },
164
+ zcode: {
165
+ install: '安装 ZCode 全局规则、ds-worker / ds-reviewer 角色、命令和按来源选择的 MCP 配置到 ~/.zcode/',
166
+ update: '重新渲染 ZCode 角色、命令和 MCP 条目;保留个人文件与其他 MCP 服务',
167
+ restore: '仅移除 dsh-crew 管理的 ZCode 文件和 MCP 条目(保留备份)',
168
+ },
169
+ },
170
+ },
171
+ en: {
172
+ label: 'DSH Crew',
173
+ title: 'DSH Crew',
174
+ intro: 'Manage workflows, model routing and host integrations. Expand a section to edit its settings.',
175
+ surfaceLabel: '3210 / ADVANCED SETTINGS',
176
+ openOfficial: 'Open official frontend (3080) →',
177
+ integrations: 'Integrations',
178
+ installed: 'installed', notInstalled: 'not installed', ready: 'integration ready', integrationReady: 'integration ready', modelCallable: 'model callable', modelNotCallable: 'model not verified', modelUnknown: 'model unknown', notReady: 'not ready', hud: 'HUD segment',
179
+ install: 'Install', update: 'Update', restore: 'Restore',
180
+ confirmRestore: (name: string) => `Remove the dsh-crew integration from ${name}? (settings are backed up first)`,
181
+ globalConfig: 'Global configuration',
182
+ expandAll: 'Expand all', collapseAll: 'Collapse all',
183
+ modelCount: (count: number) => `${count} models`, providerCount: (count: number) => `${count} providers`,
184
+ jobCount: (count: number) => `${count} jobs`, runningCount: (count: number) => `${count} running`,
185
+ sectionNames: { integrations: 'Codex / Claude / ZCode integration status', workflow: 'Crew workflow settings', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: 'Model priority & dispatch', schedule: 'Peak / off-peak scheduling', adaptive: 'Adaptive routing', runtime: 'Runtime / activation boundaries', harnessProviders: 'Harness Providers', jobs: 'Task status' },
186
+ harnessHint: 'The official DeepSeek Harness dsh-crew profile: providers, Harness Models, and runtime configuration',
187
+ hostReadiness: 'Host integration readiness', hostReadinessHint: 'Uses structured installer and runtime evidence only; missing evidence is never READY.',
188
+ readinessLabels: { codex_mcp: 'Codex MCP', ds_worker: 'ds-worker', ds_reviewer: 'ds-reviewer', claude_plugin: 'Claude plugin', zcode_mcp: 'ZCode MCP', crew_harness: 'Crew plugin profile', official_bridge: 'Official bridge' },
189
+ readinessStates: { READY: 'READY', DEGRADED: 'DEGRADED', UNAVAILABLE: 'UNAVAILABLE', UNKNOWN: 'UNKNOWN', NOT_APPLICABLE: 'N/A' },
190
+ globalHint: 'Changes save instantly to ~/.config/dsh-crew/config.json; new CC / Codex sessions pick them up as defaults (override per session with /dsh-crew:config).',
191
+ orchestration: 'Agent orchestration',
192
+ enableSubagents: 'Enable Subagents',
193
+ enableSubagentsHint: 'Master switch. When off, dsh_run_worker / dsh_spawn_worker refuse dispatch at the tool layer (the configuration is kept; re-enable to restore).',
194
+ subagentsKept: 'Configuration kept',
195
+ mainAgentMode: 'Main Agent Mode',
196
+ mainAgentModeDesc: { 'direct-allowed': 'Direct Allowed · implement directly or delegate', 'coordinator-first': 'Coordinator First · plan, decompose, delegate, verify', 'dispatcher-only': 'Dispatcher Only · plan, dispatch, review, integrate' },
197
+ mainAgentModeHint: 'Host routing guidance — not a hard restriction on the host\'s own tools.',
198
+ collaborationMode: 'Collaboration Mode',
199
+ collaborationModeDesc: { 'flash-only': 'Flash Only', 'pro-only': 'Pro Only', balanced: 'Balanced', 'review-pipeline': 'Review Pipeline · implement + review', custom: 'Custom' },
200
+ tierCardFlash: 'Flash', tierCardPro: 'Pro',
201
+ tierState: 'State',
202
+ tierStateDesc: { disabled: 'Disabled', manual: 'Manual (only when the user names this tier)', auto: 'Auto (the orchestrator may delegate to it)' },
203
+ stateManagedByPreset: 'managed by the collaboration mode',
204
+ statePresetHint: 'Switch to Custom to configure each tier state separately.',
205
+ workerProvider: 'Worker Provider',
206
+ workerProviderDesc: { 'follow-dsh': 'Follow Harness · use each tier\'s ordered model priority', 'deepseek-official': 'DeepSeek Official · use legacy fixed model routing' },
207
+ workerProviderHint: 'Applies to Hub workers only; Standalone is legacy migration metadata only and is not a production execution path.',
208
+ workerModels: 'Worker Models', refreshHarnessModels: 'Refresh Harness Models', refreshingModels: 'Refreshing…',
209
+ modelPriority: 'Model Priority', addPriorityModel: '+ Add Priority Model', modelSearch: 'Search Provider or Model…',
210
+ harnessDefault: 'Harness Default', providerUnavailable: 'Provider unavailable', notAdvertised: 'Currently not advertised',
211
+ noPriority: 'No priority models configured', modelPoolSummary: (p: number, m: number) => `Providers: ${p} · Models: ${m}`,
212
+ healthUnprobed: 'UNPROBED', checkedAgo: (s: number) => `checked ${s}s ago`,
213
+ partialCatalog: 'Some providers failed to load', catalogFailed: 'Unable to read Harness model catalog', removePriority: 'Remove', moveUp: 'Move up', moveDown: 'Move down',
214
+ needsModelSelection: 'Multiple providers advertise the preferred model and Harness Default cannot disambiguate them. Falling back to Harness Default; choose a priority model explicitly.',
215
+ responsibilities: 'Responsibilities',
216
+ responsibilitiesHint: 'Routing guidance describing the split of work; with a single Auto tier left, that tier carries all delegatable coding work.',
217
+ roleLabels: { implementation: 'Code implementation', simple_fix: 'Simple fixes', tests: 'Tests', search_inspection: 'Search / inspection', architecture: 'Architecture', complex_debugging: 'Complex debugging', refactor: 'Refactoring', code_review: 'Code review' },
218
+ routing: 'Routing',
219
+ proReviewsFlash: 'Pro reviews Flash changes',
220
+ proReviewsFlashHint: 'Automatically follow a successful Flash run with one Pro review (blocking dispatch only)',
221
+ proReviewsLocked: 'always on in Review Pipeline mode',
222
+ tier: 'Default tier', effort: 'Default effort', mode: 'Mode', timeout: 'Timeout (s)', hubUrl: 'Hub URL',
223
+ tierPolicy: 'Tier policy', escalate: 'Escalate on failure',
224
+ tierPolicyDesc: { auto: 'auto · orchestrator picks', 'flash-only': 'flash only', 'pro-only': 'pro only' },
225
+ escalateHint: 'retry a failed flash run once on pro',
226
+ presetFlash: 'flash preset', presetPro: 'pro preset',
227
+ harnessProviders: 'Harness Providers', harnessProviderHint: 'Live Provider registration and lifecycle in the official DeepSeek Harness dsh-crew profile on 3210.',
228
+ providerState: (state: string) => `State: ${state}`, providerModels: (count: number) => `${count} models`, providerJobs: (count: number) => `${count} running jobs`,
229
+ providerOfficialBuiltin: '· official built-in (immutable)', providerSourceUnresolved: '· source unresolved (locked)',
230
+ providerLayerMigration: 'Legacy profile Providers detected', providerLayerMigrationHint: 'These Providers still exist in the Crew profile/base layer, so the native 3210 page cannot show Delete. Promote them to Harness user settings with an explicit plan; keys are never copied.', providerLayerMigrationPlan: 'Migration plan', providerLayerMigrate: 'Migrate to user layer', providerLayerMigrating: 'Migrating…', providerLayerMigrationConfirm: 'Move this Provider into Harness user settings? A rollback snapshot is saved first; credential values are never copied.', providerLayerRollback: 'Rollback migration',
231
+ providerDeletePlan: 'Plan deletion', providerDelete: 'Confirm delete', providerPlanReady: 'Plan ready; a second confirmation is required before changing 3210 config.', providerRollback: 'Rollback', providerRollbackConfirm: 'Restore this Harness Provider and its previous routing from the saved transaction?', providerRollbackUnavailable: 'No verified rollback transaction is available',
232
+ providerReplacement: 'Replacement Harness Default provider id', providerDeleteConfirm: 'Delete this Harness Provider per the plan? Routing refs will be scrubbed and 3210 restarted.',
233
+ providerDeleteBlocked: 'This Provider cannot be deleted (built-in, in use, already absent, or missing a valid replacement).', providerLifecycleError: 'Provider lifecycle operation failed', providerDeletePending: 'Configuration may already be changed; the transaction still needs restart/verification. Refresh status and use rollback if needed.', providerRollbackContinue: 'Continue rollback verification', recoveryUnresolved: 'An unrecoverable recovery transaction is blocking deletion', recoveryStorage: 'storage entry', recoveryQuarantine: 'Quarantine', recoveryQuarantineConfirm: 'This recovery manifest cannot be safely reopened. Move it into the quarantine area and keep it out of automatic recovery?',
234
+ providerNoInventory: '3210 Provider inventory is temporarily unavailable.',
235
+ credentialReferences: 'Credential references', credentialReferenceHint: 'Names, ownership, and orphan status only; values are never read or deleted.',
236
+ credentialOrphan: 'orphan · separate purge approval required', credentialInUse: (count: number) => `in use · ${count} provider(s)`, credentialPurgePlan: 'Purge…', credentialPurgeConfirm: 'This irreversibly removes a Crew-owned credential. Continue?', credentialPurging: 'Purging…', credentialPurgeUnavailable: 'Credential purge unavailable', credentialPurgeUnverified: 'Purge ran but verification failed; the reference remains visible for recovery',
237
+ staleHub: 'This DSH instance is still running an older build of the plugin and lacks this route — restart DSH and retry',
238
+ emptyResponse: (path: string, status: number) => `${path} → HTTP ${status}, empty response`,
239
+ badJson: (path: string, body: string) => `${path} → non-JSON response: ${body}`,
240
+ presetDefault: (id?: string) => `default · follow DSH${id ? ` (${id})` : ''}`,
241
+ progressTip: (t: string, turn: number, step: number, calls: number | string) => `${t} elapsed · turn ${turn}, step ${step} · ${calls} tool calls`,
242
+ groupDispatch: 'Dispatch', groupRuntime: 'Runtime', groupMultimodal: 'Multimodal',
243
+ groupWorkflow: 'Workflow / Execution (v0.2)',
244
+ roleWorker: 'Worker role (execution)', roleReviewer: 'Reviewer role (independent review)',
245
+ workerStateLabel: 'Worker state', reviewStateLabel: 'Reviewer state',
246
+ roleStateHint: 'auto = the orchestrator may dispatch automatically; manual = only when explicitly named; disabled = off. When unset, derived from the collaboration mode.',
247
+ autoReview: 'Automatic review', autoReviewHint: 'Run one reviewer pass (independent model policy, read-only) after a successful worker run',
248
+ modelOrdering: 'Candidate ordering', orderingDesc: { manual: 'Manual order', 'health-aware': 'Health-aware' },
249
+ orderingHint: 'Explicit priority always keeps its order; health-aware mode only reorders automatic, unlocked candidates.',
250
+ healthGate: 'Health gate', healthGateDesc: { 'hard-failures': 'Skip known hard failures', off: 'Health gate off' },
251
+ reviewGate: 'Reviewer acceptance gate', reviewGateDesc: { required: 'required · fail unless approved', optional: 'optional · record without blocking', off: 'off · no automatic review' },
252
+ isolation: 'Workspace isolation', isolationHint: 'worktree is the default; non-git workspaces require explicit shared, otherwise jobs fail with NOT_GIT_REPOSITORY.',
253
+ isolationDesc: { worktree: 'worktree · each coding worker runs in its own temporary git worktree (primary untouched)', shared: 'shared · legacy in-place execution' },
254
+ maxParallel: 'Max parallel', maxParallelHint: 'Upper bound of concurrently running workflows (1–16); extra ones queue.',
255
+ legacyCompatibility: '↓ Legacy compatibility controls below (Flash/Pro still editable)',
256
+ cardDispatch: { t: 'Dispatch policy', d: 'Tier and effort used when the orchestrator names none, plus tier clamping, failure escalation and the Agent preset mounted per tier.' },
257
+ cardRuntime: { t: 'Execution & connection', d: 'Whether worker sessions run inside this instance or a separate process, the per-job timeout, and the address CC / Codex probes.' },
258
+ modeDesc: { auto: 'auto · prefer 3210', hub: 'hub · require 3210' },
259
+ save: 'Save', saved: 'Saved', jobs: 'Worker jobs', empty: 'No Worker / Reviewer jobs yet.',
260
+ adaptiveTitle: 'Adaptive model routing (experimental)', adaptiveHint: 'Off by default. Only system-derived candidates may be reordered; explicit model priorities always keep their order. Health uses only process-local success, failure, timeout, and coarse latency observations and resets on restart.',
261
+ adaptiveEnabled: 'Enable adaptive routing', adaptiveWindow: 'Health window', adaptiveSamples: 'Minimum samples', adaptiveBoundary: 'Effective for the next workflow',
262
+ scheduleTitle: 'Peak / off-peak scheduling', scheduleHint: 'Restricts chosen models by local wall clock. During peak, a "block" model is skipped and the next candidate serves the job; a "warn" model is still used and flagged in the selection trace. Models not listed are unrestricted. Defaults are DeepSeek\'s published peak hours (weekdays 09:00-12:00 and 14:00-18:00).',
263
+ scheduleTimezone: 'Timezone (relative to UTC)', scheduleWindows: 'Peak windows', schedulePeak: 'Peak hours now', scheduleOffPeak: 'Off-peak now',
264
+ scheduleAddWindow: 'Add window', scheduleStart: 'Start', scheduleEnd: 'End',
265
+ scheduleBlock: 'Block', scheduleWarn: 'Warn', scheduleOff: 'Off',
266
+ scheduleRestricted: 'Restricted models', scheduleNoRestricted: 'No restricted models yet; unlisted models have no time limit.',
267
+ scheduleWeekdays: 'Days the windows apply', scheduleBoundary: 'Effective for the next dispatch',
268
+ modelActivity: 'Model activity overview', modelActivityHint: 'Aggregates the latest 500 in-memory Hub jobs with real model-activity evidence; each model is counted once per qualifying job (not an inferred turn count). Shows at most 50 models; prompts, results, and credentials are never stored here.', noModelActivity: 'No real model activity yet.',
269
+ calls: 'Jobs', invocationSource: 'Task source', routingSource: 'Routing source', lastCalled: 'Last called', role: 'Role', model: 'Model', never: '—',
270
+ col: { id: 'job', role: 'role', source: 'source', model: 'model', tier: 'tier', status: 'status', progress: 'progress', tokens: 'tokens ⇅', task: 'task' },
271
+ working: 'Working…',
272
+ tips: {
273
+ claude: {
274
+ install: 'One-click install into Claude Code: register local marketplace, claude plugin install, permission allowlist, claude-hud segment wiring; settings.json backed up first',
275
+ update: 'Re-run the installer: refresh registration & permissions, migrate legacy config, update HUD wiring; idempotent',
276
+ restore: 'Remove the integration from Claude Code: marketplace registration, plugin enablement and permission rules (settings backed up); plugin source and past jobs untouched',
277
+ },
278
+ codex: {
279
+ install: 'Copy ds-flash / ds-pro roles and /dsh-config, /dsh-status prompts into ~/.codex/ (MCP paths rendered for this machine, approve mode + timeout included)',
280
+ update: 'Re-render and overwrite role files and prompts (after path/config changes); originals backed up',
281
+ restore: 'Delete ds-flash / ds-pro roles and dsh prompts from ~/.codex/ (roles backed up first)',
282
+ },
283
+ zcode: {
284
+ install: 'Install the ZCode global policy, ds-worker / ds-reviewer agents, commands and source-aware MCP config under ~/.zcode/',
285
+ update: 'Re-render ZCode agents, commands and MCP entry; user files and unrelated MCP servers are preserved',
286
+ restore: 'Remove only dsh-crew-owned ZCode files and MCP entry (backups kept)',
287
+ },
288
+ },
289
+ },
290
+ };
291
+
292
+ const S = {
293
+ // Margins are tuned against the page container's 8px flex gap: the gap alone
294
+ // sits below each heading, the margin only adds separation above it.
295
+ section: { margin: '8px 0 -2px', fontSize: 13, fontWeight: 600 as const, opacity: 0.9 },
296
+ // Small muted group label, matching the host's Agent-preset page (内置 / 自定义).
297
+ group: { margin: '4px 0 -3px', fontSize: 12, opacity: 0.55 },
298
+ // Card with a title + description header above its fields.
299
+ block: { border: '1px solid rgba(128,128,128,0.22)', borderRadius: 10, padding: '11px 14px 13px', display: 'flex', flexDirection: 'column' as const, gap: 10 },
300
+ blockGrid: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '12px 14px', alignItems: 'end' as const },
301
+ settingTitle: { fontWeight: 600 as const, fontSize: 13 },
302
+ settingDesc: { fontSize: 11.5, opacity: 0.6, marginTop: 2, lineHeight: 1.5 },
303
+ card: { border: '1px solid rgba(128,128,128,0.22)', borderRadius: 8, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' as const },
304
+ grid: {
305
+ border: '1px solid rgba(128,128,128,0.22)', borderRadius: 8, padding: '12px 14px',
306
+ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '12px 14px', alignItems: 'end' as const,
307
+ },
308
+ btn: { padding: '4px 12px', borderRadius: 6, border: '1px solid rgba(128,128,128,0.35)', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 12.5 },
309
+ sectionShell: { border: '1px solid rgba(128,128,128,0.24)', borderRadius: 11, overflow: 'hidden', background: 'rgba(128,128,128,0.025)' },
310
+ sectionButton: { width: '100%', padding: '10px 13px', border: 0, background: 'transparent', color: 'inherit', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 9, textAlign: 'left' as const, font: 'inherit' },
311
+ sectionBody: { padding: '2px 11px 12px', display: 'flex', flexDirection: 'column' as const, gap: 8 },
312
+ chip: (on: boolean) => ({ fontSize: 11.5, padding: '1px 8px', borderRadius: 99, border: '1px solid', borderColor: on ? 'rgba(63,185,80,0.5)' : 'rgba(128,128,128,0.35)', color: on ? '#3fb950' : 'inherit', opacity: on ? 1 : 0.55 }),
313
+ field: { display: 'flex', flexDirection: 'column' as const, gap: 3 },
314
+ fieldLabel: { fontSize: 11, opacity: 0.6 },
315
+ input: { padding: '3px 8px', borderRadius: 5, border: '1px solid rgba(128,128,128,0.35)', background: 'transparent', color: 'inherit', fontSize: 12.5 },
316
+ cell: { padding: '5px 10px 5px 0', borderBottom: '1px solid rgba(128,128,128,0.16)', textAlign: 'left' as const, verticalAlign: 'top' as const },
317
+ tight: {
318
+ padding: '5px 8px 5px 0', borderBottom: '1px solid rgba(128,128,128,0.16)', textAlign: 'left' as const,
319
+ verticalAlign: 'top' as const, whiteSpace: 'nowrap' as const, overflow: 'hidden', textOverflow: 'ellipsis',
320
+ },
321
+ mono: { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12, fontVariantNumeric: 'tabular-nums' as const },
322
+ };
323
+
324
+ const VISION_MODELS: Record<string, string[]> = {
325
+ 'claude-code': ['haiku', 'sonnet', 'opus'],
326
+ codex: ['default', 'gpt-5.4-mini', 'gpt-5.6'],
327
+ grok: ['default'],
328
+ agy: ['default'],
329
+ };
330
+
331
+ function CustomSelect({ value, options, onChange, minWidth }: {
332
+ value: string; options: Array<{ value: string; label?: string }>;
333
+ onChange: (v: string) => void; minWidth?: number;
334
+ }) {
335
+ const [open, setOpen] = useState<{ left: number; top: number; bottom: number; width: number; up: boolean } | null>(null);
336
+ useEffect(() => {
337
+ if (!open) return;
338
+ const close = () => setOpen(null);
339
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
340
+ const t = setTimeout(() => document.addEventListener('mousedown', close), 0);
341
+ document.addEventListener('keydown', onKey);
342
+ return () => { clearTimeout(t); document.removeEventListener('mousedown', close); document.removeEventListener('keydown', onKey); };
343
+ }, [open]);
344
+ const current = options.find((o) => o.value === value);
345
+ return (<>
346
+ <button type="button"
347
+ style={{ ...S.input, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', minWidth: minWidth ?? 92, width: '100%', boxSizing: 'border-box' as const, justifyContent: 'space-between' }}
348
+ onClick={(e: any) => {
349
+ const r = e.currentTarget.getBoundingClientRect();
350
+ const up = window.innerHeight - r.bottom < 240;
351
+ setOpen({ left: r.left, top: r.bottom + 4, bottom: window.innerHeight - r.top + 4, width: Math.max(r.width, 130), up });
352
+ }}>
353
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label ?? value}</span>
354
+ <span style={{ opacity: 0.5, fontSize: 10 }}>▾</span>
355
+ </button>
356
+ {open && createPortal(
357
+ <div
358
+ style={{
359
+ position: 'fixed', left: open.left, ...(open.up ? { bottom: open.bottom } : { top: open.top }),
360
+ minWidth: open.width, zIndex: 9999, background: 'Canvas', color: 'CanvasText',
361
+ border: '1px solid rgba(128,128,128,0.3)', borderRadius: 8,
362
+ boxShadow: '0 8px 28px rgba(0,0,0,0.22)', padding: 4, maxHeight: 240, overflowY: 'auto',
363
+ }}
364
+ onMouseDown={(e: any) => e.stopPropagation()}>
365
+ {options.map((o) => (
366
+ <div key={o.value}
367
+ onClick={() => { onChange(o.value); setOpen(null); }}
368
+ style={{
369
+ padding: '5px 10px', borderRadius: 5, cursor: 'pointer', fontSize: 12.5, whiteSpace: 'nowrap',
370
+ background: o.value === value ? 'rgba(128,128,128,0.16)' : 'transparent',
371
+ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center',
372
+ }}
373
+ onMouseEnter={(e: any) => { e.currentTarget.style.background = 'rgba(128,128,128,0.24)'; }}
374
+ onMouseLeave={(e: any) => { e.currentTarget.style.background = o.value === value ? 'rgba(128,128,128,0.16)' : 'transparent'; }}>
375
+ <span>{o.label ?? o.value}</span>{o.value === value && <span style={{ opacity: 0.6, fontSize: 11 }}>✓</span>}
376
+ </div>
377
+ ))}
378
+ </div>, document.body)}
379
+ </>);
380
+ }
381
+
382
+ function sectionSummary(...parts: Array<string | number | false | null | undefined>) {
383
+ return parts.filter((part) => part !== false && part !== null && part !== undefined && part !== '').join(' · ');
384
+ }
385
+
386
+ function sectionStorage() {
387
+ if (typeof window === 'undefined') return null;
388
+ try { return window.localStorage; } catch { return null; }
389
+ }
390
+
391
+ function CollapsibleSection({ sectionId, title, summary, expanded, onToggle, children }: {
392
+ sectionId: string; title: string; summary: string; expanded: boolean;
393
+ onToggle: () => void; children: any;
394
+ }) {
395
+ const bodyId = `dsh-crew-section-${sectionId}`;
396
+ return (
397
+ <section style={S.sectionShell} data-section-id={sectionId}>
398
+ <button type="button" className="crew-section-trigger" style={S.sectionButton} aria-expanded={expanded} aria-controls={bodyId} onClick={onToggle}>
399
+ <span aria-hidden="true" style={{ width: 14, opacity: 0.62, transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 140ms ease' }}>›</span>
400
+ <span className="crew-section-heading">
401
+ <span className="crew-section-title">{title}</span>
402
+ <span className="crew-section-summary">{summary}</span>
403
+ </span>
404
+ </button>
405
+ <div id={bodyId} hidden={!expanded} style={expanded ? S.sectionBody : undefined}>{children}</div>
406
+ </section>
407
+ );
408
+ }
409
+
410
+ function sourceLabel(source?: string) {
411
+ if (source === 'claude-code') return 'CC';
412
+ if (source === 'codex') return 'Codex';
413
+ if (source === 'api' || source === undefined) return 'API';
414
+ return source;
415
+ }
416
+
417
+ function elapsed(startedAt: string, endedAt?: string | null) {
418
+ const s = Math.max(0, Math.round(((endedAt ? +new Date(endedAt) : Date.now()) - +new Date(startedAt)) / 1000));
419
+ return s >= 60 ? `${Math.floor(s / 60)}m${s % 60}s` : `${s}s`;
420
+ }
421
+ function ktok(n: number) { return n >= 1000 ? `${Math.round(n / 100) / 10}k` : `${n}`; }
422
+
423
+ function formatTimestamp(value: string | null, locale: string) {
424
+ if (!value || !Number.isFinite(Date.parse(value))) return '—';
425
+ return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en', {
426
+ month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
427
+ }).format(new Date(value));
428
+ }
429
+
430
+ function readinessChip(state: string) {
431
+ const color = state === READINESS_STATES.READY ? '#3fb950'
432
+ : state === READINESS_STATES.DEGRADED ? '#c98735'
433
+ : state === READINESS_STATES.UNAVAILABLE ? '#f85149'
434
+ : state === READINESS_STATES.NOT_APPLICABLE ? '#7d8590' : 'inherit';
435
+ return {
436
+ fontSize: 10.5, fontWeight: 650, padding: '1px 7px', borderRadius: 99,
437
+ border: `1px solid ${state === READINESS_STATES.UNKNOWN ? 'rgba(128,128,128,0.35)' : color}`,
438
+ color, opacity: state === READINESS_STATES.UNKNOWN ? 0.58 : 1,
439
+ };
440
+ }
441
+
442
+ function MinimalCrewPanel({ locale, surface, runtime }: { locale: string; surface: string; runtime: any }) {
443
+ const zh = locale === 'zh';
444
+ const native = surface === CREW_UI_SURFACES.NATIVE;
445
+ const title = native ? (zh ? 'DSH Crew Runtime' : 'DSH Crew Runtime') : (zh ? 'DSH Crew Surface 未确认' : 'DSH Crew surface not verified');
446
+ const hint = native
447
+ ? (zh
448
+ ? '这里是 DeepSeek 官方 Harness 的专用 dsh-crew profile,DSH Crew 作为插件加载其中。请使用 Harness 原生菜单管理 Provider、Harness Models、Agent 预设与底层配置。'
449
+ : 'This is the dedicated dsh-crew profile of the official DeepSeek Harness, with DSH Crew loaded as a plugin. Use the native Harness menus for Providers, Harness Models, Agent presets, and low-level settings.')
450
+ : (zh
451
+ ? '无法用结构化后端证据确认当前界面,因此已保守隐藏 Crew 编排、任务与宿主集成功能。'
452
+ : 'Structured backend evidence could not identify this surface, so Crew orchestration, jobs, and host integrations are conservatively hidden.');
453
+ const runtimeReady = runtime?.ok === true && runtime?.service === 'dsh-crew-hub';
454
+ return (
455
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10, fontSize: 13, lineHeight: 1.55 }}>
456
+ <div style={{ ...S.block, background: 'linear-gradient(135deg, rgba(74,158,255,0.08), rgba(128,128,128,0.02))' }}>
457
+ <div>
458
+ <div style={{ fontSize: 19, fontWeight: 680 }}>{title}</div>
459
+ <div style={{ fontSize: 12, opacity: 0.68, marginTop: 3 }}>{hint}</div>
460
+ </div>
461
+ <div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
462
+ <span style={readinessChip(runtimeReady ? READINESS_STATES.READY : READINESS_STATES.UNKNOWN)}>
463
+ {runtimeReady ? `Runtime ${runtime.runtime_version ?? 'READY'}` : 'Runtime UNKNOWN'}
464
+ </span>
465
+ <span style={readinessChip(native ? READINESS_STATES.READY : READINESS_STATES.UNKNOWN)}>
466
+ {native ? '3210 · official profile + Crew plugin' : 'surface · unknown'}
467
+ </span>
468
+ </div>
469
+ <a href={CREW_CONTROL_PLANE_URL} target="_blank" rel="noopener noreferrer" style={{
470
+ ...S.btn, alignSelf: 'flex-start', textDecoration: 'none', fontWeight: 650, padding: '7px 12px',
471
+ borderColor: 'rgba(74,158,255,0.55)', background: 'rgba(74,158,255,0.08)',
472
+ }}>{zh ? '打开官方 Harness 界面(3080) →' : 'Open official Harness (3080) →'}</a>
473
+ </div>
474
+ </div>
475
+ );
476
+ }
477
+
478
+ function WorkersPanel({ ctx }: { ctx: any }) {
479
+ const locale = useSyncExternalStore(
480
+ (notify: () => void) => ctx.on('locale/change', notify),
481
+ () => ctx.locale.getLocale().active,
482
+ () => ctx.locale.getLocale().active,
483
+ );
484
+ const copy = COPY[locale === 'zh' ? 'zh' : 'en'];
485
+
486
+ const [jobs, setJobs] = useState<any[]>([]);
487
+ const [hoverTask, setHoverTask] = useState<{ id: string; text: string; right: number; top: number; bottom: number; flip: boolean } | null>(null);
488
+ const [status, setStatus] = useState<any>(null);
489
+ const [config, setConfig] = useState<any>(null);
490
+ const [dynModels, setDynModels] = useState<Record<string, Array<{ value: string; label?: string }>>>({});
491
+ const [presetOptions, setPresetOptions] = useState<Array<{ value: string; label?: string }>>([{ value: 'default' }]);
492
+ const [notice, setNotice] = useState('');
493
+ const [busy, setBusy] = useState(false);
494
+ const [modelAdd, setModelAdd] = useState<string | null>(null);
495
+ const [modelCatalog, setModelCatalog] = useState<any>(null);
496
+ const [modelCatalogError, setModelCatalogError] = useState('');
497
+ const [modelCatalogBusy, setModelCatalogBusy] = useState(false);
498
+ const [providerInventory, setProviderInventory] = useState<any>(null);
499
+ const [providerHealth, setProviderHealth] = useState<any[]>([]);
500
+ const [credentialRefs, setCredentialRefs] = useState<any[]>([]);
501
+ const [credentialUnverified, setCredentialUnverified] = useState<any[]>([]);
502
+ const [credentialLifecycleBusy, setCredentialLifecycleBusy] = useState<string | null>(null);
503
+ const [providerInventoryError, setProviderInventoryError] = useState('');
504
+ const [providerLifecycleBusy, setProviderLifecycleBusy] = useState<string | null>(null);
505
+ const [modelPickerTier, setModelPickerTier] = useState<'flash' | 'pro' | null>(null);
506
+ const [modelQuery, setModelQuery] = useState('');
507
+ const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>(() => readSectionState(sectionStorage()));
508
+ const [surface, setSurface] = useState<string>('detecting');
509
+ const [readinessEnvelope, setReadinessEnvelope] = useState<{ runtime?: any; snapshot?: any; expiresAt?: number | null }>({});
510
+ const readinessGeneration = useRef(0);
511
+ const runtimeInfo = readinessEnvelope.runtime;
512
+ const readinessSnapshot = readinessEnvelope.snapshot;
513
+
514
+ useEffect(() => {
515
+ const delay = readinessExpiryDelay(readinessEnvelope.expiresAt);
516
+ if (delay === null) return undefined;
517
+ const expected = readinessEnvelope;
518
+ const timer = setTimeout(() => setReadinessEnvelope((current) => current === expected ? {} : current), delay);
519
+ return () => clearTimeout(timer);
520
+ }, [readinessEnvelope]);
521
+
522
+ const toggleSection = (sectionId: string) => {
523
+ setExpandedSections((current) => ({ ...current, [sectionId]: !current[sectionId] }));
524
+ };
525
+ const toggleAllSections = (expanded: boolean) => setExpandedSections(setEverySection(expanded));
526
+
527
+ useEffect(() => {
528
+ writeSectionState(sectionStorage(), expandedSections);
529
+ }, [expandedSections]);
530
+
531
+ useEffect(() => {
532
+ if (modelCatalogError) setExpandedSections((current) => openSections(current, ['workflow', 'flash', 'pro']));
533
+ }, [modelCatalogError]);
534
+
535
+ useEffect(() => {
536
+ if (providerInventoryError) setExpandedSections((current) => openSections(current, ['harnessProviders']));
537
+ }, [providerInventoryError]);
538
+ useEffect(() => {
539
+ if (jobs.some((job) => job.status === 'running')) {
540
+ setExpandedSections((current) => openSections(current, ['jobs']));
541
+ }
542
+ }, [jobs]);
543
+
544
+ /**
545
+ * The panel is served from disk on every load, but the hub's routes are
546
+ * registered at instance boot — after an upgrade the new UI can call a route
547
+ * the running instance does not have yet, which answers 404/405 with an empty
548
+ * body. Report that as "restart DSH" instead of a JSON parse error.
549
+ */
550
+ const readJson = useCallback(async (res: Response, path: string) => {
551
+ const text = await res.text();
552
+ if (text === '') {
553
+ throw new Error(res.status === 404 || res.status === 405
554
+ ? `${copy.staleHub}(${path} → HTTP ${res.status})`
555
+ : copy.emptyResponse(path, res.status));
556
+ }
557
+ try { return JSON.parse(text); } catch { throw new Error(copy.badJson(path, text.slice(0, 160))); }
558
+ }, [copy]);
559
+ /** Every request carries the active locale: the panel is the authority on it
560
+ * (DSH's setting may be unset), and the hub localizes its own strings from it. */
561
+ const withLang = useCallback((path: string) => `${API}${path}${path.includes('?') ? '&' : '?'}lang=${locale === 'zh' ? 'zh' : 'en'}`, [locale]);
562
+ const get = useCallback(async (path: string, timeoutMs = 8_000) => readJson(await fetch(withLang(path), { cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) }), path), [readJson, withLang]);
563
+ const post = useCallback(async (path: string, body: any) => readJson(await fetch(withLang(path), {
564
+ method: 'POST', headers: { 'content-type': 'application/json' },
565
+ body: JSON.stringify({ ...body, lang: locale === 'zh' ? 'zh' : 'en' }),
566
+ }), path), [readJson, withLang, locale]);
567
+
568
+ const detectSurface = useCallback(async () => {
569
+ const readOptional = async (path: string) => {
570
+ try {
571
+ const response = await fetch(`${API}${path}`, { cache: 'no-store' });
572
+ if (!response.ok) return null;
573
+ const body = await response.json();
574
+ return body && typeof body === 'object' ? body : null;
575
+ } catch { return null; }
576
+ };
577
+ const [bridgeStatus, runtime] = await Promise.all([
578
+ readOptional('/bridge-status'),
579
+ readOptional('/runtime'),
580
+ ]);
581
+ setSurface(classifyCrewSurface({ bridgeStatus, runtime }));
582
+ }, []);
583
+
584
+ useEffect(() => { void detectSurface(); }, [detectSurface]);
585
+
586
+ const refreshAll = useCallback(async () => {
587
+ const generation = ++readinessGeneration.current;
588
+ setReadinessEnvelope({});
589
+ try {
590
+ const [j, s, c, pr, pi, ph, cr] = await Promise.all([
591
+ get('/jobs'), get('/install/status'), get('/config'), get('/presets'),
592
+ get('/providers').catch(() => null), get('/provider-health').catch(() => null), get('/credential-references').catch(() => null),
593
+ ]);
594
+ if (j.ok) setJobs(j.jobs ?? []);
595
+ if (s.ok) setStatus(s.status);
596
+ if (c.ok) setConfig((prev: any) => prev ?? c.config);
597
+ if (pr.ok) setPresetOptions([
598
+ { value: 'default', label: copy.presetDefault(pr.defaultId) },
599
+ ...(pr.presets ?? []).map((x: any) => ({ value: x.id, label: x.name ?? x.id })),
600
+ ]);
601
+ if (pi?.ok) setProviderInventory(pi);
602
+ if (ph?.ok) setProviderHealth(ph.health ?? []);
603
+ if (cr?.ok) { setCredentialRefs(cr.records ?? []); setCredentialUnverified(cr.unverified_purges ?? []); }
604
+ } catch {
605
+ if (generation === readinessGeneration.current) setReadinessEnvelope({});
606
+ }
607
+ }, [get]);
608
+
609
+ useEffect(() => {
610
+ const responsibilities = surfaceResponsibilities(surface);
611
+ if (!responsibilities.fullControlPlane) return undefined;
612
+ void refreshAll();
613
+ let cancelled = false;
614
+ let timer: ReturnType<typeof setTimeout> | undefined;
615
+ const poll = async () => {
616
+ const generation = ++readinessGeneration.current;
617
+ try {
618
+ const ext = await get('/extension', 5_000);
619
+ if (!cancelled && generation === readinessGeneration.current) {
620
+ const accepted = acceptReadinessResponse(ext, { generation, latestGeneration: readinessGeneration.current });
621
+ if (accepted.accepted) setReadinessEnvelope(accepted.envelope);
622
+ }
623
+ } catch {
624
+ if (!cancelled && generation === readinessGeneration.current) setReadinessEnvelope({});
625
+ }
626
+ void Promise.all([get('/jobs', 5_000).catch(() => null), get('/provider-health', 5_000).catch(() => null)]).then(([j, health]) => {
627
+ if (cancelled) return;
628
+ if (j?.ok) setJobs(j.jobs ?? []);
629
+ if (health?.ok) setProviderHealth(health.health ?? []);
630
+ }).catch(() => {});
631
+ if (!cancelled) timer = setTimeout(() => { void poll(); }, 3_000);
632
+ };
633
+ void poll();
634
+ return () => { cancelled = true; readinessGeneration.current += 1; if (timer) clearTimeout(timer); };
635
+ }, [surface, refreshAll, get]);
636
+
637
+ const refreshHarnessModels = useCallback(async () => {
638
+ setModelCatalogBusy(true);
639
+ setModelCatalogError('');
640
+ try {
641
+ const result = await get('/models');
642
+ if (!result.ok) throw new Error(result.error ?? copy.catalogFailed);
643
+ setModelCatalog(result);
644
+ } catch (error: any) {
645
+ setModelCatalogError(error?.message ?? copy.catalogFailed);
646
+ } finally {
647
+ setModelCatalogBusy(false);
648
+ }
649
+ }, [get, copy]);
650
+
651
+ const refreshProviderInventory = useCallback(async () => {
652
+ setProviderInventoryError('');
653
+ try {
654
+ const [result, health, refs] = await Promise.all([get('/providers'), get('/provider-health').catch(() => null), get('/credential-references').catch(() => null)]);
655
+ if (!result.ok) throw new Error(result.error ?? copy.providerNoInventory);
656
+ setProviderInventory(result);
657
+ if (health?.ok) setProviderHealth(health.health ?? []);
658
+ if (refs?.ok) { setCredentialRefs(refs.records ?? []); setCredentialUnverified(refs.unverified_purges ?? []); }
659
+ } catch (error: any) {
660
+ setProviderInventoryError(error?.message ?? copy.providerNoInventory);
661
+ }
662
+ }, [get, copy]);
663
+
664
+ const purgeCredentialRef = useCallback(async (ref: any) => {
665
+ if (ref?.ownership !== 'crew-owned' || ref?.orphan !== true || ref?.purge_capability !== 'eligible') return;
666
+ const referenceId = String(ref.reference_id ?? '');
667
+ if (!referenceId || !window.confirm(copy.credentialPurgeConfirm)) return;
668
+ setCredentialLifecycleBusy(referenceId);
669
+ setNotice(copy.working);
670
+ try {
671
+ const encoded = encodeURIComponent(referenceId);
672
+ const planned = await post(`/credential-references/${encoded}/purge-plan`, {});
673
+ if (!planned.ok || !planned.plan) throw new Error(planned.code ?? copy.credentialPurgeUnavailable);
674
+ if (!window.confirm(`${copy.credentialPurgeConfirm}\n\n${referenceId}`)) return;
675
+ const result = await readJson(await fetch(withLang(`/credential-references/${encoded}`), {
676
+ method: 'DELETE', headers: { 'content-type': 'application/json' },
677
+ body: JSON.stringify({ plan_id: planned.plan.plan_id, expected_revision: planned.plan.expected_revision, confirm: true, lang: locale === 'zh' ? 'zh' : 'en' }),
678
+ }), `/credential-references/${encoded}`);
679
+ if (!result.ok || result.state !== 'VERIFIED') throw new Error(result.code ?? copy.credentialPurgeUnavailable);
680
+ setNotice(copy.saved);
681
+ await refreshProviderInventory();
682
+ } catch (error: any) {
683
+ setNotice(String(error?.message ?? error));
684
+ } finally {
685
+ setCredentialLifecycleBusy(null);
686
+ }
687
+ }, [copy, locale, post, readJson, refreshProviderInventory, withLang]);
688
+
689
+ useEffect(() => {
690
+ if (surfaceResponsibilities(surface).fullControlPlane) void refreshHarnessModels();
691
+ }, [surface, refreshHarnessModels]);
692
+
693
+ useEffect(() => {
694
+ if (surfaceResponsibilities(surface).fullControlPlane) void refreshProviderInventory();
695
+ }, [surface, refreshProviderInventory]);
696
+
697
+ const act = useCallback(async (target: string, confirmName?: string) => {
698
+ if (confirmName && !window.confirm(copy.confirmRestore(confirmName))) return;
699
+ setBusy(true); setNotice(copy.working);
700
+ try {
701
+ const r = await post('/install', { target });
702
+ if (r.ok === false) throw new Error(r.error);
703
+ setNotice((r.actions ?? []).join(';'));
704
+ const s = await get('/install/status'); if (s.ok) setStatus(s.status);
705
+ } catch (e: any) { setNotice(String(e?.message ?? e)); } finally { setBusy(false); }
706
+ }, [post, get, copy]);
707
+
708
+ const applyPatch = useCallback(async (patch: Record<string, any>) => {
709
+ try {
710
+ const result = await post('/config', patch);
711
+ if (!result.ok) throw new Error(result.error ?? 'Configuration save failed');
712
+ setConfig(result.config);
713
+ setNotice(copy.saved);
714
+ setTimeout(() => setNotice(''), 1500);
715
+ return true;
716
+ } catch (error: any) {
717
+ setNotice(String(error?.message ?? error));
718
+ try {
719
+ const authoritative = await get('/config');
720
+ if (authoritative.ok) setConfig(authoritative.config);
721
+ } catch { /* keep the error visible while the Hub is unavailable */ }
722
+ return false;
723
+ }
724
+ }, [post, get, copy]);
725
+
726
+ /** Request a Crew 3210 restart through the durable supervisor channel
727
+ * (hub writes the request; the Windows watcher executes it). Polls until
728
+ * VERIFIED or a terminal failure. Same-origin against the 3210 API — the
729
+ * legacy 3080 supervisor endpoint is retired. */
730
+ const requestRestart = useCallback(async (reason?: string): Promise<any> => {
731
+ const created = await post('/runtime/restart-request', { confirm: true, reason: reason ?? 'configuration change' });
732
+ if (!created.ok) throw new Error(created.code ?? created.error ?? copy.providerLifecycleError);
733
+ const requestId = created.request_id;
734
+ const deadline = Date.now() + 90_000;
735
+ for (;;) {
736
+ await new Promise((resolve) => setTimeout(resolve, 1000));
737
+ if (Date.now() > deadline) throw new Error('Crew restart timed out');
738
+ const status = await get(`/runtime/restart-status?id=${encodeURIComponent(requestId)}`);
739
+ if (!status.ok) continue; // 404 while the watcher has not picked it up
740
+ if (status.state === 'VERIFIED') return { ok: true, ...status };
741
+ if (status.state !== 'RESTART_REQUESTED') throw new Error(status.state ?? 'Crew restart failed');
742
+ }
743
+ }, [post, get, copy]);
744
+ /** Selects & checkboxes: apply immediately. */
745
+ const field = (key: string, value: any) => {
746
+ setConfig((c: any) => ({ ...c, [key]: value }));
747
+ void applyPatch({ [key]: value });
748
+ };
749
+ /** Text/number inputs: track locally, apply on blur. */
750
+ const fieldLocal = (key: string, value: any) => setConfig((c: any) => ({ ...c, [key]: value }));
751
+ const rolePolicyField = (role: 'worker' | 'review', patch: Record<string, any>) => {
752
+ const effectivePatch = role === 'worker' && (patch.ordering === 'manual' || patch.ordering === 'health-aware')
753
+ ? { ...patch, adaptive: { ...normalizeAdaptive(config?.worker?.model_policy?.adaptive), enabled: patch.ordering === 'health-aware' } }
754
+ : patch;
755
+ setConfig((current: any) => ({
756
+ ...current,
757
+ [role]: {
758
+ ...current?.[role],
759
+ model_policy: { ...current?.[role]?.model_policy, ...effectivePatch },
760
+ },
761
+ }));
762
+ void applyPatch({ [role]: { model_policy: effectivePatch } });
763
+ };
764
+ const reviewField = (patch: Record<string, any>) => {
765
+ setConfig((current: any) => ({ ...current, review: { ...current?.review, ...patch } }));
766
+ void applyPatch({ review: patch });
767
+ };
768
+
769
+ // --- custom providers & user-added models -------------------------------
770
+
771
+ const deleteHarnessProvider = async (record: any) => {
772
+ if (!record || record.delete_capability !== 'supported' || record.desired_state === 'absent' || Number(record.references?.active_jobs ?? 0) > 0) {
773
+ setNotice(copy.providerDeleteBlocked);
774
+ return;
775
+ }
776
+ let replacementDefault: string | undefined;
777
+ if (record.references?.harness_default === true) {
778
+ const candidate = (providerInventory?.records ?? []).find((item: any) => item.id === 'deepseek-official'
779
+ && item.id !== record.id && item.desired_state !== 'absent' && (item.models?.length ?? 0) > 0)?.id
780
+ ?? (providerInventory?.records ?? []).find((item: any) => item.id !== record.id
781
+ && item.desired_state !== 'absent' && item.delete_capability !== 'source-unresolved' && (item.models?.length ?? 0) > 0)?.id ?? '';
782
+ const entered = window.prompt(copy.providerReplacement, candidate);
783
+ if (!entered?.trim()) return;
784
+ replacementDefault = entered.trim();
785
+ }
786
+ setProviderLifecycleBusy(record.id);
787
+ setNotice(copy.working);
788
+ try {
789
+ const encoded = encodeURIComponent(record.id);
790
+ const planned = await post(`/providers/${encoded}/delete-plan`, replacementDefault ? { replacement_default: replacementDefault } : {});
791
+ if (!planned.ok || !planned.plan) throw new Error(planned.code ?? copy.providerLifecycleError);
792
+ if (!window.confirm(`${copy.providerPlanReady}\n\n${copy.providerDeleteConfirm}\n${(planned.plan.will_remove ?? []).join('\n')}`)) return;
793
+ const path = `/providers/${encoded}`;
794
+ const result = await readJson(await fetch(withLang(path), {
795
+ method: 'DELETE', headers: { 'content-type': 'application/json' },
796
+ body: JSON.stringify({ plan_id: planned.plan.plan_id, expected_revision: planned.plan.expected_revision, confirm: true, lang: locale === 'zh' ? 'zh' : 'en' }),
797
+ }), path);
798
+ if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
799
+ if (result.restart_required === true && result.result?.state === 'RESTART_PENDING') {
800
+ await requestRestart('provider delete');
801
+ const verified = await post(`/providers/${encoded}/verify-delete`, { transaction_id: planned.plan.plan_id, confirm: true });
802
+ if (!verified.ok || verified.state !== 'VERIFIED') throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
803
+ }
804
+ setNotice(copy.saved);
805
+ await refreshProviderInventory();
806
+ } catch (error: any) {
807
+ try { await refreshProviderInventory(); } catch { /* preserve the original lifecycle error */ }
808
+ setNotice(`${String(error?.message ?? error)} · ${copy.providerDeletePending}`);
809
+ } finally {
810
+ setProviderLifecycleBusy(null);
811
+ }
812
+ };
813
+
814
+ const migrateHarnessProvider = async (entry: any) => {
815
+ if (!entry || !['promote-existing-user', 'materialize-user'].includes(entry.action)) {
816
+ setNotice(entry?.blocked_reason ?? copy.providerLayerMigrationHint);
817
+ return;
818
+ }
819
+ if (!window.confirm(copy.providerLayerMigrationConfirm)) return;
820
+ const id = String(entry.provider_id ?? '');
821
+ if (!id) return;
822
+ setProviderLifecycleBusy(`migrate:${id}`);
823
+ setNotice(copy.providerLayerMigrating);
824
+ try {
825
+ const encoded = encodeURIComponent(id);
826
+ const planned = await post(`/providers/${encoded}/migrate-plan`, {});
827
+ if (!planned.ok || !planned.plan) throw new Error(planned.code ?? copy.providerLifecycleError);
828
+ if (!window.confirm(`${copy.providerLayerMigrationConfirm}\n\n${id}`)) return;
829
+ const result = await post(`/providers/${encoded}/migrate`, { plan_id: planned.plan.plan_id, confirm: true });
830
+ if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
831
+ if (result.restart_required === true && result.result?.state === 'RESTART_PENDING') {
832
+ await requestRestart('provider migrate');
833
+ const verified = await post(`/providers/${encoded}/verify-migration`, { transaction_id: planned.plan.plan_id, confirm: true });
834
+ if (!verified.ok || verified.state !== 'VERIFIED') throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
835
+ }
836
+ setNotice(copy.saved);
837
+ await refreshProviderInventory();
838
+ } catch (error: any) {
839
+ try { await refreshProviderInventory(); } catch { /* preserve the original migration error */ }
840
+ setNotice(String(error?.message ?? error));
841
+ } finally {
842
+ setProviderLifecycleBusy(null);
843
+ }
844
+ };
845
+
846
+ const rollbackHarnessMigration = async (record: any, transactionId: string) => {
847
+ if (!record?.id || !transactionId || !window.confirm(copy.providerRollbackConfirm)) return;
848
+ setProviderLifecycleBusy(`rollback-migrate:${record.id}`);
849
+ setNotice(copy.working);
850
+ try {
851
+ const encoded = encodeURIComponent(record.id);
852
+ const result = await post(`/providers/${encoded}/rollback-migration`, { transaction_id: transactionId, confirm: true });
853
+ if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
854
+ if (result.restart_required === true) {
855
+ await requestRestart('provider migrate');
856
+ }
857
+ setNotice(copy.saved);
858
+ await refreshProviderInventory();
859
+ } catch (error: any) {
860
+ setNotice(String(error?.message ?? error));
861
+ try { await refreshProviderInventory(); } catch { /* preserve the original rollback error */ }
862
+ } finally { setProviderLifecycleBusy(null); }
863
+ };
864
+
865
+ const rollbackHarnessProvider = async (record: any, transactionId: string, transactionState: string = 'VERIFIED') => {
866
+ const continuing = ['ROLLBACK_PENDING', 'ROLLBACK_RESTART_PENDING'].includes(transactionState);
867
+ const recoverable = ['PLANNED', 'TOMBSTONE_APPLYING', 'TOMBSTONE_APPLIED', 'REFERENCES_APPLYING', 'REFERENCES_APPLIED', 'DECLARATIONS_APPLYING', 'DECLARATIONS_APPLIED', 'DELETE_RESTART_PENDING', 'ROLLBACK_APPLYING', 'ROLLBACK_RESTORED'].includes(transactionState);
868
+ if (!record || !transactionId || (!continuing && !recoverable && record.desired_state !== 'absent')) {
869
+ setNotice(copy.providerRollbackUnavailable);
870
+ return;
871
+ }
872
+ if (!window.confirm(copy.providerRollbackConfirm)) return;
873
+ setProviderLifecycleBusy(`rollback:${record.id}`);
874
+ setNotice(copy.working);
875
+ try {
876
+ const encoded = encodeURIComponent(record.id);
877
+ const result = continuing
878
+ ? { ok: true, restart_required: true, state: 'ROLLBACK_PENDING' }
879
+ : await post(`/providers/${encoded}/rollback`, { transaction_id: transactionId, confirm: true });
880
+ if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
881
+ if (result.restart_required === true && result.state === 'ROLLBACK_PENDING') {
882
+ await requestRestart('provider migrate');
883
+ const verified = await post(`/providers/${encoded}/verify-rollback`, { transaction_id: transactionId, confirm: true });
884
+ if (!verified.ok || verified.state !== 'ROLLED_BACK') throw new Error(verified.code ?? verified.error ?? copy.providerLifecycleError);
885
+ } else if (result.state !== 'ROLLED_BACK') {
886
+ throw new Error(result.code ?? copy.providerLifecycleError);
887
+ }
888
+ setNotice(copy.saved);
889
+ await refreshProviderInventory();
890
+ } catch (error: any) {
891
+ try { await refreshProviderInventory(); } catch { /* preserve the original lifecycle error */ }
892
+ setNotice(`${String(error?.message ?? error)} · ${copy.providerDeletePending}`);
893
+ } finally {
894
+ setProviderLifecycleBusy(null);
895
+ }
896
+ };
897
+
898
+ const quarantineRecoveryTransaction = async (entry: any) => {
899
+ const actionId = typeof entry?.action_id === 'string' ? entry.action_id : '';
900
+ if (!actionId || entry?.recoverable === true) return;
901
+ if (!window.confirm(copy.recoveryQuarantineConfirm)) return;
902
+ setProviderLifecycleBusy(`quarantine:${actionId}`);
903
+ setNotice(copy.working);
904
+ try {
905
+ const result = await post('/providers/_recovery/quarantine', { action_id: actionId, confirm: true });
906
+ if (!result.ok) throw new Error(result.code ?? result.error ?? copy.providerLifecycleError);
907
+ setNotice(copy.saved);
908
+ await refreshProviderInventory();
909
+ } catch (error: any) {
910
+ try { await refreshProviderInventory(); } catch { /* preserve the original recovery error */ }
911
+ setNotice(String(error?.message ?? error));
912
+ } finally {
913
+ setProviderLifecycleBusy(null);
914
+ }
915
+ };
916
+
917
+ /** Card: title + one-line description header, then its fields. */
918
+ const block = (text: { t: string; d: string }, fields: any, gridded = true) => (
919
+ <div style={S.block}>
920
+ <div>
921
+ <div style={S.settingTitle}>{text.t}</div>
922
+ <div style={S.settingDesc}>{text.d}</div>
923
+ </div>
924
+ {gridded ? <div style={S.blockGrid}>{fields}</div> : fields}
925
+ </div>
926
+ );
927
+
928
+ const integrationRow = (name: string, installed: boolean, ready: boolean, extra: any, installTarget: string, uninstallTarget: string, tips: any) => (
929
+ <div style={S.card}>
930
+ <span style={{ fontWeight: 600, fontSize: 13, minWidth: 92 }}>{name}</span>
931
+ <span style={S.chip(installed)}>{installed ? `● ${copy.installed}` : `○ ${copy.notInstalled}`}</span>
932
+ <span style={S.chip(ready)}>{ready ? `● ${copy.integrationReady}` : `○ ${copy.notReady}`}</span>
933
+ {extra}
934
+ <span style={{ flex: 1 }} />
935
+ {!installed && <button style={S.btn} title={tips.install} disabled={busy} onClick={() => { void act(installTarget); }}>{copy.install}</button>}
936
+ {installed && <button style={S.btn} title={tips.update} disabled={busy} onClick={() => { void act(installTarget); }}>{copy.update}</button>}
937
+ {installed && <button style={{ ...S.btn, opacity: 0.75 }} title={tips.restore} disabled={busy} onClick={() => { void act(uninstallTarget, name); }}>{copy.restore}</button>}
938
+ </div>
939
+ );
940
+
941
+ const adaptive = normalizeAdaptive(config?.worker?.model_policy?.adaptive);
942
+ const setAdaptiveLocal = (candidate: AdaptiveConfig) => {
943
+ const next = normalizeAdaptive(candidate);
944
+ const ordering = next.enabled ? 'health-aware' : 'manual';
945
+ setConfig((current: any) => ({
946
+ ...current,
947
+ worker: {
948
+ ...current.worker,
949
+ model_policy: { ...current.worker?.model_policy, adaptive: next, ordering },
950
+ },
951
+ }));
952
+ return { next, ordering };
953
+ };
954
+ const saveAdaptive = (candidate: AdaptiveConfig) => {
955
+ const { next, ordering } = setAdaptiveLocal(candidate);
956
+ void applyPatch({ worker: { model_policy: { adaptive: next, ordering } } });
957
+ };
958
+ // ---- per-model peak/off-peak scheduling ----
959
+ // The schedule replaces as a whole (windows and model modes only mean anything
960
+ // together), so every edit saves the complete object.
961
+ const schedule = normalizeModelSchedule(config?.model_schedule);
962
+ const saveSchedule = (candidate: any) => {
963
+ const next = normalizeModelSchedule(candidate);
964
+ setConfig((current: any) => ({ ...current, model_schedule: next }));
965
+ void applyPatch({ model_schedule: next });
966
+ };
967
+ const schedulePeakNow = isPeakAt(schedule);
968
+ const scheduleModeOf = (ref: any) => schedule.models.find(
969
+ (entry: any) => entry.provider === ref.provider && entry.model === ref.model,
970
+ )?.mode ?? 'off';
971
+ const setScheduleMode = (ref: any, mode: string) => {
972
+ const others = schedule.models.filter(
973
+ (entry: any) => !(entry.provider === ref.provider && entry.model === ref.model),
974
+ );
975
+ saveSchedule({ ...schedule, models: mode === 'off' ? others : [...others, { ...ref, mode }] });
976
+ };
977
+ // Every model the router can actually select, not just what the flat tier
978
+ // mirrors happen to expose. The Pro mirror collapses worker-escalation and
979
+ // reviewer priority into one list — it shows whichever is set and hides the
980
+ // other — so a reviewer-only model would be routable yet impossible to
981
+ // restrict. Harness Default and the catalog are included for the same reason:
982
+ // routing can land on a model that is in no priority list at all.
983
+ const scheduleModelRefs = (() => {
984
+ const seen = new Map<string, any>();
985
+ const add = (ref: any) => {
986
+ if (ref?.provider && ref?.model) seen.set(`${ref.provider}\0${ref.model}`, { provider: ref.provider, model: ref.model });
987
+ };
988
+ for (const ref of config?.flash_model_priority ?? []) add(ref);
989
+ for (const ref of config?.pro_model_priority ?? []) add(ref);
990
+ for (const role of ['worker', 'review'] as const) {
991
+ const policy = config?.[role]?.model_policy;
992
+ for (const ref of policy?.priority ?? []) add(ref);
993
+ for (const ref of policy?.escalation_priority ?? []) add(ref);
994
+ }
995
+ add(modelCatalog?.harness_default);
996
+ for (const provider of modelCatalog?.providers ?? []) {
997
+ for (const model of provider?.models ?? []) add({ provider: provider.id, model: model?.id });
998
+ }
999
+ for (const entry of schedule.models) add(entry);
1000
+ return [...seen.values()];
1001
+ })();
1002
+ const scheduleWeekdayNames = locale === 'zh'
1003
+ ? ['日', '一', '二', '三', '四', '五', '六']
1004
+ : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1005
+ const toggleScheduleWeekday = (day: number) => {
1006
+ const days = schedule.weekdays.includes(day)
1007
+ ? schedule.weekdays.filter((value: number) => value !== day)
1008
+ : [...schedule.weekdays, day].sort((a: number, b: number) => a - b);
1009
+ saveSchedule({ ...schedule, weekdays: days });
1010
+ };
1011
+ const modelActivity = aggregateModelInvocations(jobs);
1012
+ const modelState = modelCallabilityState(readinessSnapshot?.model_callability, runtimeInfo);
1013
+ const currentSurfaceResponsibilities = surfaceResponsibilities(surface);
1014
+ const hostReadiness = projectHostReadiness({ installStatus: status, runtime: runtimeInfo, surface, readinessSnapshot });
1015
+
1016
+ if (!currentSurfaceResponsibilities.fullControlPlane) {
1017
+ return <MinimalCrewPanel locale={locale} surface={surface} runtime={runtimeInfo} />;
1018
+ }
1019
+
1020
+ return (
1021
+ <div className="dsh-crew-ui" style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13, lineHeight: 1.55 }}>
1022
+ <PanelStyles />
1023
+ <PanelHeader title={copy.title} eyebrow={copy.surfaceLabel} description={copy.intro} href={CREW_CONTROL_PLANE_URL} linkText={copy.openOfficial}>
1024
+ <span style={S.chip(status?.codex?.installed === true)}>{`Codex ${status?.codex?.installed === true ? copy.installed : copy.notInstalled}`}</span>
1025
+ <span style={S.chip(status?.claude?.installed === true)}>{`Claude ${status?.claude?.installed === true ? copy.installed : copy.notInstalled}`}</span>
1026
+ <span style={S.chip(status?.zcode?.installed === true)}>{`ZCode ${status?.zcode?.installed === true ? copy.installed : copy.notInstalled}`}</span>
1027
+ <span style={readinessChip(modelState)}>{modelState === READINESS_STATES.READY ? copy.modelCallable : modelState === READINESS_STATES.UNKNOWN ? copy.modelUnknown : copy.modelNotCallable}</span>
1028
+ <span style={S.chip(jobs.some((job) => job.status === 'running'))}>{copy.runningCount(jobs.filter((job) => job.status === 'running').length)}</span>
1029
+ </PanelHeader>
1030
+ <HistoryPanel locale={locale} />
1031
+
1032
+ <div className="crew-config-toolbar">
1033
+ <div style={{ ...S.section, margin: 0, flex: 1 }}>{copy.globalConfig}</div>
1034
+ <button type="button" style={{ ...S.btn, padding: '3px 9px' }} onClick={() => toggleAllSections(true)}>{copy.expandAll}</button>
1035
+ <button type="button" style={{ ...S.btn, padding: '3px 9px' }} onClick={() => toggleAllSections(false)}>{copy.collapseAll}</button>
1036
+ </div>
1037
+
1038
+ <CollapsibleSection sectionId="integrations" title={copy.sectionNames.integrations}
1039
+ summary={sectionSummary(...['Codex', 'Claude', 'ZCode'].map((name) => `${name} ${status?.[name.toLowerCase()]?.installed ? copy.installed : copy.notInstalled}`), modelState === READINESS_STATES.READY ? copy.modelCallable : copy.modelNotCallable)}
1040
+ expanded={!!expandedSections.integrations} onToggle={() => toggleSection('integrations')}>
1041
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
1042
+ <div style={S.block}>
1043
+ <div>
1044
+ <div style={S.settingTitle}>{copy.hostReadiness}</div>
1045
+ <div style={S.settingDesc}>{copy.hostReadinessHint}</div>
1046
+ </div>
1047
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '6px 10px' }}>
1048
+ {hostReadiness.map((row) => (
1049
+ <div key={row.id} style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0, padding: '4px 7px', border: '1px solid rgba(128,128,128,0.16)', borderRadius: 7 }}>
1050
+ <span style={{ flex: 1, minWidth: 0, fontSize: 11.5 }}>{(copy as any).readinessLabels[row.id]}</span>
1051
+ {row.detail && <span style={{ ...S.mono, opacity: 0.55 }}>{row.detail}</span>}
1052
+ <span style={readinessChip(row.state)}>{(copy as any).readinessStates[row.state]}</span>
1053
+ </div>
1054
+ ))}
1055
+ </div>
1056
+ </div>
1057
+ {integrationRow('Claude Code', !!status?.claude?.installed, !!status?.claude?.ready,
1058
+ status?.claude?.installed ? <span style={S.chip(!!status?.claude?.hud)}>{copy.hud}</span> : null,
1059
+ 'claude', 'claude-uninstall', (copy as any).tips.claude)}
1060
+ {integrationRow('Codex', !!status?.codex?.installed, !!status?.codex?.ready,
1061
+ status?.codex?.ready ? null : <span title={(status?.codex?.missing ?? []).join(', ')} style={{ fontSize: 11, opacity: 0.6 }}>{(status?.codex?.missing ?? []).join(' · ')}</span>,
1062
+ 'codex', 'codex-uninstall', (copy as any).tips.codex)}
1063
+ {integrationRow('ZCode', !!status?.zcode?.installed, !!status?.zcode?.ready,
1064
+ status?.zcode?.ready ? null : <span title={(status?.zcode?.missing ?? []).join(', ')} style={{ fontSize: 11, opacity: 0.6 }}>{(status?.zcode?.missing ?? []).join(' · ')}</span>,
1065
+ 'zcode', 'zcode-uninstall', (copy as any).tips.zcode)}
1066
+ </div>
1067
+ </CollapsibleSection>
1068
+
1069
+ {config && (<>
1070
+ {(() => {
1071
+ const clampNum = (v: any) => {
1072
+ const n = Number(v);
1073
+ if (Number.isNaN(n)) return 3;
1074
+ return Math.max(1, Math.min(16, n));
1075
+ };
1076
+ const mode = config.collaboration_mode ?? 'flash-only';
1077
+ const nonCustom = mode !== 'custom';
1078
+ const presetStates = { 'flash-only': { flash: 'auto', pro: 'disabled' }, 'pro-only': { flash: 'disabled', pro: 'auto' }, balanced: { flash: 'auto', pro: 'auto' }, 'review-pipeline': { flash: 'auto', pro: 'auto' }, custom: null } as Record<string, { flash: string; pro: string } | null>;
1079
+ const states = nonCustom ? presetStates[mode] : null;
1080
+ const effState = (tier: string) => states ? states[tier] : (tier === 'flash' ? config.flash_state : config.pro_state) ?? 'auto';
1081
+ const roleOptions = ['implementation', 'simple_fix', 'tests', 'search_inspection', 'architecture', 'complex_debugging', 'refactor', 'code_review'] as const;
1082
+ const roleKeys = (tier: string) => {
1083
+ const arr = tier === 'flash' ? (config.flash_roles ?? []) : (config.pro_roles ?? []);
1084
+ const set = new Set(arr);
1085
+ return { arr: roleOptions.filter((r) => set.has(r)), set, save: (list: string[]) => field(tier === 'flash' ? 'flash_roles' : 'pro_roles', list) };
1086
+ };
1087
+ const catalogProviders: any[] = modelCatalog?.providers ?? [];
1088
+ const refKey = (ref: any) => `${ref?.provider ?? ''}\0${ref?.model ?? ''}`;
1089
+ const priorityFor = (tier: string): any[] => Array.isArray(config[`${tier}_model_priority`]) ? config[`${tier}_model_priority`] : [];
1090
+ const savePriority = (tier: string, list: any[]) => {
1091
+ const key = `${tier}_model_priority`;
1092
+ setConfig((current: any) => ({ ...current, [key]: list, [`${tier}_model_priority_configured`]: true }));
1093
+ void applyPatch({ [key]: list });
1094
+ };
1095
+ const modelMeta = (ref: any) => {
1096
+ const provider = catalogProviders.find((item) => item.id === ref.provider);
1097
+ const model = provider?.models?.find((item: any) => item.id === ref.model);
1098
+ return { provider, model };
1099
+ };
1100
+ const modelHealth = (ref: any) => providerHealth.find((entry: any) => entry.provider === ref.provider && entry.model === ref.model && entry.fresh === true) ?? null;
1101
+ const healthLabel = (health: any) => health?.state ? String(health.state).toUpperCase().replace(/-/g, ' ') : copy.healthUnprobed;
1102
+ const healthAge = (health: any) => Number.isFinite(health?.observed_at)
1103
+ ? copy.checkedAgo(Math.max(0, Math.round((Date.now() - health.observed_at) / 1000)))
1104
+ : '';
1105
+ const filteredProviders = catalogProviders.map((provider) => ({
1106
+ ...provider,
1107
+ models: (provider.models ?? []).filter((model: any) => {
1108
+ const query = modelQuery.trim().toLowerCase();
1109
+ return !query || [provider.id, provider.name, model.id, model.name].some((value) => String(value ?? '').toLowerCase().includes(query));
1110
+ }),
1111
+ })).filter((provider) => provider.models.length > 0);
1112
+ const modelPriorityPanel = (tier: 'flash' | 'pro') => {
1113
+ const priority = priorityFor(tier);
1114
+ const selected = new Set(priority.map(refKey));
1115
+ const preferredId = tier === 'flash' ? 'deepseek-v4-flash' : 'deepseek-v4-pro';
1116
+ const preferredProviders = catalogProviders.filter((provider) => (provider.models ?? []).some((model: any) => model.id === preferredId));
1117
+ const ambiguousPreferred = priority.length === 0 && config[`${tier}_model_priority_configured`] !== true
1118
+ && preferredProviders.length > 1
1119
+ && !preferredProviders.some((provider) => provider.id === modelCatalog?.harness_default?.provider);
1120
+ return (
1121
+ <div style={{ gridColumn: '1 / -1', borderTop: '1px solid rgba(128,128,128,0.18)', paddingTop: 9 }}>
1122
+ <div style={{ ...S.fieldLabel, marginBottom: 5 }}>{copy.modelPriority}</div>
1123
+ {priority.length === 0 && <div style={{ fontSize: 12, opacity: 0.55, marginBottom: 6 }}>{copy.noPriority}</div>}
1124
+ {ambiguousPreferred && <div style={{ fontSize: 11.5, color: '#c98735', marginBottom: 6 }}>{copy.needsModelSelection}</div>}
1125
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
1126
+ {priority.map((ref, index) => {
1127
+ const meta = modelMeta(ref);
1128
+ const health = modelHealth(ref);
1129
+ const warning = !meta.provider ? copy.providerUnavailable : !meta.model ? copy.notAdvertised : '';
1130
+ return (
1131
+ <div key={refKey(ref)} style={{ display: 'flex', gap: 7, alignItems: 'center', padding: '6px 8px', border: '1px solid rgba(128,128,128,0.2)', borderRadius: 6 }}>
1132
+ <span style={{ opacity: 0.55, width: 18 }}>{index + 1}.</span>
1133
+ <span style={{ flex: 1, minWidth: 0 }}>
1134
+ <span style={{ display: 'block', fontSize: 12.5 }}>{meta.model?.name ?? ref.model}</span>
1135
+ <span style={{ display: 'block', fontSize: 10.5, opacity: 0.58, fontFamily: 'monospace' }}>{ref.provider} / {ref.model}</span>
1136
+ <span style={{ display: 'block', fontSize: 10.5, color: health?.state === 'callable' ? '#3fb950' : health ? '#c98735' : 'inherit', opacity: health ? 0.9 : 0.5 }}>
1137
+ {healthLabel(health)}{healthAge(health) ? ` · ${healthAge(health)}` : ''}
1138
+ </span>
1139
+ {warning && <span style={{ display: 'block', fontSize: 10.5, color: '#c98735' }}>{warning}</span>}
1140
+ </span>
1141
+ <button type="button" style={{ ...S.btn, padding: '2px 7px' }} title={copy.moveUp} disabled={index === 0}
1142
+ onClick={() => { const next = [...priority]; [next[index - 1], next[index]] = [next[index], next[index - 1]]; savePriority(tier, next); }}>↑</button>
1143
+ <button type="button" style={{ ...S.btn, padding: '2px 7px' }} title={copy.moveDown} disabled={index === priority.length - 1}
1144
+ onClick={() => { const next = [...priority]; [next[index], next[index + 1]] = [next[index + 1], next[index]]; savePriority(tier, next); }}>↓</button>
1145
+ <button type="button" style={{ ...S.btn, padding: '2px 7px' }} title={copy.removePriority}
1146
+ onClick={() => savePriority(tier, priority.filter((_, itemIndex) => itemIndex !== index))}>×</button>
1147
+ </div>
1148
+ );
1149
+ })}
1150
+ </div>
1151
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 7 }}>
1152
+ <button type="button" style={S.btn} disabled={!modelCatalog || modelCatalogBusy}
1153
+ onClick={() => { setModelPickerTier(modelPickerTier === tier ? null : tier); setModelQuery(''); }}>{copy.addPriorityModel}</button>
1154
+ <span style={{ fontSize: 11, opacity: 0.58 }}>{copy.harnessDefault}: {modelCatalog?.harness_default ? `${modelCatalog.harness_default.provider} / ${modelCatalog.harness_default.model}` : '—'}</span>
1155
+ </div>
1156
+ {modelPickerTier === tier && (
1157
+ <div style={{ marginTop: 8, border: '1px solid rgba(128,128,128,0.22)', borderRadius: 7, padding: 8, maxHeight: 260, overflow: 'auto' }}>
1158
+ <input autoFocus value={modelQuery} placeholder={copy.modelSearch} onChange={(event) => setModelQuery(event.target.value)}
1159
+ style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, marginBottom: 7 }} />
1160
+ {filteredProviders.map((provider) => (
1161
+ <div key={provider.id} style={{ marginBottom: 8 }}>
1162
+ <div style={{ fontSize: 11.5, fontWeight: 600, marginBottom: 3 }}>{provider.name} <span style={{ opacity: 0.5, fontFamily: 'monospace' }}>{provider.id}</span></div>
1163
+ {(provider.models ?? []).map((model: any) => {
1164
+ const ref = { provider: provider.id, model: model.id };
1165
+ const duplicate = selected.has(refKey(ref));
1166
+ return <button key={model.id} type="button" disabled={duplicate} style={{ ...S.btn, display: 'block', width: '100%', textAlign: 'left', marginBottom: 3, opacity: duplicate ? 0.45 : 1 }}
1167
+ onClick={() => { savePriority(tier, [...priority, ref]); setModelPickerTier(null); }}>{model.name} <span style={{ opacity: 0.55, fontFamily: 'monospace' }}>{model.id}</span></button>;
1168
+ })}
1169
+ </div>
1170
+ ))}
1171
+ </div>
1172
+ )}
1173
+ </div>
1174
+ );
1175
+ };
1176
+ const tierCard = (tier: string, card: { t: string; d: string }) => (
1177
+ <div style={S.block}>
1178
+ <div>
1179
+ <div style={S.settingTitle}>{card.t}</div>
1180
+ <div style={S.settingDesc}>{card.d}</div>
1181
+ </div>
1182
+ <div style={S.blockGrid}>
1183
+ <label style={S.field}><span style={S.fieldLabel}>{copy.tierState}</span>
1184
+ {nonCustom ? (
1185
+ <div style={{ fontSize: 12.5, padding: '3px 8px', borderRadius: 5, border: '1px solid rgba(128,128,128,0.35)', opacity: 0.8 }}>
1186
+ {effState(tier) === 'auto' ? copy.tierStateDesc.auto : copy.tierStateDesc.disabled}({copy.stateManagedByPreset})
1187
+ </div>
1188
+ ) : (
1189
+ <CustomSelect value={tier === 'flash' ? (config.flash_state ?? 'auto') : (config.pro_state ?? 'auto')}
1190
+ onChange={(v) => field(tier === 'flash' ? 'flash_state' : 'pro_state', v)}
1191
+ options={(['disabled', 'manual', 'auto'] as const).map((s) => ({ value: s, label: copy.tierStateDesc[s] }))} />
1192
+ )}
1193
+ </label>
1194
+ <label style={S.field}><span style={S.fieldLabel}>{tier === 'flash' ? copy.presetFlash : copy.presetPro}</span>
1195
+ <CustomSelect value={tier === 'flash' ? (config.preset_flash ?? 'default') : (config.preset_pro ?? 'default')}
1196
+ onChange={(v) => field(tier === 'flash' ? 'preset_flash' : 'preset_pro', v)} options={presetOptions} /></label>
1197
+ </div>
1198
+ <div style={{ fontSize: 11, opacity: 0.6, marginTop: 2 }}>
1199
+ {copy.statePresetHint}{' '}{copy.responsibilitiesHint}
1200
+ </div>
1201
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '3px 14px' }}>
1202
+ {roleOptions.map((r) => {
1203
+ const k = roleKeys(tier);
1204
+ const on = k.set.has(r);
1205
+ return (
1206
+ <label key={r} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
1207
+ <input type="checkbox" checked={on} onChange={() => {
1208
+ k.save(on ? k.arr.filter((x) => x !== r) : [...k.arr, r]);
1209
+ }} />
1210
+ <span>{copy.roleLabels[r]}</span>
1211
+ </label>
1212
+ );
1213
+ })}
1214
+ </div>
1215
+ {modelPriorityPanel(tier as 'flash' | 'pro')}
1216
+ </div>
1217
+ );
1218
+ return (<>
1219
+ <CollapsibleSection sectionId="workflow" title={copy.sectionNames.workflow}
1220
+ summary={sectionSummary(config.worker_state ?? 'auto', config.review_state ?? 'auto', mode, config.isolation ?? 'worktree', `×${config.max_parallel ?? 3}`)}
1221
+ expanded={!!expandedSections.workflow} onToggle={() => toggleSection('workflow')}>
1222
+ {block({ t: copy.roleWorker, d: copy.roleStateHint }, (<>
1223
+ <label style={S.field}><span style={S.fieldLabel}>{copy.workerStateLabel}</span>
1224
+ <CustomSelect value={config.worker_state ?? 'auto'} onChange={(v) => field('worker_state', v)}
1225
+ options={(['auto', 'manual', 'disabled'] as const).map((s) => ({ value: s, label: copy.tierStateDesc[s] }))} /></label>
1226
+ <label style={S.field}><span style={S.fieldLabel}>{copy.reviewStateLabel}</span>
1227
+ <CustomSelect value={config.review_state ?? 'auto'} onChange={(v) => field('review_state', v)}
1228
+ options={(['auto', 'manual', 'disabled'] as const).map((s) => ({ value: s, label: copy.tierStateDesc[s] }))} /></label>
1229
+ </>))}
1230
+ {block({ t: copy.autoReview, d: copy.autoReviewHint }, (
1231
+ <>
1232
+ <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6 }}>
1233
+ <input type="checkbox" checked={config.auto_review ?? !!config.pro_reviews_flash} onChange={(e) => field('auto_review', e.target.checked)} />
1234
+ <span style={{ fontSize: 12.5 }}>{copy.autoReview}</span>
1235
+ </label>
1236
+ <label style={S.field}><span style={S.fieldLabel}>{copy.reviewGate}</span>
1237
+ <CustomSelect value={config.review?.gate ?? 'required'} onChange={(v) => reviewField({ gate: v })}
1238
+ options={(['required', 'optional', 'off'] as const).map((value) => ({ value, label: copy.reviewGateDesc[value] }))} /></label>
1239
+ </>
1240
+ ))}
1241
+ {block({ t: copy.modelOrdering, d: copy.orderingHint }, (<>
1242
+ {(['worker', 'review'] as const).map((role) => (
1243
+ <div key={role} style={{ display: 'contents' }}>
1244
+ <label style={S.field}><span style={S.fieldLabel}>{role === 'worker' ? copy.roleWorker : copy.roleReviewer} · {copy.modelOrdering}</span>
1245
+ <CustomSelect value={config?.[role]?.model_policy?.ordering ?? 'manual'}
1246
+ onChange={(v) => rolePolicyField(role, { ordering: v })}
1247
+ options={(['manual', 'health-aware'] as const).map((value) => ({ value, label: copy.orderingDesc[value] }))} /></label>
1248
+ <label style={S.field}><span style={S.fieldLabel}>{role === 'worker' ? copy.roleWorker : copy.roleReviewer} · {copy.healthGate}</span>
1249
+ <CustomSelect value={config?.[role]?.model_policy?.health_gate ?? 'hard-failures'}
1250
+ onChange={(v) => rolePolicyField(role, { health_gate: v })}
1251
+ options={(['hard-failures', 'off'] as const).map((value) => ({ value, label: copy.healthGateDesc[value] }))} /></label>
1252
+ </div>
1253
+ ))}
1254
+ </>))}
1255
+ {block({ t: copy.isolation, d: copy.isolationHint }, (<>
1256
+ <label style={S.field}><span style={S.fieldLabel}>{copy.isolation}</span>
1257
+ <CustomSelect value={config.isolation ?? 'worktree'} onChange={(v) => field('isolation', v)}
1258
+ options={(['worktree', 'shared'] as const).map((s) => ({ value: s, label: copy.isolationDesc[s] }))} /></label>
1259
+ <label style={S.field}><span style={S.fieldLabel}>{copy.maxParallel}</span>
1260
+ <input type="number" min={1} max={16} value={config.max_parallel ?? 3}
1261
+ onChange={(e) => fieldLocal('max_parallel', clampNum(e.target.value))}
1262
+ onBlur={() => field('max_parallel', clampNum(config.max_parallel))} style={S.input} /></label>
1263
+ </>))}
1264
+ <div style={{ fontSize: 11, opacity: 0.6, marginTop: 3 }}>{copy.legacyCompatibility}</div>
1265
+ {block({ t: copy.orchestration, d: copy.enableSubagentsHint }, (
1266
+ <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6 }} title={copy.enableSubagentsHint}>
1267
+ <input type="checkbox" checked={!!config.subagents_enabled} onChange={(e) => field('subagents_enabled', e.target.checked)} />
1268
+ <span style={{ fontSize: 12.5 }}>{config.subagents_enabled === false ? copy.subagentsKept : copy.enableSubagents}</span>
1269
+ </label>
1270
+ ), false)}
1271
+ {block({ t: copy.mainAgentMode, d: copy.mainAgentModeHint }, (<>
1272
+ <label style={S.field}><span style={S.fieldLabel}>{copy.mainAgentMode}</span>
1273
+ <CustomSelect value={config.main_agent_mode ?? 'direct-allowed'} onChange={(v) => field('main_agent_mode', v)}
1274
+ options={(['direct-allowed', 'coordinator-first', 'dispatcher-only'] as const).map((m) => ({ value: m, label: copy.mainAgentModeDesc[m] }))} /></label>
1275
+ <label style={S.field}><span style={S.fieldLabel}>{copy.collaborationMode}</span>
1276
+ <CustomSelect value={mode} onChange={(v) => field('collaboration_mode', v)}
1277
+ options={(['flash-only', 'pro-only', 'balanced', 'review-pipeline', 'custom'] as const).map((m) => ({ value: m, label: copy.collaborationModeDesc[m] }))} /></label>
1278
+ <label style={S.field} title={copy.workerProviderHint}><span style={S.fieldLabel}>{copy.workerProvider}</span>
1279
+ <CustomSelect value={config.worker_provider_mode ?? 'deepseek-official'} onChange={(v) => field('worker_provider_mode', v)}
1280
+ options={(['follow-dsh', 'deepseek-official'] as const).map((m) => ({ value: m, label: copy.workerProviderDesc[m] }))} /></label>
1281
+ </>))}
1282
+ {block({ t: copy.workerModels, d: modelCatalog ? copy.modelPoolSummary(modelCatalog.provider_count ?? 0, modelCatalog.model_count ?? 0) : copy.catalogFailed }, (<>
1283
+ <button type="button" style={S.btn} disabled={modelCatalogBusy} onClick={() => { void refreshHarnessModels(); }}>
1284
+ {modelCatalogBusy ? copy.refreshingModels : copy.refreshHarnessModels}
1285
+ </button>
1286
+ {modelCatalog?.partial && <span style={{ fontSize: 11.5, color: '#c98735' }}>{copy.partialCatalog}</span>}
1287
+ {modelCatalogError && <span style={{ fontSize: 11.5, color: '#c55' }}>{modelCatalogError}</span>}
1288
+ </>), false)}
1289
+ {block({ t: copy.routing, d: '' }, (<>
1290
+ <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, paddingBottom: 5 }} title={copy.escalateHint}>
1291
+ <input type="checkbox" checked={!!config.escalate_on_failure} onChange={(e) => field('escalate_on_failure', e.target.checked)} />
1292
+ <span style={{ fontSize: 12 }}>{copy.escalate}</span></label>
1293
+ <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, paddingBottom: 5 }} title={mode === 'review-pipeline' ? copy.proReviewsLocked : copy.proReviewsFlashHint}>
1294
+ <input type="checkbox" disabled={mode === 'review-pipeline'} checked={mode === 'review-pipeline' ? true : !!config.pro_reviews_flash}
1295
+ onChange={(e) => field('pro_reviews_flash', e.target.checked)} />
1296
+ <span style={{ fontSize: 12 }}>{copy.proReviewsFlash}{mode === 'review-pipeline' ? `(${copy.proReviewsLocked})` : ''}</span></label>
1297
+ </>))}
1298
+ </CollapsibleSection>
1299
+ <CollapsibleSection sectionId="flash" title={copy.sectionNames.flash}
1300
+ summary={sectionSummary(effState('flash'), copy.modelCount(priorityFor('flash').length), priorityFor('flash')[0]?.model ?? copy.harnessDefault)}
1301
+ expanded={!!expandedSections.flash} onToggle={() => toggleSection('flash')}>
1302
+ {tierCard('flash', { t: copy.tierCardFlash, d: 'implementation · direct validation · Delivery Report' })}
1303
+ </CollapsibleSection>
1304
+ <CollapsibleSection sectionId="pro" title={copy.sectionNames.pro}
1305
+ summary={sectionSummary(effState('pro'), copy.modelCount(priorityFor('pro').length), priorityFor('pro')[0]?.model ?? copy.harnessDefault)}
1306
+ expanded={!!expandedSections.pro} onToggle={() => toggleSection('pro')}>
1307
+ {tierCard('pro', { t: copy.tierCardPro, d: 'manual advanced work · review pipeline' })}
1308
+ </CollapsibleSection>
1309
+ </>);
1310
+ })()}
1311
+
1312
+ <CollapsibleSection sectionId="dispatch" title={copy.sectionNames.dispatch}
1313
+ summary={sectionSummary(config.default_tier, config.default_effort, config.tier_policy, config.escalate_on_failure && copy.escalate)}
1314
+ expanded={!!expandedSections.dispatch} onToggle={() => toggleSection('dispatch')}>
1315
+ {block(copy.cardDispatch, (<>
1316
+ <label style={S.field}><span style={S.fieldLabel}>{copy.tier}</span>
1317
+ <CustomSelect value={config.default_tier} onChange={(v) => field('default_tier', v)}
1318
+ options={[{ value: 'flash' }, { value: 'pro' }]} /></label>
1319
+ <label style={S.field}><span style={S.fieldLabel}>{copy.effort}</span>
1320
+ <CustomSelect value={config.default_effort} onChange={(v) => field('default_effort', v)}
1321
+ options={[{ value: 'off' }, { value: 'high' }, { value: 'max' }]} /></label>
1322
+ <label style={S.field}><span style={S.fieldLabel}>{copy.tierPolicy}</span>
1323
+ <CustomSelect value={config.tier_policy} onChange={(v) => field('tier_policy', v)}
1324
+ options={(['auto', 'flash-only', 'pro-only'] as const).map((p) => ({ value: p, label: copy.tierPolicyDesc[p] }))} /></label>
1325
+ <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, paddingBottom: 5 }} title={copy.escalateHint}>
1326
+ <input type="checkbox" checked={!!config.escalate_on_failure} onChange={(e) => field('escalate_on_failure', e.target.checked)} />
1327
+ <span style={{ fontSize: 12 }}>{copy.escalate}</span></label>
1328
+ <label style={S.field}><span style={S.fieldLabel}>{copy.presetFlash}</span>
1329
+ <CustomSelect value={config.preset_flash ?? 'default'} onChange={(v) => field('preset_flash', v)} options={presetOptions} /></label>
1330
+ <label style={S.field}><span style={S.fieldLabel}>{copy.presetPro}</span>
1331
+ <CustomSelect value={config.preset_pro ?? 'default'} onChange={(v) => field('preset_pro', v)} options={presetOptions} /></label>
1332
+ </>))}
1333
+ </CollapsibleSection>
1334
+
1335
+ <CollapsibleSection sectionId="adaptive" title={copy.sectionNames.adaptive}
1336
+ summary={sectionSummary(adaptive.enabled ? copy.adaptiveEnabled : 'off', `${adaptive.min_samples}/${adaptive.window_size}`, copy.adaptiveBoundary)}
1337
+ expanded={!!expandedSections.adaptive} onToggle={() => toggleSection('adaptive')}>
1338
+ {block({ t: copy.adaptiveTitle, d: copy.adaptiveHint }, (<>
1339
+ <label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 7, gridColumn: '1 / -1' }}>
1340
+ <input type="checkbox" checked={adaptive.enabled}
1341
+ onChange={(event) => saveAdaptive({ ...adaptive, enabled: event.target.checked })} />
1342
+ <span style={{ fontSize: 12.5 }}>{copy.adaptiveEnabled}</span>
1343
+ <span style={{ fontSize: 11, opacity: 0.55 }}>· {copy.adaptiveBoundary}</span>
1344
+ </label>
1345
+ <label style={S.field}><span style={S.fieldLabel}>{copy.adaptiveWindow}</span>
1346
+ <input type="number" min={1} max={32} value={adaptive.window_size} style={S.input}
1347
+ onChange={(event) => setAdaptiveLocal({ ...adaptive, window_size: clampInt(event.target.value, adaptive.window_size, 1, 32) })}
1348
+ onBlur={() => saveAdaptive(adaptive)} /></label>
1349
+ <label style={S.field}><span style={S.fieldLabel}>{copy.adaptiveSamples}</span>
1350
+ <input type="number" min={1} max={adaptive.window_size} value={adaptive.min_samples} style={S.input}
1351
+ onChange={(event) => setAdaptiveLocal({ ...adaptive, min_samples: clampInt(event.target.value, adaptive.min_samples, 1, adaptive.window_size) })}
1352
+ onBlur={() => saveAdaptive(adaptive)} /></label>
1353
+ </>))}
1354
+ </CollapsibleSection>
1355
+
1356
+ <CollapsibleSection sectionId="schedule" title={copy.sectionNames.schedule}
1357
+ summary={sectionSummary(schedulePeakNow ? copy.schedulePeak : copy.scheduleOffPeak,
1358
+ `UTC${schedule.timezone_offset_minutes >= 0 ? '+' : '-'}${Math.abs(schedule.timezone_offset_minutes) / 60}`,
1359
+ config?.model_schedule === undefined ? copy.scheduleOff : `${schedule.models.length} · ${copy.scheduleRestricted}`)}
1360
+ expanded={!!expandedSections.schedule} onToggle={() => toggleSection('schedule')}>
1361
+ {block({ t: copy.scheduleTitle, d: copy.scheduleHint }, (<>
1362
+ <label style={S.field}><span style={S.fieldLabel}>{copy.scheduleTimezone}</span>
1363
+ <CustomSelect value={String(schedule.timezone_offset_minutes)}
1364
+ onChange={(v) => saveSchedule({ ...schedule, timezone_offset_minutes: Number(v) })}
1365
+ options={(() => {
1366
+ // Real-world fixed offsets, not just whole hours: a stored
1367
+ // minute-level offset with no matching option would render as
1368
+ // a different value than it holds.
1369
+ const offsets = new Set<number>();
1370
+ for (let h = -14; h <= 14; h++) offsets.add(h * 60);
1371
+ for (const minutes of [330, 345, 570, -210, -570]) offsets.add(minutes);
1372
+ offsets.add(schedule.timezone_offset_minutes);
1373
+ return [...offsets].sort((a, b) => a - b).map((minutes) => ({
1374
+ value: String(minutes),
1375
+ label: `UTC${minutes >= 0 ? '+' : '-'}${Math.floor(Math.abs(minutes) / 60)}`
1376
+ + (Math.abs(minutes) % 60 ? `:${String(Math.abs(minutes) % 60).padStart(2, '0')}` : ''),
1377
+ }));
1378
+ })()} /></label>
1379
+ <div style={{ gridColumn: '1 / -1' }}>
1380
+ <span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleWeekdays}</span>
1381
+ <div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
1382
+ {scheduleWeekdayNames.map((name: string, day: number) => (
1383
+ <button key={day} type="button"
1384
+ style={{ ...S.btn, padding: '3px 9px', opacity: schedule.weekdays.includes(day) ? 1 : 0.45 }}
1385
+ onClick={() => toggleScheduleWeekday(day)}>{name}</button>
1386
+ ))}
1387
+ </div>
1388
+ </div>
1389
+ <div style={{ gridColumn: '1 / -1' }}>
1390
+ <span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleWindows}</span>
1391
+ <div style={{ display: 'flex', flexDirection: 'column' as const, gap: 6 }}>
1392
+ {schedule.peak_windows.map((window: any, index: number) => (
1393
+ <div key={`${window.start}-${window.end}-${index}`} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
1394
+ <input type="time" value={window.start} style={S.input}
1395
+ onChange={(event) => {
1396
+ const next = schedule.peak_windows.map((row: any, i: number) => i === index ? { ...row, start: event.target.value } : row);
1397
+ saveSchedule({ ...schedule, peak_windows: next });
1398
+ }} />
1399
+ <span style={{ opacity: 0.6 }}>–</span>
1400
+ <input type="time" value={window.end} style={S.input}
1401
+ onChange={(event) => {
1402
+ const next = schedule.peak_windows.map((row: any, i: number) => i === index ? { ...row, end: event.target.value } : row);
1403
+ saveSchedule({ ...schedule, peak_windows: next });
1404
+ }} />
1405
+ <button type="button" style={{ ...S.btn, padding: '2px 8px' }}
1406
+ onClick={() => saveSchedule({ ...schedule, peak_windows: schedule.peak_windows.filter((_: any, i: number) => i !== index) })}>×</button>
1407
+ </div>
1408
+ ))}
1409
+ <button type="button" style={{ ...S.btn, alignSelf: 'flex-start' }}
1410
+ onClick={() => saveSchedule({ ...schedule, peak_windows: [...schedule.peak_windows, { start: '09:00', end: '12:00' }] })}>
1411
+ {copy.scheduleAddWindow}</button>
1412
+ </div>
1413
+ </div>
1414
+ <div style={{ gridColumn: '1 / -1' }}>
1415
+ <span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleRestricted}</span>
1416
+ {scheduleModelRefs.length === 0
1417
+ ? <span style={{ fontSize: 11.5, opacity: 0.6 }}>{copy.scheduleNoRestricted}</span>
1418
+ : <div style={{ display: 'flex', flexDirection: 'column' as const, gap: 5 }}>
1419
+ {scheduleModelRefs.map((ref: any) => (
1420
+ <div key={`${ref.provider}\0${ref.model}`} style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '5px 8px', border: '1px solid rgba(128,128,128,0.2)', borderRadius: 6 }}>
1421
+ <span style={{ flex: 1, minWidth: 0, fontSize: 11, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ref.provider} / {ref.model}</span>
1422
+ <CustomSelect value={scheduleModeOf(ref)}
1423
+ onChange={(v) => setScheduleMode(ref, v)}
1424
+ options={[
1425
+ { value: 'off', label: copy.scheduleOff },
1426
+ { value: 'warn', label: copy.scheduleWarn },
1427
+ { value: 'block', label: copy.scheduleBlock },
1428
+ ]} />
1429
+ </div>
1430
+ ))}
1431
+ </div>}
1432
+ <span style={{ display: 'block', fontSize: 11, opacity: 0.55, marginTop: 5 }}>{copy.scheduleBoundary}</span>
1433
+ </div>
1434
+ </>))}
1435
+ </CollapsibleSection>
1436
+
1437
+ <CollapsibleSection sectionId="runtime" title={copy.sectionNames.runtime}
1438
+ summary={sectionSummary(config.mode, `${config.default_timeout_seconds}s`, config.hub_url)}
1439
+ expanded={!!expandedSections.runtime} onToggle={() => toggleSection('runtime')}>
1440
+ {block(copy.cardRuntime, (<>
1441
+ <label style={S.field}><span style={S.fieldLabel}>{copy.mode}</span>
1442
+ <CustomSelect value={config.mode} onChange={(v) => field('mode', v)}
1443
+ options={(['auto', 'hub'] as const).map((m) => ({ value: m, label: copy.modeDesc[m] }))} /></label>
1444
+ <label style={S.field}><span style={S.fieldLabel}>{copy.timeout}</span>
1445
+ <input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const }} type="number" min={60} max={7200} value={config.default_timeout_seconds}
1446
+ onChange={(e) => fieldLocal('default_timeout_seconds', Number(e.target.value))}
1447
+ onBlur={() => { void applyPatch({ default_timeout_seconds: config.default_timeout_seconds }); }} /></label>
1448
+ <label style={S.field}><span style={S.fieldLabel}>{copy.hubUrl}</span>
1449
+ <input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const }} value={config.hub_url}
1450
+ onChange={(e) => fieldLocal('hub_url', e.target.value)}
1451
+ onBlur={() => { void applyPatch({ hub_url: config.hub_url }); }}
1452
+ onKeyDown={(e: any) => { if (e.key === 'Enter') e.currentTarget.blur(); }} /></label>
1453
+ </>))}
1454
+ <ActivationSummary activation={config.config_activation} locale={locale} />
1455
+ </CollapsibleSection>
1456
+
1457
+ <CollapsibleSection sectionId="harnessProviders" title={copy.sectionNames.harnessProviders}
1458
+ summary={sectionSummary(copy.providerCount(providerInventory?.records?.length ?? 0), providerInventoryError ? copy.providerLifecycleError : '')}
1459
+ expanded={!!expandedSections.harnessProviders} onToggle={() => toggleSection('harnessProviders')}>
1460
+ {providerInventoryError && <div style={{ fontSize: 12, color: '#c55' }}>{providerInventoryError}</div>}
1461
+ {!providerInventoryError && (providerInventory?.migration?.providers?.length ?? 0) > 0 && (
1462
+ <div style={{ border: '1px solid rgba(201,135,53,0.45)', borderRadius: 7, padding: '8px 10px', marginBottom: 8 }}>
1463
+ <div style={{ fontWeight: 650, fontSize: 12.5 }}>{copy.providerLayerMigration}</div>
1464
+ <div style={{ fontSize: 11.5, opacity: 0.7, marginTop: 3 }}>{copy.providerLayerMigrationHint}</div>
1465
+ <div style={{ fontSize: 11, marginTop: 6, fontFamily: 'monospace' }}>
1466
+ {(providerInventory.migration.providers ?? []).map((entry: any) => (
1467
+ <div key={entry.provider_id} style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3 }}>
1468
+ {entry.provider_id} · {entry.action}{entry.harness_default ? ' · Harness Default' : ''}
1469
+ <span style={{ flex: 1 }} />
1470
+ {['promote-existing-user', 'materialize-user'].includes(entry.action) && <button type="button" style={{ ...S.btn, padding: '2px 7px' }} disabled={providerLifecycleBusy !== null}
1471
+ onClick={() => { void migrateHarnessProvider(entry); }}>
1472
+ {providerLifecycleBusy === `migrate:${entry.provider_id}` ? copy.providerLayerMigrating : copy.providerLayerMigrate}
1473
+ </button>}
1474
+ </div>
1475
+ ))}
1476
+ </div>
1477
+ <a href="http://127.0.0.1:3210/" target="_blank" rel="noopener noreferrer" style={{ ...S.btn, display: 'inline-block', textDecoration: 'none', marginTop: 7, padding: '3px 8px' }}>
1478
+ {copy.providerLayerMigrationPlan} → 3210
1479
+ </a>
1480
+ </div>
1481
+ )}
1482
+ {!providerInventoryError && (providerInventory?.records?.length ?? 0) === 0 && (
1483
+ <div style={{ fontSize: 12, opacity: 0.55 }}>{copy.providerNoInventory}</div>
1484
+ )}
1485
+ {(providerInventory?.records ?? []).map((record: any) => {
1486
+ const immutable = record.delete_capability === 'immutable-builtin';
1487
+ const unresolved = record.delete_capability === 'source-unresolved';
1488
+ const blocked = immutable || unresolved || record.desired_state === 'absent' || Number(record.references?.active_jobs ?? 0) > 0;
1489
+ const lifecycle = record.desired_state === 'absent' ? 'absent' : record.lifecycle?.enabled === false ? 'disabled' : record.lifecycle?.catalogued ? 'catalogued' : 'configured';
1490
+ const lifecycleTransactions = providerInventory?.lifecycle_transactions ?? [];
1491
+ const recoveryTransactions = (providerInventory?.recovery_transactions ?? []).map((entry: any) => ({ ...entry, state: entry.phase }));
1492
+ const rollbackTransaction = [...lifecycleTransactions, ...recoveryTransactions]
1493
+ .filter((entry: any) => entry.provider_id === record.id && ['VERIFIED', 'RESTART_PENDING', 'ROLLBACK_PENDING', 'PLANNED', 'TOMBSTONE_APPLYING', 'TOMBSTONE_APPLIED', 'REFERENCES_APPLYING', 'REFERENCES_APPLIED', 'DECLARATIONS_APPLYING', 'DECLARATIONS_APPLIED', 'DELETE_RESTART_PENDING', 'ROLLBACK_RESTART_PENDING', 'ROLLBACK_RESTORED', 'ROLLBACK_APPLYING', 'PREPARED', 'USER_MATERIALIZED', 'BASE_REMOVED'].includes(entry.state))
1494
+ .sort((a: any, b: any) => String(a.updated_at ?? '').localeCompare(String(b.updated_at ?? ''))).at(-1);
1495
+ return (
1496
+ <div key={record.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, flexWrap: 'wrap' as const }}>
1497
+ <span style={{ fontWeight: 600 }}>{record.display_name || record.id}</span>
1498
+ <span style={{ ...S.chip(record.lifecycle?.enabled === true), ...S.mono }}>{record.id}</span>
1499
+ <span style={{ opacity: 0.65 }}>{copy.providerState(lifecycle)}</span>
1500
+ <span style={{ opacity: 0.65 }}>{copy.providerModels(record.models?.length ?? 0)}</span>
1501
+ {immutable && <span style={{ opacity: 0.65 }}>{copy.providerOfficialBuiltin}</span>}
1502
+ {unresolved && <span style={{ color: '#c98735' }}>{copy.providerSourceUnresolved}</span>}
1503
+ {Number(record.references?.active_jobs ?? 0) > 0 && <span style={{ color: '#c98735' }}>{copy.providerJobs(record.references.active_jobs)}</span>}
1504
+ <span style={{ flex: 1 }} />
1505
+ {record.delete_capability === 'supported' && record.desired_state !== 'absent' && (
1506
+ <button type="button" style={{ ...S.btn, padding: '2px 8px' }} disabled={blocked || providerLifecycleBusy !== null}
1507
+ onClick={() => { void deleteHarnessProvider(record); }}>
1508
+ {providerLifecycleBusy === record.id ? copy.working : copy.providerDeletePlan}
1509
+ </button>
1510
+ )}
1511
+ {(record.desired_state === 'absent' || ['PLANNED', 'TOMBSTONE_APPLYING', 'TOMBSTONE_APPLIED', 'REFERENCES_APPLYING', 'REFERENCES_APPLIED', 'DECLARATIONS_APPLYING', 'DECLARATIONS_APPLIED', 'DELETE_RESTART_PENDING', 'ROLLBACK_PENDING', 'ROLLBACK_RESTART_PENDING', 'ROLLBACK_RESTORED', 'ROLLBACK_APPLYING'].includes(rollbackTransaction?.state)) && rollbackTransaction && rollbackTransaction.source !== 'provider-layer-migration' && (
1512
+ <button type="button" style={{ ...S.btn, padding: '2px 8px' }} disabled={providerLifecycleBusy !== null}
1513
+ onClick={() => { void rollbackHarnessProvider(record, rollbackTransaction.transaction_id, rollbackTransaction.state); }}>
1514
+ {providerLifecycleBusy === `rollback:${record.id}` ? copy.working : rollbackTransaction.state === 'ROLLBACK_PENDING' ? copy.providerRollbackContinue : copy.providerRollback}
1515
+ </button>
1516
+ )}
1517
+ {rollbackTransaction?.source === 'provider-layer-migration' && rollbackTransaction.transaction_id && (
1518
+ <button type="button" style={{ ...S.btn, padding: '2px 8px' }} disabled={providerLifecycleBusy !== null}
1519
+ onClick={() => { void rollbackHarnessMigration(record, rollbackTransaction.transaction_id); }}>
1520
+ {providerLifecycleBusy === `rollback-migrate:${record.id}` ? copy.working : copy.providerLayerRollback}
1521
+ </button>
1522
+ )}
1523
+ </div>
1524
+ );
1525
+ })}
1526
+ {(providerInventory?.recovery_transactions ?? []).filter((entry: any) => entry?.unresolved === true || !entry?.provider_id).map((entry: any) => (
1527
+ <div key={`recovery:${entry.storage_id ?? entry.transaction_id ?? 'unknown'}`} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, flexWrap: 'wrap' as const, color: '#c98735', marginTop: 6 }}>
1528
+ <span style={{ fontWeight: 600 }}>{copy.recoveryUnresolved}</span>
1529
+ <span style={S.mono}>{entry.storage_id ?? entry.transaction_id ?? 'unknown'}</span>
1530
+ <span style={{ opacity: 0.7 }}>{entry.phase}</span>
1531
+ <span style={{ flex: 1 }} />
1532
+ {entry.action_id && <button type="button" style={{ ...S.btn, padding: '2px 8px' }} disabled={providerLifecycleBusy !== null}
1533
+ onClick={() => { void quarantineRecoveryTransaction(entry); }}>
1534
+ {providerLifecycleBusy === `quarantine:${entry.action_id}` ? copy.working : copy.recoveryQuarantine}
1535
+ </button>}
1536
+ </div>
1537
+ ))}
1538
+ {(credentialRefs.length > 0 || credentialUnverified.length > 0) && (
1539
+ <div style={{ marginTop: 10, borderTop: '1px solid rgba(128,128,128,0.16)', paddingTop: 8 }}>
1540
+ <div style={{ fontWeight: 600, fontSize: 12.5 }}>{copy.credentialReferences}</div>
1541
+ <div style={{ fontSize: 11, opacity: 0.6, margin: '3px 0 7px' }}>{copy.credentialReferenceHint}</div>
1542
+ {credentialUnverified.map((entry: any) => (
1543
+ <div key={`unverified:${entry.reference_id}`} style={{ color: '#c55', fontSize: 11.5 }}>
1544
+ <span style={S.mono}>{entry.reference_id}</span> · {copy.credentialPurgeUnverified}
1545
+ </div>
1546
+ ))}
1547
+ {credentialRefs.map((ref: any) => (
1548
+ <div key={ref.reference_id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11.5, flexWrap: 'wrap' as const }}>
1549
+ <span style={S.mono}>{ref.reference_id}</span>
1550
+ <span style={{ opacity: 0.65 }}>{ref.ownership}</span>
1551
+ <span style={{ color: ref.orphan ? '#c98735' : undefined }}>{ref.orphan ? copy.credentialOrphan : copy.credentialInUse(ref.consumer_count ?? 0)}</span>
1552
+ <span style={{ flex: 1 }} />
1553
+ {ref.ownership === 'crew-owned' && ref.orphan === true && ref.purge_capability === 'eligible' && (
1554
+ <button type="button" style={{ ...S.btn, padding: '2px 8px' }}
1555
+ disabled={credentialLifecycleBusy !== null}
1556
+ onClick={() => { void purgeCredentialRef(ref); }}>
1557
+ {credentialLifecycleBusy === ref.reference_id ? copy.credentialPurging : copy.credentialPurgePlan}
1558
+ </button>
1559
+ )}
1560
+ </div>
1561
+ ))}
1562
+ </div>
1563
+ )}
1564
+ </CollapsibleSection>
1565
+
1566
+ </>)}
1567
+ <div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 2 }}>{copy.globalHint}</div>
1568
+
1569
+ {notice !== '' && <div style={{ fontSize: 12, opacity: 0.8, whiteSpace: 'pre-wrap' }}>{notice}</div>}
1570
+
1571
+ <CollapsibleSection sectionId="jobs" title={copy.sectionNames.jobs}
1572
+ summary={sectionSummary(copy.jobCount(jobs.length), jobs.some((job) => job.status === 'running') && copy.runningCount(jobs.filter((job) => job.status === 'running').length))}
1573
+ expanded={!!expandedSections.jobs} onToggle={() => toggleSection('jobs')}>
1574
+ {jobs.length === 0 ? (
1575
+ <div style={{ opacity: 0.55 }}>{copy.empty}</div>
1576
+ ) : (
1577
+ <div style={{ overflowX: 'auto', border: '1px solid rgba(128,128,128,0.20)', borderRadius: 8, padding: '0 9px' }}>
1578
+ <table style={{ borderCollapse: 'collapse', width: '100%', tableLayout: 'fixed', minWidth: 1020 }}>
1579
+ <colgroup>
1580
+ <col style={{ width: 68 }} /><col style={{ width: 72 }} /><col style={{ width: 58 }} />
1581
+ <col style={{ width: 190 }} /><col style={{ width: 70 }} /><col style={{ width: 70 }} />
1582
+ <col style={{ width: 105 }} /><col style={{ width: 84 }} /><col style={{ width: 300 }} />
1583
+ </colgroup>
1584
+ <thead><tr>
1585
+ {[copy.col.id, copy.col.role, copy.col.source, copy.col.model, copy.col.tier, copy.col.status, copy.col.progress, copy.col.tokens, copy.col.task].map((heading) => (
1586
+ <th key={heading} style={{ ...S.tight, fontSize: 10.5, opacity: 0.55, fontWeight: 500 }}>{heading}</th>
1587
+ ))}
1588
+ </tr></thead>
1589
+ <tbody>
1590
+ {jobs.map((job) => {
1591
+ const color = job.status === 'running' ? '#4a9eff' : job.status === 'done' ? '#3fb950' : '#f85149';
1592
+ return (
1593
+ <tr key={job.id}>
1594
+ <td style={{ ...S.tight, ...S.mono, fontWeight: 600 }} title={job.id}>{String(job.id).replace(/-[a-z0-9]+$/, '')}</td>
1595
+ <td style={S.tight}><span style={S.chip(job.role === 'reviewer')}>{job.role === 'reviewer' ? 'Reviewer' : 'Worker'}</span></td>
1596
+ <td style={S.tight}><span style={S.chip(job.source === 'claude-code' || job.source === 'codex')}>{sourceLabel(job.source)}</span></td>
1597
+ <td style={{ ...S.tight, ...S.mono }} title={`${job.provider ?? '—'} / ${job.model ?? '—'}`}>{job.provider ?? '—'} / {job.model ?? '—'}</td>
1598
+ <td style={S.tight}>{job.tier}/{job.effort}</td>
1599
+ <td style={S.tight} title={`${job.status}${job.currentTool ? ` · ${job.currentTool}` : ''}`}>
1600
+ <span style={{ color }} aria-hidden="true">●</span> <span style={{ color, fontSize: 10.5 }}>{job.status}</span>
1601
+ </td>
1602
+ <td style={{ ...S.tight, ...S.mono }} title={copy.progressTip(elapsed(job.startedAt, job.endedAt), job.turn, job.step, job.toolCalls ?? '-')}>{elapsed(job.startedAt, job.endedAt)} · {job.turn}.{job.step} · {job.toolCalls ?? '-'}t</td>
1603
+ <td style={{ ...S.tight, ...S.mono }}>{job.tokens ? `${ktok(job.tokens.input)}/${ktok(job.tokens.output)}` : '—'}</td>
1604
+ <td
1605
+ style={{ ...S.cell, position: 'relative' }}
1606
+ onMouseEnter={(event: any) => {
1607
+ const rect = event.currentTarget.getBoundingClientRect();
1608
+ setHoverTask({ id: job.id, text: job.task, right: Math.max(8, window.innerWidth - rect.right), top: rect.bottom + 4, bottom: window.innerHeight - rect.top + 4, flip: rect.top < 240 });
1609
+ }}
1610
+ onMouseLeave={() => setHoverTask(null)}>
1611
+ <div style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', lineHeight: 1.45, fontSize: 12 }}>
1612
+ {job.task}
1613
+ </div>
1614
+ <div style={{ fontSize: 10, opacity: 0.5 }}>{job.selection_source ?? 'unknown'}</div>
1615
+ </td>
1616
+ </tr>
1617
+ );
1618
+ })}
1619
+ </tbody>
1620
+ </table>
1621
+ </div>
1622
+ )}
1623
+
1624
+ <div style={{ ...S.block, marginTop: 2 }}>
1625
+ <div>
1626
+ <div style={S.settingTitle}>{copy.modelActivity}</div>
1627
+ <div style={S.settingDesc}>{copy.modelActivityHint}</div>
1628
+ </div>
1629
+ {modelActivity.length === 0 ? <div style={{ opacity: 0.55 }}>{copy.noModelActivity}</div> : (
1630
+ <div style={{ overflowX: 'auto' }}>
1631
+ <div role="table" style={{ minWidth: 620 }}>
1632
+ <div role="row" style={{ display: 'grid', gridTemplateColumns: 'minmax(190px, 1.35fr) 72px minmax(170px, 1fr) 125px', gap: 10, padding: '0 6px 5px', fontSize: 10.5, opacity: 0.55 }}>
1633
+ <span role="columnheader">{copy.model}</span><span role="columnheader">{copy.calls}</span><span role="columnheader">{copy.invocationSource} / {copy.routingSource}</span><span role="columnheader">{copy.lastCalled}</span>
1634
+ </div>
1635
+ {modelActivity.map((entry) => (
1636
+ <div role="row" key={`${entry.provider}/${entry.model}`} style={{ display: 'grid', gridTemplateColumns: 'minmax(190px, 1.35fr) 72px minmax(170px, 1fr) 125px', gap: 10, alignItems: 'center', padding: '7px 6px', borderTop: '1px solid rgba(128,128,128,0.16)' }}>
1637
+ <span role="cell" style={{ minWidth: 0 }}><strong style={{ display: 'block', fontSize: 12 }}>{entry.model}</strong><span style={{ ...S.mono, opacity: 0.55 }}>{entry.provider}</span></span>
1638
+ <span role="cell" style={{ ...S.mono, fontSize: 15, fontWeight: 650 }}>{entry.count}</span>
1639
+ <span role="cell" style={{ minWidth: 0, fontSize: 11.5 }}><span style={{ display: 'block' }}>{entry.task_sources.map(sourceLabel).join(' · ')}</span><span style={{ display: 'block', opacity: 0.55 }}>{entry.selection_sources.join(' · ')} · {entry.roles.length > 0 ? entry.roles.join(' / ') : copy.never}</span></span>
1640
+ <span role="cell" style={{ ...S.mono, fontSize: 10.5 }}>{formatTimestamp(entry.last_called_at, locale)}</span>
1641
+ </div>
1642
+ ))}
1643
+ </div>
1644
+ </div>
1645
+ )}
1646
+ </div>
1647
+ </CollapsibleSection>
1648
+ {hoverTask && createPortal(
1649
+ <div style={{
1650
+ position: 'fixed', right: hoverTask.right, zIndex: 9999,
1651
+ ...(hoverTask.flip ? { top: hoverTask.top } : { bottom: hoverTask.bottom }),
1652
+ minWidth: 260, maxWidth: 420, maxHeight: 180, overflowY: 'auto',
1653
+ padding: '8px 12px', borderRadius: 8,
1654
+ border: '1px solid rgba(128,128,128,0.35)',
1655
+ background: 'Canvas', color: 'CanvasText',
1656
+ boxShadow: '0 8px 28px rgba(0,0,0,0.22)',
1657
+ whiteSpace: 'pre-wrap', fontSize: 12, lineHeight: 1.5,
1658
+ }}>{hoverTask.text}</div>,
1659
+ document.body,
1660
+ )}
1661
+ </div>
1662
+ );
1663
+ }
1664
+
1665
+ export function apply(ctx: any): void {
1666
+ let runningCount = 0;
1667
+ let badgeSurface: boolean | null = null;
1668
+ ctx.slots.inject('settings.section', () => {
1669
+ let dispose = register();
1670
+ function register() {
1671
+ return ctx.slots.register(
1672
+ {
1673
+ name: 'settings.section',
1674
+ id: 'dsh-crew',
1675
+ order: 65,
1676
+ label: () => {
1677
+ const base = COPY[ctx.locale.getLocale().active === 'zh' ? 'zh' : 'en'].label;
1678
+ if (runningCount <= 0) return base;
1679
+ // String-only slot labels: filled circled digits are the closest
1680
+ // thing to a badge the contract allows.
1681
+ const CIRCLED = ['❶', '❷', '❸', '❹', '❺', '❻', '❼', '❽', '❾', '❿'];
1682
+ return `${base} ${CIRCLED[runningCount - 1] ?? `(${runningCount})`}`;
1683
+ },
1684
+ },
1685
+ () => <WorkersPanel ctx={ctx} />,
1686
+ );
1687
+ }
1688
+ const poll = async () => {
1689
+ if (document.visibilityState !== 'visible') return;
1690
+ try {
1691
+ if (badgeSurface === null) {
1692
+ const [bridgeStatus, runtime] = await Promise.all([
1693
+ fetch(`${API}/bridge-status`, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
1694
+ fetch(`${API}/runtime`, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
1695
+ ]);
1696
+ badgeSurface = surfaceResponsibilities(classifyCrewSurface({ bridgeStatus, runtime })).fullControlPlane;
1697
+ }
1698
+ if (!badgeSurface) return;
1699
+ const r = await (await fetch(`${API}/jobs`, { cache: 'no-store' })).json();
1700
+ const n = r.ok ? (r.jobs ?? []).filter((j: any) => j.status === 'running').length : 0;
1701
+ if (n !== runningCount) {
1702
+ runningCount = n;
1703
+ dispose(); dispose = register(); // slots/changed → nav re-renders the label
1704
+ }
1705
+ } catch { /* hub restarting */ }
1706
+ };
1707
+ void poll();
1708
+ const timer = setInterval(() => { void poll(); }, 10_000);
1709
+ return () => { clearInterval(timer); dispose(); };
1710
+ });
1711
+ }