dsh-switchman 1.3.1 → 1.5.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.
package/host/teams.js ADDED
@@ -0,0 +1,430 @@
1
+ /** dsh-switchman Agent Teams layer (teams mode).
2
+ *
3
+ * Owns both switchman-side halves of the teams mode:
4
+ *
5
+ * - The dynamic `[SWITCHMAN:TEAMS]` systemPrompt section (order 10250, after
6
+ * the persona suffix at 10200, before the language section at 10300). The
7
+ * clauses it renders are exactly the team-specific parts extracted from
8
+ * the shared persona-suffix anchor in cordis.patch.yml (conservative-team-
9
+ * policy reversal, spawn_teammate trigger conditions, shared task-board
10
+ * discipline); they are injected only while the live `teamsMode` setting
11
+ * is ON, so OFF falls back to the DSH factory conservative team policy
12
+ * with zero residue (same live-reference pattern as dispatch.js's POOLS).
13
+ * - The ON-mode async orchestration, ALWAYS deferred through setImmediate so
14
+ * it never runs synchronously inside plugin apply (bundle changes must not
15
+ * nest inside the apply HMR transaction — boot/hmr forbids it): first
16
+ * `pluginManager.setBundleEnabled('@deepseek-ai/dsh-experimental-agent-
17
+ * team-profile', true)` (latched per process; never disabled on OFF), then
18
+ * — only when `syncWhitelist` is also ON — one replace-sync of the six-
19
+ * pool union into DSH's `subagent-model-selection-settings` whitelist
20
+ * (`settings.describe()` → `settings.mutate(...)`, re-describing and
21
+ * retrying up to 3 attempts on SettingsConflictError). Replace semantics:
22
+ * switchman is the single source of truth, so the deduplicated exact
23
+ * (provider, model) union overwrites allowedModels wholesale; an empty
24
+ * union skips the write (DSH rejects enabled=true with an empty list).
25
+ * Enforcement is a per-new-session snapshot, so a sync only affects
26
+ * top-level sessions composed after it.
27
+ *
28
+ * Trigger points: plugin apply (applyTeams schedules once), every successful
29
+ * POST /api/dsh-switchman/config (routes.js calls scheduleTeamsOrchestration),
30
+ * and settings/document-updated for this plugin's own namespace (volatile
31
+ * commits never remount the plugin, so edits through DSH's own settings UI
32
+ * must also trigger). Every async path catches and warns — nothing ever
33
+ * throws into the caller. The last sync outcome is module state, snapshotted
34
+ * by routes.js's whitelistSync config field.
35
+ *
36
+ * One caveat the deferral cannot fix: a setImmediate callback inherits the
37
+ * scheduling context's AsyncLocalStorage store, so when apply itself runs
38
+ * inside an HMR transaction (bundle re-enable / code reload with teamsMode
39
+ * persisted ON), the deferred run is STILL rejected as nested. That
40
+ * rejection is detected, recorded in pendingNestedRetry, and pumped again
41
+ * from the next clean-context trigger (the GET config and vision-state HTTP
42
+ * handlers call pumpTeamsOrchestration). Fail-open either way: a missed run
43
+ * only delays the bundle enable / whitelist sync until the next trigger.
44
+ */
45
+
46
+ import { LANES, laneField } from "./config.js";
47
+
48
+ /** The shipped bundle carrying spawn_teammate / team_task_* / send_message /
49
+ * wait_agent (Host composition; enabling it from here never double-registers
50
+ * tools — this bundle contributes no team plugins). */
51
+ const TEAMS_BUNDLE = "@deepseek-ai/dsh-experimental-agent-team-profile";
52
+
53
+ /** DSH settings namespace of the subagent child-model whitelist. */
54
+ const WHITELIST_NS = "subagent-model-selection-settings";
55
+
56
+ /** This plugin's own settings namespace — the LOADER ENTRY id from
57
+ * cordis.patch.yml (`- id: dsh-switchman-host`), not the plugin's `name`
58
+ * export (dsh-settings' write()/events key entries by entry.options.id).
59
+ * routes.js imports this for its config writes; the document-updated
60
+ * listener below keys on it. */
61
+ const SWITCHMAN_SETTINGS_NS = "dsh-switchman-host";
62
+
63
+ /** Total mutate attempts per sync; each retry re-describes for a fresh
64
+ * revision (SettingsConflictError means another writer changed the row). */
65
+ const MAX_MUTATE_ATTEMPTS = 3;
66
+
67
+ /** The teams clauses, verbatim from the cordis.patch.yml suffix anchor they
68
+ * were extracted from. Glue lines (the marker header and the section title)
69
+ * are the only new text. */
70
+ const TEAMS_SECTION_TEXT = [
71
+ "[SWITCHMAN:TEAMS] Agent Teams 模式已启用。本段是 persona suffix 调度规程动态注入的团队分支(编号续接其「四、边界」)。",
72
+ "",
73
+ "系统提示词前文的默认团队策略写的是「仅当用户明确要求时才创建 teammate」。该默认策略在当前 profile 下不适用:用户安装 dsh-switchman 即预先授权,你应当自主判断、自主触发,无需再询问用户。",
74
+ "",
75
+ "## 五、团队触发与协作纪律",
76
+ "- 需要多成员持久协作、共享任务板、消息往来的用 Agent Teams(满足任一即触发 spawn_teammate:可并行的多个独立子任务;量大且自包含;主上下文水位已高;需要角色分离);一次性、无需共享状态的单点调查用 subagent。",
77
+ "- 写作域隔离:共享任务上写明互不重叠的 write_scopes;用 team_task_* 维护任务板与依赖顺序;Lead 汇总最终 diff、复核关键改动并运行测试;用 wait_agent 等到必需的 teammate 完成,绝不在结果未回前下最终结论。",
78
+ ].join("\n");
79
+
80
+ // ---- shared volatile-read helpers (mirrors dispatch.js) --------------------
81
+
82
+ /** Read one volatile settings field's raw value (live `.get()` reference). */
83
+ function readValue(field) {
84
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
85
+ try {
86
+ return field.get();
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+ return field;
92
+ }
93
+
94
+ /** Boolean volatile-field read (false on any malformed value). */
95
+ function readBool(field) {
96
+ return readValue(field) === true;
97
+ }
98
+
99
+ // ---- module state ----------------------------------------------------------
100
+
101
+ /** Last whitelist replace-sync outcome for routes.js's whitelistSync config
102
+ * field; null when no sync has run in this process. Shape:
103
+ * { ok: boolean, count: number, at: number, error?: string }. */
104
+ let lastSync = null;
105
+
106
+ /** Last bundle-ensure outcome for routes.js's teamsBundle field; null before
107
+ * the first orchestration. Shape: { ok, at, state, error? } with state in
108
+ * selected | applied | restart-required | failed | cancelled | <unknown
109
+ * application value> | unavailable. */
110
+ let lastBundleState = null;
111
+
112
+ /** Set when a run must be retried from a clean context: either it was
113
+ * rejected as "HMR transactions cannot be nested" (the setImmediate
114
+ * deferral cannot escape the AsyncLocalStorage store the scheduling context
115
+ * carried — boot/hmr's runExclusive checks that store), or
116
+ * pluginManager.setBundleEnabled RESOLVED with a failed/cancelled
117
+ * application (no exception ever reaches the central catch). The next
118
+ * clean-context HTTP trigger pumps a retry; retries are strictly
119
+ * request-driven, never self-scheduling, so there is no retry loop. */
120
+ let pendingNestedRetry = false;
121
+
122
+ /** Single-flight guard: the apply + POST + document-updated triggers may
123
+ * overlap, but at most one orchestration runs at a time — requests
124
+ * arriving mid-run collapse into one rerun. */
125
+ let orchestrating = false;
126
+ let rerunRequested = false;
127
+
128
+ // ---- orchestration ---------------------------------------------------------
129
+
130
+ /** Whether an error is the HMR nesting rejection: boot/hmr's runExclusive
131
+ * throws exactly "HMR transactions cannot be nested" when the AsyncLocalStorage
132
+ * store is already set, and setImmediate callbacks inherit that store from
133
+ * the scheduling context (e.g. an apply that itself ran in a transaction). */
134
+ function isNestedHmrError(error) {
135
+ return /cannot be nested/iu.test(String(error?.message ?? ""));
136
+ }
137
+
138
+ /** Deduplicated exact (provider, model) union of the six pools. Exact string
139
+ * identity (trimmed) — DSH's whitelist authorizes exact pairs and its
140
+ * assertAllowedModelRoutes rejects repeated provider/model routes outright. */
141
+ function poolUnion(config) {
142
+ const seen = new Set();
143
+ const routes = [];
144
+ for (const lane of LANES) {
145
+ const raw = readValue(config?.[laneField(lane)]);
146
+ if (!Array.isArray(raw)) continue;
147
+ for (const entry of raw) {
148
+ if (entry === null || typeof entry !== "object") continue;
149
+ const provider = typeof entry.provider === "string" ? entry.provider.trim() : "";
150
+ const model = typeof entry.model === "string" ? entry.model.trim() : "";
151
+ if (provider === "" || model === "") continue;
152
+ const key = `${provider}\u0000${model}`;
153
+ if (seen.has(key)) continue;
154
+ seen.add(key);
155
+ routes.push({ provider, model });
156
+ }
157
+ }
158
+ return routes;
159
+ }
160
+
161
+ /** Ensure the shipped agent-team-profile bundle layer is selected in the
162
+ * profile. pluginManager.setBundleEnabled NEVER rejects: its change
163
+ * transaction swallows every error into a resolved
164
+ * {application:'failed', error} (cancellations into 'cancelled'), while
165
+ * 'restart-required' means the manifest was persisted and the tools
166
+ * activate after a host restart (boot/plugin-manager/src/index.ts:795-819).
167
+ * Treating that resolved value as success latches a false positive and
168
+ * suppresses every later retry — so only verified outcomes latch, and a
169
+ * listBundles pre-check both short-circuits the already-enabled case and
170
+ * converges with disk truth after a late failure (selectBundle persists
171
+ * before reload can throw). There is deliberately NO process-level success
172
+ * latch — the DSH-side toggle can be flipped off at any time, so every
173
+ * orchestration re-verifies via listBundles and re-enables when needed.
174
+ * A missing pluginManager service only warns; the doctrine section still
175
+ * renders, the tools simply stay absent. */
176
+ async function ensureTeamsBundle(ctx) {
177
+ const manager = ctx.get?.("pluginManager", false);
178
+ if (manager === undefined || manager === null || typeof manager.setBundleEnabled !== "function") {
179
+ lastBundleState = { ok: false, at: Date.now(), state: "unavailable" };
180
+ ctx.logger?.warn?.("dsh-switchman: pluginManager service unavailable; agent-team bundle not enabled");
181
+ return;
182
+ }
183
+ if (typeof manager.listBundles === "function") {
184
+ try {
185
+ const bundles = await manager.listBundles();
186
+ const selected = Array.isArray(bundles)
187
+ ? bundles.find((item) => item?.name === TEAMS_BUNDLE)
188
+ : undefined;
189
+ if (selected?.enabled === true) {
190
+ // Already selected in the profile manifest: no call, no reload —
191
+ // and deliberately NO process-level latch. The user may flip the
192
+ // DSH-side toggle off at any moment; every orchestration re-reads
193
+ // this disk truth instead (live-verified regression: a boot-time
194
+ // latch froze the state right before the user toggled DSH off).
195
+ lastBundleState = { ok: true, at: Date.now(), state: "selected" };
196
+ return;
197
+ }
198
+ } catch {
199
+ // Unreadable manifest: fall through and let setBundleEnabled decide.
200
+ }
201
+ }
202
+ const result = await manager.setBundleEnabled(TEAMS_BUNDLE, true);
203
+ // Whitelist latch: only the two verified-persisted outcomes count. Anything
204
+ // else — failed, cancelled, or a future unknown application value — warns
205
+ // and stays unlatched so the next trigger retries (unknown values must NOT
206
+ // be assumed successful; that is exactly the false-positive this fix ends).
207
+ const outcome = typeof result?.application === "string" ? result.application : "applied";
208
+ if (outcome !== "applied" && outcome !== "restart-required") {
209
+ // A resolved failure raises no exception, so isNestedHmrError and
210
+ // orchestrateTeams' central catch never see it. Arm the clean-context
211
+ // pump here: the next GET (Switchman page view, post-save refresh)
212
+ // retries from a plain HTTP context, where transient causes — an HMR
213
+ // transaction around reload(), a package.json lock held by a boot-time
214
+ // pnpm/refresh — are gone. Verified live: the identical call succeeds
215
+ // when made from a clean context moments after failing in-page.
216
+ pendingNestedRetry = true;
217
+ lastBundleState = {
218
+ ok: false,
219
+ at: Date.now(),
220
+ state: outcome,
221
+ error: typeof result?.error === "string" ? result.error : result?.error?.message,
222
+ };
223
+ ctx.logger?.warn?.(
224
+ `dsh-switchman: agent-team bundle enable ${outcome}: `
225
+ + `${result?.error?.message ?? result?.error ?? "unknown error"} — retrying on the next trigger`,
226
+ );
227
+ return;
228
+ }
229
+ lastBundleState = { ok: true, at: Date.now(), state: outcome };
230
+ if (outcome === "restart-required") {
231
+ ctx.logger?.info?.("dsh-switchman: agent-team bundle persisted; restart DSH to activate team tools in new sessions");
232
+ } else {
233
+ ctx.logger?.info?.(`dsh-switchman: agent-team bundle enabled (${TEAMS_BUNDLE})`);
234
+ }
235
+ }
236
+
237
+ /** One replace-sync of the six-pool union into the DSH whitelist. describe →
238
+ * mutate with the fresh revision; SettingsConflictError retries (re-describe
239
+ * first) up to MAX_MUTATE_ATTEMPTS total. Every terminal outcome lands in
240
+ * the module-level lastSync status; nothing throws. */
241
+ async function syncWhitelist(ctx, config) {
242
+ const routes = poolUnion(config);
243
+ if (routes.length === 0) {
244
+ // DSH's invariant: enabled=true with an empty allowedModels breaks
245
+ // every new session's composition — skipping is the only safe move.
246
+ lastSync = {
247
+ ok: false,
248
+ count: 0,
249
+ at: Date.now(),
250
+ error: "six-pool union is empty; sync skipped (enabled=true requires a non-empty allowedModels)",
251
+ };
252
+ ctx.logger?.warn?.(`dsh-switchman: whitelist sync skipped: ${lastSync.error}`);
253
+ return;
254
+ }
255
+ const settings = ctx.settings;
256
+ if (settings === null || typeof settings !== "object" || typeof settings.describe !== "function" || typeof settings.mutate !== "function") {
257
+ lastSync = { ok: false, count: routes.length, at: Date.now(), error: "settings service unavailable" };
258
+ ctx.logger?.warn?.(`dsh-switchman: whitelist sync failed: ${lastSync.error}`);
259
+ return;
260
+ }
261
+ for (let attempt = 1; attempt <= MAX_MUTATE_ATTEMPTS; attempt += 1) {
262
+ let revision;
263
+ try {
264
+ const described = settings.describe();
265
+ const row = Array.isArray(described) ? described.find((entry) => entry?.ns === WHITELIST_NS) : undefined;
266
+ if (row === undefined || !Number.isFinite(row.revision)) {
267
+ lastSync = {
268
+ ok: false,
269
+ count: routes.length,
270
+ at: Date.now(),
271
+ error: `settings namespace "${WHITELIST_NS}" not found or carries no revision`,
272
+ };
273
+ ctx.logger?.warn?.(`dsh-switchman: whitelist sync failed: ${lastSync.error}`);
274
+ return;
275
+ }
276
+ revision = row.revision;
277
+ } catch (error) {
278
+ // Drift-guard: describe() is a synchronous file read in current DSH
279
+ // (it never crosses runExclusive), so this branch is unreachable
280
+ // today — kept in case a future revision routes it through the
281
+ // editor lock.
282
+ if (isNestedHmrError(error)) {
283
+ pendingNestedRetry = true;
284
+ ctx.logger?.info?.("dsh-switchman: whitelist describe deferred (HMR transaction active); the next clean-context trigger retries");
285
+ return;
286
+ }
287
+ lastSync = { ok: false, count: routes.length, at: Date.now(), error: error?.message ?? String(error) };
288
+ ctx.logger?.warn?.(`dsh-switchman: whitelist describe failed: ${lastSync.error}`);
289
+ return;
290
+ }
291
+ try {
292
+ await settings.mutate(
293
+ WHITELIST_NS,
294
+ [
295
+ { op: "set", path: ["enabled"], value: true },
296
+ { op: "set", path: ["allowedModels"], value: routes },
297
+ ],
298
+ revision,
299
+ );
300
+ lastSync = { ok: true, count: routes.length, at: Date.now() };
301
+ ctx.logger?.info?.(
302
+ `dsh-switchman: whitelist replace-synced (${routes.length} routes; applies to newly composed top-level sessions only)`,
303
+ );
304
+ return;
305
+ } catch (error) {
306
+ // Conflict = another writer changed the row between describe and
307
+ // mutate: re-describe and retry. A nested-HMR rejection defers the
308
+ // whole sync to the next clean trigger (transient — no failure
309
+ // status is recorded). Anything else is terminal.
310
+ if (isNestedHmrError(error)) {
311
+ pendingNestedRetry = true;
312
+ ctx.logger?.info?.("dsh-switchman: whitelist sync deferred (HMR transaction active); the next clean-context trigger retries");
313
+ return;
314
+ }
315
+ if (error?.name === "SettingsConflictError" && attempt < MAX_MUTATE_ATTEMPTS) continue;
316
+ lastSync = { ok: false, count: routes.length, at: Date.now(), error: error?.message ?? String(error) };
317
+ ctx.logger?.warn?.(`dsh-switchman: whitelist sync failed after ${attempt} attempt(s): ${lastSync.error}`);
318
+ return;
319
+ }
320
+ }
321
+ }
322
+
323
+ /** The orchestration behind the two INDEPENDENT switches: teamsMode drives
324
+ * the bundle ensure (+ the doctrine section, handled elsewhere);
325
+ * syncWhitelist drives the whitelist replace-sync — either may be on alone.
326
+ * Single-flight (see orchestrating); every failure is caught and warned —
327
+ * a nested-HMR rejection arms the clean-context retry pump instead —
328
+ * never rethrown. */
329
+ async function orchestrateTeams(ctx, config) {
330
+ if (orchestrating) {
331
+ rerunRequested = true;
332
+ return;
333
+ }
334
+ orchestrating = true;
335
+ try {
336
+ if (readBool(config?.teamsMode) === true) {
337
+ await ensureTeamsBundle(ctx).catch((error) => {
338
+ // A nested rejection must defer the WHOLE run (the whitelist
339
+ // sync would hit the same wall) — rethrow to the central catch.
340
+ if (isNestedHmrError(error)) throw error;
341
+ ctx.logger?.warn?.(`dsh-switchman: agent-team bundle enable failed: ${error?.message ?? error}`);
342
+ });
343
+ }
344
+ if (readBool(config?.syncWhitelist) === true) await syncWhitelist(ctx, config);
345
+ } catch (error) {
346
+ if (isNestedHmrError(error)) {
347
+ pendingNestedRetry = true;
348
+ ctx.logger?.info?.("dsh-switchman: teams orchestration deferred (HMR transaction active); the next clean-context trigger retries");
349
+ return;
350
+ }
351
+ ctx.logger?.warn?.(`dsh-switchman: teams orchestration failed: ${error?.message ?? error}`);
352
+ } finally {
353
+ orchestrating = false;
354
+ if (rerunRequested) {
355
+ rerunRequested = false;
356
+ scheduleTeamsOrchestration(ctx, config);
357
+ }
358
+ }
359
+ }
360
+
361
+ /** Last whitelist replace-sync outcome ({ ok, count, at, error? } | null).
362
+ * Raw module state — routes.js gates exposure on teamsMode && syncWhitelist. */
363
+ function teamsBundleState() {
364
+ return lastBundleState === null ? null : { ...lastBundleState };
365
+ }
366
+
367
+ function teamsSyncSnapshot() {
368
+ return lastSync;
369
+ }
370
+
371
+ /** Fire-and-forget orchestration trigger. Safe from any code path (plugin
372
+ * apply, HTTP handlers): the work always lands on a setImmediate turn, so
373
+ * nothing runs synchronously inside apply or a route handler. The deferral
374
+ * does NOT escape an enclosing HMR transaction's AsyncLocalStorage store —
375
+ * a nested rejection is detected here and retried from the next clean
376
+ * trigger (see pendingNestedRetry / pumpTeamsOrchestration). */
377
+ function scheduleTeamsOrchestration(ctx, config) {
378
+ try {
379
+ // Either switch alone is a reason to run (they are independent).
380
+ if (readBool(config?.teamsMode) !== true && readBool(config?.syncWhitelist) !== true) return;
381
+ setImmediate(() => {
382
+ Promise.resolve(orchestrateTeams(ctx, config)).catch((error) => {
383
+ ctx.logger?.warn?.(`dsh-switchman: teams orchestration crashed: ${error?.message ?? error}`);
384
+ });
385
+ });
386
+ } catch (error) {
387
+ ctx.logger?.warn?.(`dsh-switchman: teams orchestration scheduling failed: ${error?.message ?? error}`);
388
+ }
389
+ }
390
+
391
+ /** Retry pump for runs rejected as nested: call from clean-context code
392
+ * paths (HTTP handlers never run inside an HMR transaction). A no-op
393
+ * single-boolean check when nothing is pending; a retry that fails nested
394
+ * again simply re-arms the flag, so retries stay request-driven. */
395
+ function pumpTeamsOrchestration(ctx, config) {
396
+ if (!pendingNestedRetry) return;
397
+ pendingNestedRetry = false;
398
+ scheduleTeamsOrchestration(ctx, config);
399
+ }
400
+
401
+ /** Mount the teams layer: the dynamic `[SWITCHMAN:TEAMS]` section (empty
402
+ * while OFF — the live reference makes toggling instant) plus the
403
+ * orchestration triggers (apply-time scheduling and settings-document
404
+ * updates for this plugin's own namespace). */
405
+ function applyTeams(ctx, config) {
406
+ ctx.systemPrompt.section({
407
+ name: "switchman:teams",
408
+ order: 10250,
409
+ interpolate: false,
410
+ text: () => {
411
+ try {
412
+ if (readBool(config.teamsMode) !== true) return "";
413
+ return TEAMS_SECTION_TEXT;
414
+ } catch {
415
+ return "";
416
+ }
417
+ },
418
+ });
419
+ // Volatile commits never remount the plugin, so teamsMode/syncWhitelist
420
+ // (or pool) edits through DSH's OWN settings UI — which bypass our POST
421
+ // config route — must re-trigger the orchestration too. The settings
422
+ // service emits this app-wide with no emitting thisArg, so a listener on
423
+ // this plugin's context receives every namespace's event; filter on ns.
424
+ ctx.on("settings/document-updated", (ns) => {
425
+ if (ns === SWITCHMAN_SETTINGS_NS) scheduleTeamsOrchestration(ctx, config);
426
+ });
427
+ scheduleTeamsOrchestration(ctx, config);
428
+ }
429
+
430
+ export { applyTeams, pumpTeamsOrchestration, SWITCHMAN_SETTINGS_NS, scheduleTeamsOrchestration, teamsBundleState, teamsSyncSnapshot };
@@ -0,0 +1,35 @@
1
+ /** dsh-switchman UI-locale bridge store (host half).
2
+ *
3
+ * The DeepSeek Harness UI language resolves on the renderer as
4
+ * Host preference (settings ns "locale", field "preference") ??
5
+ * navigator.languages ?? "en" (dsh-client-locale/lib/client.js). The host
6
+ * half can read only the first link — when the user never explicitly chose
7
+ * a language there is no host-side trace of the navigator fallback. So the
8
+ * browser half reports the final active locale through
9
+ * POST /api/dsh-switchman/ui-locale whenever it mounts; lang.js merges the
10
+ * two sources (explicit preference wins, reported locale fills the gap).
11
+ *
12
+ * Module-level singleton on purpose: exactly one host bundle instance runs
13
+ * per profile process, and the route family shares the value with the
14
+ * language layer without extra wiring. Fail-open everywhere.
15
+ */
16
+
17
+ /** Last locale tag reported by the client half ("" = never reported). */
18
+ let reported = "";
19
+
20
+ /** Normalize a reported tag: lowercase, trimmed, bounded (defensive). */
21
+ function normalizeTag(value) {
22
+ if (typeof value !== "string") return "";
23
+ const tag = value.trim().toLowerCase();
24
+ return tag.length > 0 && tag.length <= 16 ? tag : "";
25
+ }
26
+
27
+ /** Store the client-reported active UI locale (unusable values ignored). */
28
+ export function setReportedUiLocale(value) {
29
+ reported = normalizeTag(value);
30
+ }
31
+
32
+ /** The client-reported active UI locale ("" when none arrived yet). */
33
+ export function reportedUiLocale() {
34
+ return reported;
35
+ }