@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,260 +1,260 @@
1
- // Opt-in adaptive model routing primitives.
2
- //
3
- // This module is deliberately pure/process-local: it never reads credentials,
4
- // provider quota, billing data, persistent files, or DSH profile state. The
5
- // caller may record only outcomes Crew already observed, and routing decisions
6
- // expose bounded evidence suitable for model-selection trace metadata.
7
-
8
- export const ADAPTIVE_ROUTING_VERSION = 1;
9
- export const ADAPTIVE_ROUTING_DEFAULTS = Object.freeze({
10
- enabled: false,
11
- window_size: 8,
12
- min_samples: 2,
13
- });
14
-
15
- export const ADAPTIVE_ROUTING_REASON_CODES = Object.freeze({
16
- DISABLED: 'DISABLED',
17
- EXPLICIT_PRIORITY: 'EXPLICIT_PRIORITY',
18
- NO_ELIGIBLE_CANDIDATES: 'NO_ELIGIBLE_CANDIDATES',
19
- SINGLE_CANDIDATE: 'SINGLE_CANDIDATE',
20
- INSUFFICIENT_HISTORY: 'INSUFFICIENT_HISTORY',
21
- HEALTH_NEUTRAL: 'HEALTH_NEUTRAL',
22
- HEALTH_REORDERED: 'HEALTH_REORDERED',
23
- HEALTH_ORDER_UNCHANGED: 'HEALTH_ORDER_UNCHANGED',
24
- });
25
-
26
- function clampInteger(value, fallback, min, max) {
27
- const n = Number(value);
28
- if (!Number.isInteger(n)) return fallback;
29
- return Math.max(min, Math.min(max, n));
30
- }
31
-
32
- export function normalizeAdaptiveRouting(raw = {}) {
33
- const windowSize = clampInteger(raw?.window_size, ADAPTIVE_ROUTING_DEFAULTS.window_size, 1, 32);
34
- return {
35
- enabled: raw?.enabled === true,
36
- window_size: windowSize,
37
- min_samples: clampInteger(raw?.min_samples, ADAPTIVE_ROUTING_DEFAULTS.min_samples, 1, windowSize),
38
- };
39
- }
40
-
41
- function normalizeRef(raw) {
42
- if (!raw || typeof raw !== 'object') return null;
43
- const provider = typeof raw.provider === 'string' ? raw.provider.trim() : '';
44
- const model = typeof raw.model === 'string' ? raw.model.trim() : '';
45
- return provider && model ? { provider, model } : null;
46
- }
47
-
48
- function refKey(ref, role = 'worker') {
49
- const normalized = normalizeRef(ref);
50
- return normalized ? `${role === 'reviewer' ? 'reviewer' : 'worker'}\0${normalized.provider}\0${normalized.model}` : '';
51
- }
52
-
53
- function normalizeLatencyMs(value) {
54
- const n = Number(value);
55
- if (!Number.isFinite(n) || n < 0) return null;
56
- // Bound pathological values; adaptive routing only needs a coarse signal.
57
- return Math.min(Math.round(n), 3_600_000);
58
- }
59
-
60
- function latencyBucket(samples) {
61
- const values = samples.map((sample) => sample.latency_ms).filter(Number.isFinite);
62
- if (values.length === 0) return 'unknown';
63
- const average = values.reduce((sum, value) => sum + value, 0) / values.length;
64
- if (average <= 30_000) return 'fast';
65
- if (average <= 120_000) return 'medium';
66
- return 'slow';
67
- }
68
-
69
- function outcomeKind(observation = {}) {
70
- const stopReason = String(observation.stopReason ?? observation.stop_reason ?? '').toLowerCase();
71
- if (observation.timed_out === true || stopReason === 'timeout' || stopReason === 'timed_out') return 'timeout';
72
- const status = String(observation.status ?? '').toLowerCase();
73
- if (status === 'done' || status === 'success' || status === 'completed') return 'success';
74
- if (status === 'failed' || status === 'failure' || status === 'error') return 'failure';
75
- return null;
76
- }
77
-
78
- function healthScore(summary) {
79
- let score = summary.successes * 4 - summary.failures * 3 - summary.timeouts * 6;
80
- if (summary.latency_bucket === 'medium') score -= 1;
81
- else if (summary.latency_bucket === 'slow') score -= 2;
82
- return score;
83
- }
84
-
85
- export function createAdaptiveHealthStore({ maxSamples = 32 } = {}) {
86
- const limit = clampInteger(maxSamples, 32, 1, 32);
87
- const history = new Map();
88
-
89
- return {
90
- record(ref, observation = {}) {
91
- const key = refKey(ref, observation.role);
92
- const outcome = outcomeKind(observation);
93
- if (!key || !outcome) return false;
94
- const samples = history.get(key) ?? [];
95
- samples.push({
96
- outcome,
97
- latency_ms: normalizeLatencyMs(observation.latencyMs ?? observation.latency_ms),
98
- });
99
- if (samples.length > limit) samples.splice(0, samples.length - limit);
100
- history.set(key, samples);
101
- return true;
102
- },
103
-
104
- snapshot(ref, { role = 'worker', windowSize = ADAPTIVE_ROUTING_DEFAULTS.window_size } = {}) {
105
- const key = refKey(ref, role);
106
- const window = clampInteger(windowSize, ADAPTIVE_ROUTING_DEFAULTS.window_size, 1, 32);
107
- const samples = key ? (history.get(key) ?? []).slice(-window) : [];
108
- const summary = {
109
- samples: samples.length,
110
- successes: samples.filter((sample) => sample.outcome === 'success').length,
111
- failures: samples.filter((sample) => sample.outcome === 'failure').length,
112
- timeouts: samples.filter((sample) => sample.outcome === 'timeout').length,
113
- latency_bucket: latencyBucket(samples),
114
- };
115
- return { ...summary, score: healthScore(summary) };
116
- },
117
-
118
- clear() {
119
- history.clear();
120
- },
121
- };
122
- }
123
-
124
- // One bounded store per Node.js process. Hub routing and its entry wrapper share
125
- // this module instance, so a completed opt-in Hub attempt can influence a later
126
- // opt-in Hub selection without writing any persistent state. Process restart is
127
- // intentionally the reset boundary.
128
- const PROCESS_ADAPTIVE_HEALTH = createAdaptiveHealthStore();
129
-
130
- export function getProcessAdaptiveHealthStore() {
131
- return PROCESS_ADAPTIVE_HEALTH;
132
- }
133
-
134
- function uniqueCandidates(candidates) {
135
- const seen = new Set();
136
- const out = [];
137
- for (const candidate of Array.isArray(candidates) ? candidates : []) {
138
- const ref = normalizeRef(candidate);
139
- if (!ref) continue;
140
- const key = `${ref.provider}\0${ref.model}`;
141
- if (seen.has(key)) continue;
142
- seen.add(key);
143
- out.push(ref);
144
- }
145
- return out;
146
- }
147
-
148
- function traceBase(config, reason, candidates = []) {
149
- return {
150
- version: ADAPTIVE_ROUTING_VERSION,
151
- enabled: config.enabled,
152
- decision_supported: false,
153
- applied: false,
154
- reason,
155
- window_size: config.window_size,
156
- min_samples: config.min_samples,
157
- candidates,
158
- };
159
- }
160
-
161
- /**
162
- * Stable health-based ordering for automatically derived candidates.
163
- *
164
- * Explicit user priority is a hard bypass: health never changes its order.
165
- * Candidates below min_samples are scored as neutral (0), allowing a mature
166
- * unhealthy candidate to yield to an unknown one without overreacting to a
167
- * single sample. Ties always retain baseline order.
168
- */
169
- export function rankAdaptiveCandidates(candidates, {
170
- config: rawConfig,
171
- healthStore = PROCESS_ADAPTIVE_HEALTH,
172
- role = 'worker',
173
- explicitPriority = false,
174
- } = {}) {
175
- const config = normalizeAdaptiveRouting(rawConfig);
176
- const baseline = uniqueCandidates(candidates);
177
-
178
- if (!config.enabled) {
179
- return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.DISABLED) };
180
- }
181
- if (explicitPriority) {
182
- return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.EXPLICIT_PRIORITY) };
183
- }
184
- if (baseline.length === 0) {
185
- return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.NO_ELIGIBLE_CANDIDATES) };
186
- }
187
- if (baseline.length === 1) {
188
- return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.SINGLE_CANDIDATE) };
189
- }
190
-
191
- const scored = baseline.map((candidate, baselineRank) => {
192
- const summary = healthStore?.snapshot?.(candidate, { role, windowSize: config.window_size }) ?? {
193
- samples: 0, successes: 0, failures: 0, timeouts: 0, latency_bucket: 'unknown', score: 0,
194
- };
195
- const mature = summary.samples >= config.min_samples;
196
- return {
197
- candidate,
198
- baselineRank,
199
- mature,
200
- effectiveScore: mature ? summary.score : 0,
201
- summary,
202
- };
203
- });
204
-
205
- const mature = scored.filter((entry) => entry.mature);
206
- if (mature.length === 0) {
207
- const candidatesTrace = scored.map((entry) => ({
208
- ...entry.candidate,
209
- baseline_rank: entry.baselineRank,
210
- adaptive_rank: entry.baselineRank,
211
- health_state: 'warming',
212
- samples: entry.summary.samples,
213
- successes: entry.summary.successes,
214
- failures: entry.summary.failures,
215
- timeouts: entry.summary.timeouts,
216
- latency_bucket: entry.summary.latency_bucket,
217
- score: null,
218
- }));
219
- return {
220
- candidates: baseline,
221
- trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.INSUFFICIENT_HISTORY, candidatesTrace),
222
- };
223
- }
224
-
225
- const hasSignal = mature.some((entry) => entry.effectiveScore !== 0);
226
- const ranked = [...scored].sort((a, b) => {
227
- if (a.effectiveScore !== b.effectiveScore) return b.effectiveScore - a.effectiveScore;
228
- return a.baselineRank - b.baselineRank;
229
- });
230
- const orderChanged = ranked.some((entry, index) => entry.baselineRank !== index);
231
- const rankByKey = new Map(ranked.map((entry, index) => [`${entry.candidate.provider}\0${entry.candidate.model}`, index]));
232
- const candidatesTrace = scored.map((entry) => ({
233
- ...entry.candidate,
234
- baseline_rank: entry.baselineRank,
235
- adaptive_rank: rankByKey.get(`${entry.candidate.provider}\0${entry.candidate.model}`),
236
- health_state: entry.mature ? 'mature' : 'warming',
237
- samples: entry.summary.samples,
238
- successes: entry.summary.successes,
239
- failures: entry.summary.failures,
240
- timeouts: entry.summary.timeouts,
241
- latency_bucket: entry.summary.latency_bucket,
242
- score: entry.mature ? entry.effectiveScore : null,
243
- }));
244
-
245
- if (!hasSignal) {
246
- return {
247
- candidates: baseline,
248
- trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.HEALTH_NEUTRAL, candidatesTrace),
249
- };
250
- }
251
-
252
- const trace = traceBase(
253
- config,
254
- orderChanged ? ADAPTIVE_ROUTING_REASON_CODES.HEALTH_REORDERED : ADAPTIVE_ROUTING_REASON_CODES.HEALTH_ORDER_UNCHANGED,
255
- candidatesTrace,
256
- );
257
- trace.decision_supported = true;
258
- trace.applied = orderChanged;
259
- return { candidates: ranked.map((entry) => entry.candidate), trace };
260
- }
1
+ // Opt-in adaptive model routing primitives.
2
+ //
3
+ // This module is deliberately pure/process-local: it never reads credentials,
4
+ // provider quota, billing data, persistent files, or DSH profile state. The
5
+ // caller may record only outcomes Crew already observed, and routing decisions
6
+ // expose bounded evidence suitable for model-selection trace metadata.
7
+
8
+ export const ADAPTIVE_ROUTING_VERSION = 1;
9
+ export const ADAPTIVE_ROUTING_DEFAULTS = Object.freeze({
10
+ enabled: false,
11
+ window_size: 8,
12
+ min_samples: 2,
13
+ });
14
+
15
+ export const ADAPTIVE_ROUTING_REASON_CODES = Object.freeze({
16
+ DISABLED: 'DISABLED',
17
+ EXPLICIT_PRIORITY: 'EXPLICIT_PRIORITY',
18
+ NO_ELIGIBLE_CANDIDATES: 'NO_ELIGIBLE_CANDIDATES',
19
+ SINGLE_CANDIDATE: 'SINGLE_CANDIDATE',
20
+ INSUFFICIENT_HISTORY: 'INSUFFICIENT_HISTORY',
21
+ HEALTH_NEUTRAL: 'HEALTH_NEUTRAL',
22
+ HEALTH_REORDERED: 'HEALTH_REORDERED',
23
+ HEALTH_ORDER_UNCHANGED: 'HEALTH_ORDER_UNCHANGED',
24
+ });
25
+
26
+ function clampInteger(value, fallback, min, max) {
27
+ const n = Number(value);
28
+ if (!Number.isInteger(n)) return fallback;
29
+ return Math.max(min, Math.min(max, n));
30
+ }
31
+
32
+ export function normalizeAdaptiveRouting(raw = {}) {
33
+ const windowSize = clampInteger(raw?.window_size, ADAPTIVE_ROUTING_DEFAULTS.window_size, 1, 32);
34
+ return {
35
+ enabled: raw?.enabled === true,
36
+ window_size: windowSize,
37
+ min_samples: clampInteger(raw?.min_samples, ADAPTIVE_ROUTING_DEFAULTS.min_samples, 1, windowSize),
38
+ };
39
+ }
40
+
41
+ function normalizeRef(raw) {
42
+ if (!raw || typeof raw !== 'object') return null;
43
+ const provider = typeof raw.provider === 'string' ? raw.provider.trim() : '';
44
+ const model = typeof raw.model === 'string' ? raw.model.trim() : '';
45
+ return provider && model ? { provider, model } : null;
46
+ }
47
+
48
+ function refKey(ref, role = 'worker') {
49
+ const normalized = normalizeRef(ref);
50
+ return normalized ? `${role === 'reviewer' ? 'reviewer' : 'worker'}\0${normalized.provider}\0${normalized.model}` : '';
51
+ }
52
+
53
+ function normalizeLatencyMs(value) {
54
+ const n = Number(value);
55
+ if (!Number.isFinite(n) || n < 0) return null;
56
+ // Bound pathological values; adaptive routing only needs a coarse signal.
57
+ return Math.min(Math.round(n), 3_600_000);
58
+ }
59
+
60
+ function latencyBucket(samples) {
61
+ const values = samples.map((sample) => sample.latency_ms).filter(Number.isFinite);
62
+ if (values.length === 0) return 'unknown';
63
+ const average = values.reduce((sum, value) => sum + value, 0) / values.length;
64
+ if (average <= 30_000) return 'fast';
65
+ if (average <= 120_000) return 'medium';
66
+ return 'slow';
67
+ }
68
+
69
+ function outcomeKind(observation = {}) {
70
+ const stopReason = String(observation.stopReason ?? observation.stop_reason ?? '').toLowerCase();
71
+ if (observation.timed_out === true || stopReason === 'timeout' || stopReason === 'timed_out') return 'timeout';
72
+ const status = String(observation.status ?? '').toLowerCase();
73
+ if (status === 'done' || status === 'success' || status === 'completed') return 'success';
74
+ if (status === 'failed' || status === 'failure' || status === 'error') return 'failure';
75
+ return null;
76
+ }
77
+
78
+ function healthScore(summary) {
79
+ let score = summary.successes * 4 - summary.failures * 3 - summary.timeouts * 6;
80
+ if (summary.latency_bucket === 'medium') score -= 1;
81
+ else if (summary.latency_bucket === 'slow') score -= 2;
82
+ return score;
83
+ }
84
+
85
+ export function createAdaptiveHealthStore({ maxSamples = 32 } = {}) {
86
+ const limit = clampInteger(maxSamples, 32, 1, 32);
87
+ const history = new Map();
88
+
89
+ return {
90
+ record(ref, observation = {}) {
91
+ const key = refKey(ref, observation.role);
92
+ const outcome = outcomeKind(observation);
93
+ if (!key || !outcome) return false;
94
+ const samples = history.get(key) ?? [];
95
+ samples.push({
96
+ outcome,
97
+ latency_ms: normalizeLatencyMs(observation.latencyMs ?? observation.latency_ms),
98
+ });
99
+ if (samples.length > limit) samples.splice(0, samples.length - limit);
100
+ history.set(key, samples);
101
+ return true;
102
+ },
103
+
104
+ snapshot(ref, { role = 'worker', windowSize = ADAPTIVE_ROUTING_DEFAULTS.window_size } = {}) {
105
+ const key = refKey(ref, role);
106
+ const window = clampInteger(windowSize, ADAPTIVE_ROUTING_DEFAULTS.window_size, 1, 32);
107
+ const samples = key ? (history.get(key) ?? []).slice(-window) : [];
108
+ const summary = {
109
+ samples: samples.length,
110
+ successes: samples.filter((sample) => sample.outcome === 'success').length,
111
+ failures: samples.filter((sample) => sample.outcome === 'failure').length,
112
+ timeouts: samples.filter((sample) => sample.outcome === 'timeout').length,
113
+ latency_bucket: latencyBucket(samples),
114
+ };
115
+ return { ...summary, score: healthScore(summary) };
116
+ },
117
+
118
+ clear() {
119
+ history.clear();
120
+ },
121
+ };
122
+ }
123
+
124
+ // One bounded store per Node.js process. Hub routing and its entry wrapper share
125
+ // this module instance, so a completed opt-in Hub attempt can influence a later
126
+ // opt-in Hub selection without writing any persistent state. Process restart is
127
+ // intentionally the reset boundary.
128
+ const PROCESS_ADAPTIVE_HEALTH = createAdaptiveHealthStore();
129
+
130
+ export function getProcessAdaptiveHealthStore() {
131
+ return PROCESS_ADAPTIVE_HEALTH;
132
+ }
133
+
134
+ function uniqueCandidates(candidates) {
135
+ const seen = new Set();
136
+ const out = [];
137
+ for (const candidate of Array.isArray(candidates) ? candidates : []) {
138
+ const ref = normalizeRef(candidate);
139
+ if (!ref) continue;
140
+ const key = `${ref.provider}\0${ref.model}`;
141
+ if (seen.has(key)) continue;
142
+ seen.add(key);
143
+ out.push(ref);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function traceBase(config, reason, candidates = []) {
149
+ return {
150
+ version: ADAPTIVE_ROUTING_VERSION,
151
+ enabled: config.enabled,
152
+ decision_supported: false,
153
+ applied: false,
154
+ reason,
155
+ window_size: config.window_size,
156
+ min_samples: config.min_samples,
157
+ candidates,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Stable health-based ordering for automatically derived candidates.
163
+ *
164
+ * Explicit user priority is a hard bypass: health never changes its order.
165
+ * Candidates below min_samples are scored as neutral (0), allowing a mature
166
+ * unhealthy candidate to yield to an unknown one without overreacting to a
167
+ * single sample. Ties always retain baseline order.
168
+ */
169
+ export function rankAdaptiveCandidates(candidates, {
170
+ config: rawConfig,
171
+ healthStore = PROCESS_ADAPTIVE_HEALTH,
172
+ role = 'worker',
173
+ explicitPriority = false,
174
+ } = {}) {
175
+ const config = normalizeAdaptiveRouting(rawConfig);
176
+ const baseline = uniqueCandidates(candidates);
177
+
178
+ if (!config.enabled) {
179
+ return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.DISABLED) };
180
+ }
181
+ if (explicitPriority) {
182
+ return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.EXPLICIT_PRIORITY) };
183
+ }
184
+ if (baseline.length === 0) {
185
+ return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.NO_ELIGIBLE_CANDIDATES) };
186
+ }
187
+ if (baseline.length === 1) {
188
+ return { candidates: baseline, trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.SINGLE_CANDIDATE) };
189
+ }
190
+
191
+ const scored = baseline.map((candidate, baselineRank) => {
192
+ const summary = healthStore?.snapshot?.(candidate, { role, windowSize: config.window_size }) ?? {
193
+ samples: 0, successes: 0, failures: 0, timeouts: 0, latency_bucket: 'unknown', score: 0,
194
+ };
195
+ const mature = summary.samples >= config.min_samples;
196
+ return {
197
+ candidate,
198
+ baselineRank,
199
+ mature,
200
+ effectiveScore: mature ? summary.score : 0,
201
+ summary,
202
+ };
203
+ });
204
+
205
+ const mature = scored.filter((entry) => entry.mature);
206
+ if (mature.length === 0) {
207
+ const candidatesTrace = scored.map((entry) => ({
208
+ ...entry.candidate,
209
+ baseline_rank: entry.baselineRank,
210
+ adaptive_rank: entry.baselineRank,
211
+ health_state: 'warming',
212
+ samples: entry.summary.samples,
213
+ successes: entry.summary.successes,
214
+ failures: entry.summary.failures,
215
+ timeouts: entry.summary.timeouts,
216
+ latency_bucket: entry.summary.latency_bucket,
217
+ score: null,
218
+ }));
219
+ return {
220
+ candidates: baseline,
221
+ trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.INSUFFICIENT_HISTORY, candidatesTrace),
222
+ };
223
+ }
224
+
225
+ const hasSignal = mature.some((entry) => entry.effectiveScore !== 0);
226
+ const ranked = [...scored].sort((a, b) => {
227
+ if (a.effectiveScore !== b.effectiveScore) return b.effectiveScore - a.effectiveScore;
228
+ return a.baselineRank - b.baselineRank;
229
+ });
230
+ const orderChanged = ranked.some((entry, index) => entry.baselineRank !== index);
231
+ const rankByKey = new Map(ranked.map((entry, index) => [`${entry.candidate.provider}\0${entry.candidate.model}`, index]));
232
+ const candidatesTrace = scored.map((entry) => ({
233
+ ...entry.candidate,
234
+ baseline_rank: entry.baselineRank,
235
+ adaptive_rank: rankByKey.get(`${entry.candidate.provider}\0${entry.candidate.model}`),
236
+ health_state: entry.mature ? 'mature' : 'warming',
237
+ samples: entry.summary.samples,
238
+ successes: entry.summary.successes,
239
+ failures: entry.summary.failures,
240
+ timeouts: entry.summary.timeouts,
241
+ latency_bucket: entry.summary.latency_bucket,
242
+ score: entry.mature ? entry.effectiveScore : null,
243
+ }));
244
+
245
+ if (!hasSignal) {
246
+ return {
247
+ candidates: baseline,
248
+ trace: traceBase(config, ADAPTIVE_ROUTING_REASON_CODES.HEALTH_NEUTRAL, candidatesTrace),
249
+ };
250
+ }
251
+
252
+ const trace = traceBase(
253
+ config,
254
+ orderChanged ? ADAPTIVE_ROUTING_REASON_CODES.HEALTH_REORDERED : ADAPTIVE_ROUTING_REASON_CODES.HEALTH_ORDER_UNCHANGED,
255
+ candidatesTrace,
256
+ );
257
+ trace.decision_supported = true;
258
+ trace.applied = orderChanged;
259
+ return { candidates: ranked.map((entry) => entry.candidate), trace };
260
+ }
@@ -1,64 +1,64 @@
1
- import React from 'react';
2
-
3
- type Boundary = 'live' | 'next-workflow' | 'next-session' | 'restart-required';
4
- type ActivationEntry = { global?: Boundary; session?: Boundary | null; note?: string };
5
-
6
- const ORDER: Boundary[] = ['live', 'next-workflow', 'next-session', 'restart-required'];
7
-
8
- const LABELS = {
9
- zh: {
10
- title: '配置生效边界',
11
- hint: '这里显示全局 Settings 保存后的实际生效时机;会话内 dsh_worker_config 覆盖可能更早生效。',
12
- boundary: {
13
- live: 'Live · 当前运行时',
14
- 'next-workflow': 'Next workflow · 下一个任务',
15
- 'next-session': 'Next session · 新 CC / Codex 会话',
16
- 'restart-required': 'Restart required · 重启 DSH / MCP',
17
- } as Record<Boundary, string>,
18
- },
19
- en: {
20
- title: 'Configuration activation boundaries',
21
- hint: 'Shows when persisted Settings changes actually take effect. Session-level dsh_worker_config overrides may activate earlier.',
22
- boundary: {
23
- live: 'Live · current runtime',
24
- 'next-workflow': 'Next workflow',
25
- 'next-session': 'Next session · new CC / Codex session',
26
- 'restart-required': 'Restart required · restart DSH / MCP',
27
- } as Record<Boundary, string>,
28
- },
29
- };
30
-
31
- export function groupActivationBoundaries(activation: Record<string, ActivationEntry> = {}) {
32
- const grouped = Object.fromEntries(ORDER.map((boundary) => [boundary, [] as string[]])) as Record<Boundary, string[]>;
33
- for (const [key, entry] of Object.entries(activation)) {
34
- if (entry?.global && grouped[entry.global]) grouped[entry.global].push(key);
35
- }
36
- for (const boundary of ORDER) grouped[boundary].sort();
37
- return grouped;
38
- }
39
-
40
- export function ActivationSummary({ activation, locale }: {
41
- activation?: Record<string, ActivationEntry>;
42
- locale?: string;
43
- }) {
44
- if (!activation || Object.keys(activation).length === 0) return null;
45
- const lang = locale === 'zh' ? 'zh' : 'en';
46
- const copy = LABELS[lang];
47
- const grouped = groupActivationBoundaries(activation);
48
- return (
49
- <div style={{ border: '1px solid rgba(128,128,128,0.22)', borderRadius: 8, padding: '9px 12px', display: 'flex', flexDirection: 'column', gap: 5 }}>
50
- <div style={{ fontWeight: 600, fontSize: 12.5 }}>{copy.title}</div>
51
- <div style={{ fontSize: 11, opacity: 0.6 }}>{copy.hint}</div>
52
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '5px 12px' }}>
53
- {ORDER.map((boundary) => (
54
- <div key={boundary} style={{ minWidth: 0 }}>
55
- <div style={{ fontSize: 10.5, opacity: 0.7, fontWeight: 600 }}>{copy.boundary[boundary]}</div>
56
- <div style={{ fontSize: 10.5, opacity: 0.55, wordBreak: 'break-word' }}>
57
- {grouped[boundary].length ? grouped[boundary].join(' · ') : '—'}
58
- </div>
59
- </div>
60
- ))}
61
- </div>
62
- </div>
63
- );
64
- }
1
+ import React from 'react';
2
+
3
+ type Boundary = 'live' | 'next-workflow' | 'next-session' | 'restart-required';
4
+ type ActivationEntry = { global?: Boundary; session?: Boundary | null; note?: string };
5
+
6
+ const ORDER: Boundary[] = ['live', 'next-workflow', 'next-session', 'restart-required'];
7
+
8
+ const LABELS = {
9
+ zh: {
10
+ title: '配置生效边界',
11
+ hint: '这里显示全局 Settings 保存后的实际生效时机;会话内 dsh_worker_config 覆盖可能更早生效。',
12
+ boundary: {
13
+ live: 'Live · 当前运行时',
14
+ 'next-workflow': 'Next workflow · 下一个任务',
15
+ 'next-session': 'Next session · 新 CC / Codex 会话',
16
+ 'restart-required': 'Restart required · 重启 DSH / MCP',
17
+ } as Record<Boundary, string>,
18
+ },
19
+ en: {
20
+ title: 'Configuration activation boundaries',
21
+ hint: 'Shows when persisted Settings changes actually take effect. Session-level dsh_worker_config overrides may activate earlier.',
22
+ boundary: {
23
+ live: 'Live · current runtime',
24
+ 'next-workflow': 'Next workflow',
25
+ 'next-session': 'Next session · new CC / Codex session',
26
+ 'restart-required': 'Restart required · restart DSH / MCP',
27
+ } as Record<Boundary, string>,
28
+ },
29
+ };
30
+
31
+ export function groupActivationBoundaries(activation: Record<string, ActivationEntry> = {}) {
32
+ const grouped = Object.fromEntries(ORDER.map((boundary) => [boundary, [] as string[]])) as Record<Boundary, string[]>;
33
+ for (const [key, entry] of Object.entries(activation)) {
34
+ if (entry?.global && grouped[entry.global]) grouped[entry.global].push(key);
35
+ }
36
+ for (const boundary of ORDER) grouped[boundary].sort();
37
+ return grouped;
38
+ }
39
+
40
+ export function ActivationSummary({ activation, locale }: {
41
+ activation?: Record<string, ActivationEntry>;
42
+ locale?: string;
43
+ }) {
44
+ if (!activation || Object.keys(activation).length === 0) return null;
45
+ const lang = locale === 'zh' ? 'zh' : 'en';
46
+ const copy = LABELS[lang];
47
+ const grouped = groupActivationBoundaries(activation);
48
+ return (
49
+ <div style={{ border: '1px solid rgba(128,128,128,0.22)', borderRadius: 8, padding: '9px 12px', display: 'flex', flexDirection: 'column', gap: 5 }}>
50
+ <div style={{ fontWeight: 600, fontSize: 12.5 }}>{copy.title}</div>
51
+ <div style={{ fontSize: 11, opacity: 0.6 }}>{copy.hint}</div>
52
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '5px 12px' }}>
53
+ {ORDER.map((boundary) => (
54
+ <div key={boundary} style={{ minWidth: 0 }}>
55
+ <div style={{ fontSize: 10.5, opacity: 0.7, fontWeight: 600 }}>{copy.boundary[boundary]}</div>
56
+ <div style={{ fontSize: 10.5, opacity: 0.55, wordBreak: 'break-word' }}>
57
+ {grouped[boundary].length ? grouped[boundary].join(' · ') : '—'}
58
+ </div>
59
+ </div>
60
+ ))}
61
+ </div>
62
+ </div>
63
+ );
64
+ }