@aiwayds/dsh-tui-pi 2.20.0 → 2.22.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 (70) hide show
  1. package/README.md +19 -8
  2. package/README.zh-CN.md +19 -8
  3. package/cordis.patch.yml +11 -2
  4. package/icon.svg +7 -0
  5. package/lib/agents.js +23 -3
  6. package/lib/agents.js.map +1 -1
  7. package/lib/btw-overlay.d.ts +18 -2
  8. package/lib/btw-overlay.js +55 -15
  9. package/lib/btw-overlay.js.map +1 -1
  10. package/lib/btw.d.ts +43 -5
  11. package/lib/btw.js +40 -6
  12. package/lib/btw.js.map +1 -1
  13. package/lib/dsh-events.d.ts +23 -9
  14. package/lib/dsh-events.js +20 -8
  15. package/lib/dsh-events.js.map +1 -1
  16. package/lib/history.d.ts +8 -2
  17. package/lib/history.js +4 -1
  18. package/lib/history.js.map +1 -1
  19. package/lib/index.d.ts +3 -1
  20. package/lib/index.js +119 -61
  21. package/lib/index.js.map +1 -1
  22. package/lib/live-widgets.js +6 -3
  23. package/lib/live-widgets.js.map +1 -1
  24. package/lib/login.js.map +1 -1
  25. package/lib/messages.d.ts +10 -0
  26. package/lib/messages.js +39 -0
  27. package/lib/messages.js.map +1 -1
  28. package/lib/preset.d.ts +128 -58
  29. package/lib/preset.js +208 -113
  30. package/lib/preset.js.map +1 -1
  31. package/lib/remote-tail.js +3 -0
  32. package/lib/remote-tail.js.map +1 -1
  33. package/lib/selectors.d.ts +4 -1
  34. package/lib/selectors.js +8 -3
  35. package/lib/selectors.js.map +1 -1
  36. package/lib/session.d.ts +3 -1
  37. package/lib/session.js +16 -11
  38. package/lib/session.js.map +1 -1
  39. package/lib/sessions.d.ts +7 -0
  40. package/lib/sessions.js +15 -1
  41. package/lib/sessions.js.map +1 -1
  42. package/lib/settings.d.ts +17 -11
  43. package/lib/settings.js +35 -26
  44. package/lib/settings.js.map +1 -1
  45. package/lib/source-kind.d.ts +26 -0
  46. package/lib/source-kind.js +17 -0
  47. package/lib/source-kind.js.map +1 -0
  48. package/lib/subagent-policy.js +1 -1
  49. package/lib/subagent-policy.js.map +1 -1
  50. package/lib/subagent-viewer.d.ts +3 -3
  51. package/lib/subagent-viewer.js +9 -6
  52. package/lib/subagent-viewer.js.map +1 -1
  53. package/lib/theme-settings.d.ts +353 -159
  54. package/lib/theme-settings.js +258 -336
  55. package/lib/theme-settings.js.map +1 -1
  56. package/lib/tokens.d.ts +5 -3
  57. package/lib/tokens.js +5 -8
  58. package/lib/tokens.js.map +1 -1
  59. package/locale/en.json +6 -0
  60. package/locale/ja.json +6 -0
  61. package/locale/ko.json +6 -0
  62. package/locale/zh-CN.json +6 -0
  63. package/locales/en.json +7 -2
  64. package/locales/ja.json +7 -2
  65. package/locales/ko.json +7 -2
  66. package/locales/zh-CN.json +7 -2
  67. package/package.json +22 -16
  68. package/scripts/diagnose-container-tui.sh +3 -3
  69. package/scripts/smoke-boot.mjs +2 -2
  70. package/skills/dsh-tui-pi-config/SKILL.md +11 -7
@@ -1,22 +1,36 @@
1
1
  /**
2
- * Theme settings: persists the user's theme preference, the think/tool panel
3
- * height, and the subagent concurrency/rounds limits under the `dsh-tui`
4
- * settings namespace, surfaced by the /settings browser and the /theme
5
- * command. The theme and panel-height preferences are read once at TUI
6
- * startup (`readThemePreference` / `readPanelHeightPreference`); the subagent
7
- * limits are read live at every policy decision (`readSubagentLimits`). The
8
- * namespace is marked `applies: 'live'`: a committed change (the /theme
9
- * picker, the /settings browser, an external edit) is pushed through the
10
- * watch hook, so the running TUI repaints without a restart.
2
+ * Theme settings: the dsh-tui entry configuration — the user's theme
3
+ * preference, the think/tool panel height, the UI language, the footer-hint
4
+ * selection, the icon-set mode and the subagent concurrency/rounds limits.
11
5
  *
12
- * The session-management sections (`retention`, `resume`) ride the same
13
- * namespace but are read from the descriptor's USER layer
14
- * (`readSessionManagementExplicit`), not the resolved value: only a field
15
- * the user explicitly wrote to settings.yaml is an override — the resolved
16
- * value's baked-in defaults must not shadow the DSH_TUI_RETENTION_* /
17
- * DSH_TUI_RESUME_* environment variables (precedence: settings explicit >
18
- * env > default; the janitor consumes its values at next startup, the
19
- * /resume filter at every picker open).
6
+ * dsh 0.1.7 model (breaking change from 0.1.5): the runtime
7
+ * namespace-registration API is GONE. A plugin declares its
8
+ * user-facing configuration as a `static Config` schema; the loader projects
9
+ * every `.volatile()` field into the settings surface (describe / update /
10
+ * mutate) and hands the resolved values to `apply(ctx, config)` — volatile
11
+ * fields arrive as live `Volatile<T>` references. Declaration IS
12
+ * registration: there is nothing to register at runtime anymore.
13
+ *
14
+ * Reading: `config.field.get()` — a deep-frozen snapshot, refreshed IN PLACE
15
+ * by the loader on every volatile-only commit (the fiber never remounts, the
16
+ * references stay identical), so a value read is always current.
17
+ *
18
+ * Hot-apply: the plugin subscribes to `settings/document-updated` (the
19
+ * 0.1.5 per-namespace watch-hook replacement) and re-reads the references on
20
+ * every event. The
21
+ * event also fires for this TUI's own writes; every sink downstream is an
22
+ * idempotent no-op on an unchanged value (theme-bundle identity guard, same
23
+ * height, same hints), so the echo is harmless.
24
+ *
25
+ * Session-management sections (`retention`, `resume`, `askUser`) keep their
26
+ * USER-layer precedence seam: only a field the user explicitly wrote into the
27
+ * profile patch (`cordis.patch.yml` → entry `dsh-tui` → `config:`; the 0.1.5
28
+ * `settings.yaml` document is auto-imported there once on first 0.1.7 boot)
29
+ * is an override — the resolved value's baked-in defaults must not shadow the
30
+ * DSH_TUI_RETENTION_* / DSH_TUI_RESUME_* / DSH_TUI_ASK_USER_* environment
31
+ * variables (precedence: settings explicit > env > default). Those readers
32
+ * go through `settings.describe()[].user`, the only channel exposing the raw
33
+ * explicit layer.
20
34
  */
21
35
  import { SettingsConflictError, } from '@deepseek-ai/dsh-settings';
22
36
  import z from '@deepseek-ai/schemastery';
@@ -27,16 +41,14 @@ import { narrowStringList } from "./model-list.js";
27
41
  import { RETENTION_MAX_AGE_DAYS, RETENTION_MAX_COUNT, RETENTION_MIN_IDLE_HOURS } from "./retention.js";
28
42
  import { RESUME_MAX_AGE_DAYS, RESUME_MIN_BYTES } from "./sessions.js";
29
43
  import { ASK_USER_ABSOLUTE_MINUTES_DEFAULT, ASK_USER_IDLE_MINUTES_DEFAULT } from "./ask-user.js";
30
- import { emitNotice } from "./notice-bridge.js";
31
44
  /**
32
- * Settings namespace carrying the persisted dsh-tui preferences.
45
+ * Settings entry id carrying the persisted dsh-tui preferences.
33
46
  *
34
- * dsh-settings 0.1.2-alpha.3 removed the runtime settingsNamespace() helper
35
- * (and the SettingsNamespace constructor it returned): a plain literal is the
36
- * supported spelling — register() brand-checks it at the type level
37
- * (SettingsNamespaceInput) and validates the same lowercase-hyphenated pattern
38
- * at runtime (parseSettingsNamespace). Comparisons against a descriptor's
39
- * branded `ns` stay exact string equality.
47
+ * This is BOTH the profile patch entry id (cordis.patch.yml mounts this
48
+ * package as `- id: dsh-tui`) and the legacy settings.yaml section name —
49
+ * keeping them identical is what lets the 0.1.7 one-time settings.yaml
50
+ * import (`settings.yaml` → renamed `.imported`, sections merged into the
51
+ * matching entry's config) pick up every existing user value without a shim.
40
52
  */
41
53
  export const THEME_SETTINGS_NAMESPACE = 'dsh-tui';
42
54
  /** The footer CH default: per-message, matching the pi-tui footer. */
@@ -48,13 +60,12 @@ export function narrowCacheHitMode(value) {
48
60
  return value === 'session' ? 'session' : DEFAULT_CACHE_HIT_MODE;
49
61
  }
50
62
  /**
51
- * Default subagent limits, applied whenever the settings service, namespace,
52
- * or a field cannot be read. 4 concurrent children and 75 rounds per child
53
- * are the documented out-of-the-box behavior (75 rounds = 75 LLM
54
- * round-trips, headroom for heavy delegated tasks while still capping a
55
- * runaway child); the native `subagent` tool is disabled by default — the
56
- * TUI's user delegates through registered agents (toggle it in /agents → l
57
- * limits when the plain tool is needed again).
63
+ * Default subagent limits, applied whenever the entry config cannot be read.
64
+ * 4 concurrent children and 75 rounds per child are the documented
65
+ * out-of-the-box behavior (75 rounds = 75 LLM round-trips, headroom for heavy
66
+ * delegated tasks while still capping a runaway child); the native `subagent`
67
+ * tool is disabled by default — the TUI's user delegates through registered
68
+ * agents (toggle it in /agents → l limits when the plain tool is needed again).
58
69
  */
59
70
  export const DEFAULT_SUBAGENT_LIMITS = Object.freeze({
60
71
  maxAgents: 4,
@@ -63,41 +74,66 @@ export const DEFAULT_SUBAGENT_LIMITS = Object.freeze({
63
74
  disableSubagent: true,
64
75
  registeredOnly: false,
65
76
  });
66
- /** Schema of the `dsh-tui` settings section. */
67
- const THEME_SETTINGS_SCHEMA = z.object({
77
+ /**
78
+ * The `dsh-tui` entry Config schema — the whole settings surface. Exported
79
+ * from the plugin root (src/index.ts re-exports it): the loader reads
80
+ * `plugin.Config` off the module namespace and validates + resolves the
81
+ * entry's config against it; every field is `.volatile()`, so the settings
82
+ * browser lists all of them and a legacy settings.yaml `dsh-tui:` section
83
+ * imports wholesale (an import carrying any non-volatile field would be
84
+ * rejected as a whole).
85
+ *
86
+ * Plain z.number() (not z.natural()) inside `retention`/`resume`/`askUser` on
87
+ * purpose, same as the 0.1.5 schema: a range-constrained field lets one
88
+ * hand-edited out-of-range number get the whole volatile-only update refused
89
+ * (the write is rejected and the raw value still lands on disk — see the
90
+ * dsh 0.1.7 "not volatile / validation" semantics), so the per-field range
91
+ * check happens in the readers (resolveRetentionConfig / resolveResumeConfig /
92
+ * resolveAskUserTimeouts), which fall back to env/defaults with one stderr
93
+ * line instead.
94
+ */
95
+ export const Config = z.object({
68
96
  language: z
69
97
  .string()
70
98
  .default(DEFAULT_LANGUAGE)
99
+ .volatile()
71
100
  .description(t('settings.language.description')),
72
101
  theme: z
73
102
  .string()
74
103
  .default('auto')
104
+ .volatile()
75
105
  .description(t('settings.theme.description')),
76
106
  panelHeight: z
77
107
  .union(['1', '5', '7', '10', 'all'])
78
108
  .default(DEFAULT_PANEL_HEIGHT)
109
+ .volatile()
79
110
  .description(t('settings.panelHeight.description')),
80
111
  // `z.natural()` is schemastery's constraint for a non-negative integer
81
112
  // (the `z.number().int().min(0)` intent — no `.int()` chain exists here).
82
113
  maxAgents: z
83
114
  .natural()
84
115
  .default(DEFAULT_SUBAGENT_LIMITS.maxAgents)
116
+ .volatile()
85
117
  .description(t('settings.maxAgents.description')),
86
118
  maxRounds: z
87
119
  .natural()
88
120
  .default(DEFAULT_SUBAGENT_LIMITS.maxRounds)
121
+ .volatile()
89
122
  .description(t('settings.maxRounds.description')),
90
123
  maxRoundsGrace: z
91
124
  .natural()
92
125
  .default(DEFAULT_SUBAGENT_LIMITS.maxRoundsGrace)
126
+ .volatile()
93
127
  .description(t('settings.maxRoundsGrace.description')),
94
128
  disableSubagent: z
95
129
  .boolean()
96
130
  .default(DEFAULT_SUBAGENT_LIMITS.disableSubagent)
131
+ .volatile()
97
132
  .description(t('settings.disableSubagent.description')),
98
133
  registeredOnly: z
99
134
  .boolean()
100
135
  .default(DEFAULT_SUBAGENT_LIMITS.registeredOnly)
136
+ .volatile()
101
137
  .description(t('settings.registeredOnly.description')),
102
138
  footerHints: z
103
139
  .object({
@@ -110,34 +146,33 @@ const THEME_SETTINGS_SCHEMA = z.object({
110
146
  history: z.boolean().default(true).description(t('settings.footerHints.history.description')),
111
147
  })
112
148
  .default({ ...DEFAULT_FOOTER_HINTS })
149
+ .volatile()
113
150
  .description(t('settings.footerHints.description')),
114
151
  cacheHitMode: z
115
152
  .union(['lastMessage', 'session'])
116
153
  .default(DEFAULT_CACHE_HIT_MODE)
154
+ .volatile()
117
155
  .description(t('settings.cacheHitMode.description')),
118
156
  iconSet: z
119
157
  .union(['auto', 'nerdfont', 'plain'])
120
158
  .default('auto')
159
+ .volatile()
121
160
  .description(t('settings.iconSet.description')),
122
161
  rememberPreset: z
123
162
  .boolean()
124
163
  .default(true)
164
+ .volatile()
125
165
  .description(t('settings.rememberPreset.description')),
126
166
  favoriteModels: z
127
167
  .array(z.string())
128
168
  .default([])
169
+ .volatile()
129
170
  .description(t('settings.favoriteModels.description')),
130
171
  hiddenModels: z
131
172
  .array(z.string())
132
173
  .default([])
174
+ .volatile()
133
175
  .description(t('settings.hiddenModels.description')),
134
- // Plain z.number() (not z.natural()) on purpose: the settings service
135
- // validates the stored section against this schema at registration and
136
- // fails LOUD, so a range-constrained schema would let one hand-edited
137
- // out-of-range number take the whole dsh-tui namespace (theme, panel
138
- // height, everything) down with it. The per-field range check happens in
139
- // the readers (resolveRetentionConfig / resolveResumeConfig), which fall
140
- // back to env/defaults with one stderr line instead.
141
176
  retention: z
142
177
  .object({
143
178
  maxCount: z
@@ -158,6 +193,7 @@ const THEME_SETTINGS_SCHEMA = z.object({
158
193
  maxAgeDays: RETENTION_MAX_AGE_DAYS,
159
194
  minIdleHours: RETENTION_MIN_IDLE_HOURS,
160
195
  })
196
+ .volatile()
161
197
  .description(t('settings.retention.description')),
162
198
  resume: z
163
199
  .object({
@@ -171,6 +207,7 @@ const THEME_SETTINGS_SCHEMA = z.object({
171
207
  .description(t('settings.resume.minBytes.description')),
172
208
  })
173
209
  .default({ maxAgeDays: RESUME_MAX_AGE_DAYS, minBytes: RESUME_MIN_BYTES })
210
+ .volatile()
174
211
  .description(t('settings.resume.description')),
175
212
  askUser: z
176
213
  .object({
@@ -184,101 +221,34 @@ const THEME_SETTINGS_SCHEMA = z.object({
184
221
  .description(t('settings.askUser.absoluteMinutes.description')),
185
222
  })
186
223
  .default({ idleMinutes: ASK_USER_IDLE_MINUTES_DEFAULT, absoluteMinutes: ASK_USER_ABSOLUTE_MINUTES_DEFAULT })
224
+ .volatile()
187
225
  .description(t('settings.askUser.description')),
188
226
  });
189
- /** Composition entry below the user layer: fall back to the defaults. */
190
- const THEME_SETTINGS_ENTRY = {
191
- language: DEFAULT_LANGUAGE,
192
- theme: 'auto',
193
- panelHeight: DEFAULT_PANEL_HEIGHT,
194
- maxAgents: DEFAULT_SUBAGENT_LIMITS.maxAgents,
195
- maxRounds: DEFAULT_SUBAGENT_LIMITS.maxRounds,
196
- maxRoundsGrace: DEFAULT_SUBAGENT_LIMITS.maxRoundsGrace,
197
- disableSubagent: DEFAULT_SUBAGENT_LIMITS.disableSubagent,
198
- registeredOnly: DEFAULT_SUBAGENT_LIMITS.registeredOnly,
199
- footerHints: { ...DEFAULT_FOOTER_HINTS },
200
- cacheHitMode: DEFAULT_CACHE_HIT_MODE,
201
- iconSet: 'auto',
202
- rememberPreset: true,
203
- favoriteModels: [],
204
- hiddenModels: [],
205
- retention: {
206
- maxCount: RETENTION_MAX_COUNT,
207
- maxAgeDays: RETENTION_MAX_AGE_DAYS,
208
- minIdleHours: RETENTION_MIN_IDLE_HOURS,
209
- },
210
- resume: { maxAgeDays: RESUME_MAX_AGE_DAYS, minBytes: RESUME_MIN_BYTES },
211
- askUser: { idleMinutes: ASK_USER_IDLE_MINUTES_DEFAULT, absoluteMinutes: ASK_USER_ABSOLUTE_MINUTES_DEFAULT },
212
- };
213
227
  /**
214
- * In-flight namespace registration. The registration rides the settings
215
- * injection fiber, so a read issued right after `registerThemeSettings`
216
- * would not see the namespace yet; `readThemePreference` awaits this promise
217
- * (bounded) before describing. `undefined` until the first registration.
228
+ * The schema's own defaults, resolved ONCE as volatile references — the
229
+ * fallback for every reader while no entry config has been bound (a test
230
+ * driving `apply(ctx)` without a loader, or a read before `apply` ran).
218
231
  */
219
- let registrationPromise;
232
+ const DEFAULT_TUI_SETTINGS = Config({});
233
+ /** The live entry config handed to `apply` by the loader (undefined = not bound yet). */
234
+ let boundConfig;
220
235
  /**
221
- * Register the `dsh-tui` settings namespace with the settings provider.
222
- *
223
- * This registers directly through the provider (not through a
224
- * section-install helper): the registration rides the scoped injection fiber
225
- * and disappears with the settings service. `onPreferenceChange`, when given,
226
- * receives every committed change (including this TUI's own writes) through
227
- * the scope's watch hook; callers guard re-applies by theme-bundle identity
228
- * and height change, so an echoed self-write is a no-op. No source thunk is
229
- * needed — the read helpers read the resolved values on demand at TUI
230
- * startup.
231
- *
232
- * @param ctx - plugin context; does nothing while no settings service is mounted.
233
- * @param onPreferenceChange - hot-reload sink for committed `dsh-tui` theme,
234
- * panel-height, footer-hints, icon-set and language changes; `undefined` when the
235
- * namespace is already registered (a reloaded plugin instance, a second mount
236
- * of this bundle) or registration fails.
236
+ * Bind the loader-resolved entry config (the `config` parameter of
237
+ * `apply(ctx, config)`). Called once per plugin application; a `/reload`
238
+ * re-runs `apply` and re-binds. Volatile-only commits later swap the values
239
+ * behind the SAME references in place, so the bound object stays current
240
+ * without rebinding.
237
241
  */
238
- export function registerThemeSettings(ctx, onPreferenceChange) {
239
- registrationPromise = new Promise(resolve => {
240
- ctx.inject(['settings'], (sctx) => {
241
- try {
242
- // The namespace may already be registered (a reloaded plugin instance,
243
- // a second mount of this bundle): `register` throws on duplicates, and
244
- // the existing registration already serves the same schema — skip.
245
- if (sctx.settings.describe().some((descriptor) => descriptor.ns === THEME_SETTINGS_NAMESPACE)) {
246
- resolve();
247
- return;
248
- }
249
- const scope = sctx.settings.register(THEME_SETTINGS_NAMESPACE, THEME_SETTINGS_SCHEMA, {
250
- base: THEME_SETTINGS_ENTRY,
251
- // 'live': a committed change takes effect immediately — the TUI
252
- // hot-applies the theme bundle and the panel height via the watch
253
- // hook below. 'restart' was the old contract, when every component
254
- // baked its theme at startup.
255
- applies: 'live',
256
- });
257
- if (onPreferenceChange !== undefined) {
258
- scope.watch((next) => {
259
- // The resolved section is `{ language: ..., theme: ...,
260
- // panelHeight: ..., footerHints: {...}, iconSet: ... }` — narrow
261
- // the unknown to the observed fields.
262
- const section = next;
263
- const theme = section.theme;
264
- const panelHeight = section.panelHeight;
265
- onPreferenceChange(typeof theme === 'string' && theme !== '' ? theme : 'auto', isPanelHeight(panelHeight) ? panelHeight : DEFAULT_PANEL_HEIGHT, narrowFooterHints(section.footerHints), narrowIconSet(section.iconSet), narrowLanguage(section.language));
266
- });
267
- }
268
- }
269
- catch (error) {
270
- // TUI startup awaits `registrationPromise` — it must settle no matter
271
- // what, so a failed registration degrades to 'auto' instead of
272
- // hanging. Leave a trace for the operator: this fires during
273
- // apply(), before the TUI's notice sink exists, so the message goes
274
- // through the shared bridge and surfaces above the footer once the
275
- // first frame lands (never raw stderr — the alt-screen owns the
276
- // terminal by then).
277
- emitNotice(`settings namespace registration failed: ${error instanceof Error ? error.message : String(error)}`);
278
- }
279
- resolve();
280
- });
281
- });
242
+ export function bindTuiConfig(config) {
243
+ boundConfig = config;
244
+ }
245
+ /** The config to read: the bound entry config, else the schema defaults. */
246
+ function tuiConfig() {
247
+ return boundConfig ?? DEFAULT_TUI_SETTINGS;
248
+ }
249
+ /** Resolve an export of the Config schema into a `TuiSettings`-shaped object (test helper). */
250
+ export function resolveTuiSettings(overrides = {}) {
251
+ return Config(overrides);
282
252
  }
283
253
  /** Validate an unknown `footerHints` value into the typed shape (defaults win). */
284
254
  function narrowFooterHints(value) {
@@ -304,150 +274,112 @@ export function narrowLanguage(value) {
304
274
  function narrowRememberPreset(value) {
305
275
  return typeof value === 'boolean' ? value : true;
306
276
  }
307
- /**
308
- * The `dsh-tui` namespace descriptor, after waiting for the in-flight
309
- * registration — the shared plumbing of every async reader below.
310
- *
311
- * The registration is delivered through the settings injection fiber, so the
312
- * value may not be visible synchronously right after `registerThemeSettings`:
313
- * the settings service mounts asynchronously (its init sets up the provider,
314
- * a file watcher, ...), a tick after the registration request in the dsh
315
- * profile. Wait for the registration to land before describing — bounded, so
316
- * a settings-less deployment degrades to the defaults instead of hanging TUI
317
- * startup. Without a registration request there is nothing to wait for.
318
- *
319
- * @returns the descriptor, or `undefined` when no registration is in
320
- * flight, the settings service is absent, or the namespace has not landed.
321
- */
322
- async function registeredDescriptor(ctx) {
323
- if (registrationPromise === undefined)
324
- return undefined;
325
- let fallback;
326
- await Promise.race([
327
- registrationPromise,
328
- new Promise(resolve => { fallback = setTimeout(resolve, 2000); }),
329
- ]);
330
- if (fallback !== undefined)
331
- clearTimeout(fallback);
332
- const settings = ctx.get('settings');
333
- if (settings === undefined)
334
- return undefined;
335
- return settings.describe().find((descriptor) => descriptor.ns === THEME_SETTINGS_NAMESPACE);
277
+ /** Validate an unknown `theme` value (custom theme names pass through). */
278
+ function narrowTheme(value) {
279
+ return typeof value === 'string' && value !== '' ? value : 'auto';
336
280
  }
337
281
  /**
338
- * The resolved `dsh-tui` section as read from the settings provider, after
339
- * waiting for the in-flight registration (see `registeredDescriptor`).
282
+ * Subscribe the hot-reload sink to committed `dsh-tui` changes — the 0.1.7
283
+ * replacement of the 0.1.5 watch hook (the runtime registration call itself
284
+ * is gone: the static Config schema declares the entry config, the loader
285
+ * owns the projection).
340
286
  *
341
- * @returns the resolved section, or `undefined` when no registration is in
342
- * flight, the settings service is absent, or the namespace has not landed.
287
+ * `settings/document-updated` carries `(ns, revision)` and fires whenever the
288
+ * entry's raw config changed — the /theme picker, the /settings browser, an
289
+ * external patch edit, or this TUI's own write (the echo). The handler
290
+ * re-reads the live volatile references (the loader updated them in place
291
+ * before the event) and forwards the narrowed bundle to the sink; callers
292
+ * guard re-applies by theme-bundle identity and height change, so an echoed
293
+ * self-write is a no-op. The whole commit → references → event → `.get()`
294
+ * → sink chain is synchronous per event.
295
+ *
296
+ * @param ctx - plugin context; the subscription dies with the plugin fiber.
297
+ * @param onPreferenceChange - hot-reload sink for committed `dsh-tui` theme,
298
+ * panel-height, footer-hints, icon-set and language changes; `undefined`
299
+ * registers nothing.
343
300
  */
344
- async function readResolvedSection(ctx) {
345
- // The descriptor's `value` is the whole resolved section
346
- // (`{ theme: ..., panelHeight: ..., footerHints: {...}, iconSet: ... }`), not
347
- // the field itself — narrow the unknown to the observed fields.
348
- return (await registeredDescriptor(ctx))?.value;
301
+ export function subscribeThemeSettings(ctx, onPreferenceChange) {
302
+ if (onPreferenceChange === undefined)
303
+ return;
304
+ ctx.on('settings/document-updated', (ns) => {
305
+ if (ns !== THEME_SETTINGS_NAMESPACE)
306
+ return;
307
+ const section = tuiConfig();
308
+ onPreferenceChange(narrowTheme(section.theme.get()), isPanelHeight(section.panelHeight.get()) ? section.panelHeight.get() : DEFAULT_PANEL_HEIGHT, narrowFooterHints(section.footerHints.get()), narrowIconSet(section.iconSet.get()), narrowLanguage(section.language.get()));
309
+ });
349
310
  }
350
311
  /**
351
312
  * Read the persisted theme preference (the startup snapshot).
352
313
  *
353
- * @param ctx - plugin context.
354
- * @returns the resolved `dsh-tui` theme value, or `'auto'` when the settings
355
- * service is absent or the namespace/value cannot be read.
314
+ * @returns the live `dsh-tui` theme value, or `'auto'` when the entry config
315
+ * is not bound (a `Config`-less deployment always has the schema default).
356
316
  */
357
- export async function readThemePreference(ctx) {
358
- const pref = (await readResolvedSection(ctx))?.theme;
359
- if (typeof pref === 'string' && pref !== '')
360
- return pref;
361
- return 'auto';
317
+ export function readThemePreference(_ctx) {
318
+ return narrowTheme(tuiConfig().theme.get());
362
319
  }
363
320
  /**
364
321
  * Read the persisted think/tool panel height (the startup snapshot).
365
322
  *
366
- * @param ctx - plugin context.
367
- * @returns the resolved `dsh-tui` panelHeight value, or DEFAULT_PANEL_HEIGHT
368
- * when the settings service is absent or the namespace/value cannot be read.
323
+ * @returns the live `dsh-tui` panelHeight value, or DEFAULT_PANEL_HEIGHT when
324
+ * the entry config is not bound.
369
325
  */
370
- export async function readPanelHeightPreference(ctx) {
371
- const height = (await readResolvedSection(ctx))?.panelHeight;
372
- if (isPanelHeight(height))
373
- return height;
374
- return DEFAULT_PANEL_HEIGHT;
326
+ export function readPanelHeightPreference(_ctx) {
327
+ const height = tuiConfig().panelHeight.get();
328
+ return isPanelHeight(height) ? height : DEFAULT_PANEL_HEIGHT;
375
329
  }
376
330
  /**
377
331
  * Read the persisted footer-hint selection (the startup snapshot).
378
332
  *
379
- * @param ctx - plugin context.
380
- * @returns the resolved `dsh-tui` footerHints object, or DEFAULT_FOOTER_HINTS
381
- * when the settings service is absent or the namespace/value cannot be read.
333
+ * @returns the live `dsh-tui` footerHints object, or DEFAULT_FOOTER_HINTS
334
+ * when the entry config is not bound.
382
335
  */
383
- export async function readFooterHintsPreference(ctx) {
384
- return narrowFooterHints((await readResolvedSection(ctx))?.footerHints);
336
+ export function readFooterHintsPreference(_ctx) {
337
+ return narrowFooterHints(tuiConfig().footerHints.get());
385
338
  }
386
339
  /**
387
340
  * Read the persisted icon-set mode (the startup snapshot).
388
341
  *
389
- * @param ctx - plugin context.
390
- * @returns the resolved `dsh-tui` iconSet value, or `'auto'` when the settings
391
- * service is absent or the namespace/value cannot be read.
342
+ * @returns the live `dsh-tui` iconSet value, or `'auto'` when the entry
343
+ * config is not bound.
392
344
  */
393
- export async function readIconSetPreference(ctx) {
394
- return narrowIconSet((await readResolvedSection(ctx))?.iconSet);
345
+ export function readIconSetPreference(_ctx) {
346
+ return narrowIconSet(tuiConfig().iconSet.get());
395
347
  }
396
348
  /**
397
349
  * Read the persisted UI language (the startup snapshot).
398
350
  *
399
- * @param ctx - plugin context.
400
- * @returns the resolved `dsh-tui` language value, or DEFAULT_LANGUAGE ('en')
401
- * when the settings service is absent or the namespace/value cannot be read.
402
- * Whether the id is actually installed is decided by the i18n registry
403
- * (initI18n degrades unknown ids to 'en').
351
+ * @returns the live `dsh-tui` language value, or DEFAULT_LANGUAGE ('en') when
352
+ * the entry config is not bound. Whether the id is actually installed is
353
+ * decided by the i18n registry (initI18n degrades unknown ids to 'en').
404
354
  */
405
- export async function readLanguagePreference(ctx) {
406
- return narrowLanguage((await readResolvedSection(ctx))?.language);
355
+ export function readLanguagePreference(_ctx) {
356
+ return narrowLanguage(tuiConfig().language.get());
407
357
  }
408
358
  /**
409
359
  * Read the persisted preset-memory toggle (the startup snapshot). Default
410
360
  * TRUE: remembering is the out-of-the-box behavior; only an explicit
411
- * `dsh-tui.rememberPreset: false` turns it off.
361
+ * `rememberPreset: false` turns it off.
412
362
  *
413
- * @param ctx - plugin context.
414
- * @returns the resolved boolean, or `true` when the settings service is
415
- * absent or the namespace/value cannot be read.
416
- */
417
- export async function readRememberPreset(ctx) {
418
- return narrowRememberPreset((await readResolvedSection(ctx))?.rememberPreset);
419
- }
420
- /**
421
- * Read the currently persisted preset-memory toggle, synchronously. Unlike
422
- * `readRememberPreset` (the startup snapshot), this describes whatever the
423
- * settings service exposes right now — the /preset switch path calls it at
424
- * every commit, so a `/settings` toggle applies without a restart.
425
- * @returns `true` when the service, namespace, or value is absent.
363
+ * @returns the live boolean, or `true` when the entry config is not bound.
426
364
  */
427
- export function currentRememberPreset(ctx) {
428
- const settings = ctx.get('settings');
429
- if (settings === undefined)
430
- return true;
431
- return narrowRememberPreset(settings
432
- .describe()
433
- .find((descriptor) => descriptor.ns === THEME_SETTINGS_NAMESPACE)?.value?.rememberPreset);
365
+ export function readRememberPreset(_ctx) {
366
+ return narrowRememberPreset(tuiConfig().rememberPreset.get());
434
367
  }
435
368
  /**
436
- * Read the explicit `dsh-tui.retention` / `dsh-tui.resume` sections from
437
- * the settings document's user layer (see `SessionManagementExplicit`).
438
- * Awaits the namespace registration bounded (same plumbing as the theme
439
- * readers), so the startup retention pass can call it without hanging a
440
- * settings-less deployment.
369
+ * Read the explicit `dsh-tui.retention` / `dsh-tui.resume` sections from the
370
+ * settings document's user layer (see `SessionManagementExplicit`).
441
371
  *
372
+ * @param ctx - plugin context.
442
373
  * @returns ALWAYS the two-key shape — a section absent from the user
443
- * layer (or the whole service/namespace missing) reads as
374
+ * layer (or the whole service/entry missing) reads as
444
375
  * `{ retention: undefined, resume: undefined }`, never a bare
445
376
  * `undefined`, so callers destructure one stable shape. "Nothing
446
377
  * explicitly configured" (env/defaults govern) and "nothing to read at
447
378
  * all" are the same outcome for every consumer.
448
379
  */
449
- export async function readSessionManagementExplicit(ctx) {
450
- const user = (await registeredDescriptor(ctx))?.user;
380
+ export function readSessionManagementExplicit(ctx) {
381
+ const settings = ctx.get('settings');
382
+ const user = settings?.describe().find((descriptor) => descriptor.ns === THEME_SETTINGS_NAMESPACE)?.user;
451
383
  if (user === null || typeof user !== 'object') {
452
384
  return { retention: undefined, resume: undefined };
453
385
  }
@@ -465,14 +397,13 @@ export async function readSessionManagementExplicit(ctx) {
465
397
  }
466
398
  /**
467
399
  * Read the explicit `dsh-tui.askUser` section from the settings document's
468
- * user layer. Awaits the namespace registration bounded (same plumbing as
469
- * `readSessionManagementExplicit`), so an ask arriving during startup
470
- * cannot hang on a settings-less deployment. Returns `undefined` when the
471
- * section, the user layer, the namespace, or the whole service is absent —
472
- * "nothing explicitly configured" for the ask-user resolver.
400
+ * user layer. Returns `undefined` when the section, the user layer, the
401
+ * entry, or the whole service is absent — "nothing explicitly configured"
402
+ * for the ask-user resolver.
473
403
  */
474
- export async function readAskUserExplicit(ctx) {
475
- const user = (await registeredDescriptor(ctx))?.user;
404
+ export function readAskUserExplicit(ctx) {
405
+ const settings = ctx.get('settings');
406
+ const user = settings?.describe().find((descriptor) => descriptor.ns === THEME_SETTINGS_NAMESPACE)?.user;
476
407
  if (user === null || typeof user !== 'object')
477
408
  return undefined;
478
409
  const section = user.askUser;
@@ -481,74 +412,58 @@ export async function readAskUserExplicit(ctx) {
481
412
  : undefined;
482
413
  }
483
414
  /**
484
- * Read the currently persisted footer-hint selection, synchronously.
485
- * Unlike `readFooterHintsPreference` (the startup snapshot), this does not
486
- * wait for the namespace registration - it describes whatever the settings
487
- * service exposes right now, so a caller can honor a live change immediately.
488
- * @returns DEFAULT_FOOTER_HINTS when the service, namespace, or value is absent.
415
+ * Read the currently persisted footer-hint selection, synchronously — the
416
+ * live volatile reference, so a committed change applies on the next read.
417
+ * @returns DEFAULT_FOOTER_HINTS when the entry config is not bound.
489
418
  */
490
- export function currentFooterHints(ctx) {
491
- const settings = ctx.get('settings');
492
- if (settings === undefined)
493
- return { ...DEFAULT_FOOTER_HINTS };
494
- const hints = settings
495
- .describe()
496
- .find((descriptor) => descriptor.ns === THEME_SETTINGS_NAMESPACE)?.value?.footerHints;
497
- return narrowFooterHints(hints);
419
+ export function currentFooterHints(_ctx) {
420
+ return narrowFooterHints(tuiConfig().footerHints.get());
498
421
  }
499
422
  /**
500
- * Read the currently persisted footer CH mode, synchronously. Unlike a
501
- * startup snapshot, this describes whatever the settings service exposes
502
- * right now — the footer calls it on every render, so a committed change
503
- * (the /settings browser, an external edit) applies on the next repaint.
504
- * @returns DEFAULT_CACHE_HIT_MODE when the service, namespace, or value is absent.
423
+ * Read the currently persisted footer CH mode, synchronously — the footer
424
+ * calls it on every render, so a committed change applies on the next repaint.
425
+ * @returns DEFAULT_CACHE_HIT_MODE when the entry config is not bound.
505
426
  */
506
- export function currentCacheHitMode(ctx) {
507
- const settings = ctx.get('settings');
508
- if (settings === undefined)
509
- return DEFAULT_CACHE_HIT_MODE;
510
- const mode = settings
511
- .describe()
512
- .find((descriptor) => descriptor.ns === THEME_SETTINGS_NAMESPACE)?.value?.cacheHitMode;
513
- return narrowCacheHitMode(mode);
427
+ export function currentCacheHitMode(_ctx) {
428
+ return narrowCacheHitMode(tuiConfig().cacheHitMode.get());
514
429
  }
515
430
  /**
516
- * Read the currently persisted theme preference, synchronously.
517
- * Unlike `readThemePreference` (the startup snapshot), this does not wait for
518
- * the namespace registration — it describes whatever the settings service
519
- * exposes right now, so the `/theme` picker preselects the live value, which
520
- * may have changed since startup (e.g. through the /settings browser).
521
- * @returns 'auto' when the service, namespace, or value cannot be read.
431
+ * Read the currently persisted theme preference, synchronously — the /theme
432
+ * picker preselects the live value.
433
+ * @returns 'auto' when the entry config is not bound.
522
434
  */
523
- export function currentThemePreference(ctx) {
524
- const settings = ctx.get('settings');
525
- if (settings === undefined)
526
- return 'auto';
527
- const pref = settings
528
- .describe()
529
- .find((descriptor) => descriptor.ns === THEME_SETTINGS_NAMESPACE)?.value?.theme;
530
- if (typeof pref === 'string' && pref !== '')
531
- return pref;
532
- return 'auto';
435
+ export function currentThemePreference(_ctx) {
436
+ return narrowTheme(tuiConfig().theme.get());
437
+ }
438
+ /**
439
+ * Read the currently persisted preset-memory toggle, synchronously — the
440
+ * /preset switch path calls it at every commit, so a `/settings` toggle
441
+ * applies immediately.
442
+ * @returns `true` when the entry config is not bound.
443
+ */
444
+ export function currentRememberPreset(_ctx) {
445
+ return narrowRememberPreset(tuiConfig().rememberPreset.get());
533
446
  }
534
447
  /**
535
448
  * Persist one `dsh-tui` preference (theme, panelHeight, or a subagent limit)
536
- * to the settings namespace. The namespace is `applies: 'live'`, so the
537
- * commit (observed through the registration's watch hook) hot-applies the
538
- * change to the running TUI. Best-effort: a deployment without a settings
539
- * provider reports the failure; a failed write returns its error message for
540
- * the caller to surface. A concurrent writer moving the namespace rejects
541
- * with `SettingsConflictError` — retried once against a fresh revision; a
542
- * second conflict surfaces a friendly message instead of the raw error.
449
+ * through `settings.mutate` (the 0.1.7 spell of the same 0.1.5 write — the
450
+ * signature is unchanged). The write is volatile-only, so the loader commits
451
+ * it into the running references without remounting the plugin, and the
452
+ * `settings/document-updated` event hot-applies it to the TUI. Best-effort:
453
+ * a deployment without the settings service reports the failure; a failed
454
+ * write returns its error message for the caller to surface. A concurrent
455
+ * writer moving the entry rejects with `SettingsConflictError` — retried
456
+ * once against a fresh revision; a second conflict surfaces a friendly
457
+ * message instead of the raw error.
543
458
  * @returns undefined on success, the failure message otherwise.
544
459
  */
545
460
  async function writeDshTuiPreference(ctx, key, value) {
546
461
  const settings = ctx.get('settings');
547
462
  if (settings === undefined)
548
463
  return 'Settings service is not available.';
549
- // The descriptor carries the namespace's revision (optimistic-concurrency
550
- // token for mutate) and proves the schema registration that validates the
551
- // path below; the write rejects when the namespace is unregistered.
464
+ // The descriptor carries the entry's revision (optimistic-concurrency
465
+ // token for mutate) and proves the entry is live; the write rejects when
466
+ // it is not.
552
467
  const ops = [{ op: 'set', path: [key], value }];
553
468
  for (let attempt = 0;; attempt++) {
554
469
  const descriptor = settings.describe().find((d) => d.ns === THEME_SETTINGS_NAMESPACE);
@@ -566,86 +481,93 @@ async function writeDshTuiPreference(ctx, key, value) {
566
481
  }
567
482
  }
568
483
  /**
569
- * Persist the theme preference to the `dsh-tui` settings namespace. The
570
- * namespace is `applies: 'live'`, so the commit (observed through the
571
- * registration's watch hook) hot-applies the change to the running TUI.
484
+ * Persist the theme preference to the `dsh-tui` entry config. Volatile-only
485
+ * write: the commit hot-applies to the running TUI through the
486
+ * `settings/document-updated` subscription.
572
487
  * @returns undefined on success, the failure message otherwise.
573
488
  */
574
489
  export async function writeThemePreference(ctx, pref) {
575
490
  return writeDshTuiPreference(ctx, 'theme', pref);
576
491
  }
577
492
  /**
578
- * Persist the UI language to the `dsh-tui` settings namespace. The namespace
579
- * is `applies: 'live'`, so the commit (observed through the registration's
580
- * watch hook) hot-applies the language to the running TUI.
493
+ * Persist the UI language to the `dsh-tui` entry config. Volatile-only
494
+ * write: the commit hot-applies to the running TUI.
581
495
  * @returns undefined on success, the failure message otherwise.
582
496
  */
583
497
  export async function writeLanguagePreference(ctx, id) {
584
498
  return writeDshTuiPreference(ctx, 'language', id);
585
499
  }
586
500
  /**
587
- * Read the currently resolved subagent limits, synchronously. Unlike the
588
- * startup-snapshot readers, this does not wait for the namespace registration
589
- * — it describes whatever the settings service exposes right now, so every
590
- * policy decision (the guard at each spawn, `onRoundCount` at each child
591
- * assistant message)
592
- * reflects the latest committed value without a watcher. Missing settings
593
- * service or namespace, or a non-integer/negative field, degrades to the
594
- * defaults — a settings-less deployment keeps the documented caps.
501
+ * Read the currently resolved subagent limits, synchronously — the live
502
+ * volatile references, so every policy decision (the guard at each spawn,
503
+ * `onRoundCount` at each child assistant message) reflects the latest
504
+ * committed value without a watcher. An unbound entry config or a
505
+ * non-integer/negative field degrades to the defaults — a config-less
506
+ * deployment keeps the documented caps.
595
507
  */
596
- export function readSubagentLimits(ctx) {
597
- const settings = ctx.get('settings');
598
- if (settings === undefined)
599
- return { ...DEFAULT_SUBAGENT_LIMITS };
600
- // The descriptor's `value` is the whole resolved section
601
- // (`{ theme: ..., panelHeight: ..., maxAgents: ..., maxRounds: ...,
602
- // disableSubagent: ... }`) — narrow the unknown to the observed fields.
603
- const section = settings
604
- .describe()
605
- .find((descriptor) => descriptor.ns === THEME_SETTINGS_NAMESPACE)?.value;
508
+ export function readSubagentLimits(_ctx) {
509
+ const section = tuiConfig();
606
510
  const natural = (value, fallback) => typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback;
607
511
  return {
608
- maxAgents: natural(section?.maxAgents, DEFAULT_SUBAGENT_LIMITS.maxAgents),
609
- maxRounds: natural(section?.maxRounds, DEFAULT_SUBAGENT_LIMITS.maxRounds),
610
- maxRoundsGrace: natural(section?.maxRoundsGrace, DEFAULT_SUBAGENT_LIMITS.maxRoundsGrace),
611
- disableSubagent: typeof section?.disableSubagent === 'boolean'
612
- ? section.disableSubagent
512
+ maxAgents: natural(section.maxAgents.get(), DEFAULT_SUBAGENT_LIMITS.maxAgents),
513
+ maxRounds: natural(section.maxRounds.get(), DEFAULT_SUBAGENT_LIMITS.maxRounds),
514
+ maxRoundsGrace: natural(section.maxRoundsGrace.get(), DEFAULT_SUBAGENT_LIMITS.maxRoundsGrace),
515
+ disableSubagent: typeof section.disableSubagent.get() === 'boolean'
516
+ ? section.disableSubagent.get()
613
517
  : DEFAULT_SUBAGENT_LIMITS.disableSubagent,
614
- registeredOnly: typeof section?.registeredOnly === 'boolean'
615
- ? section.registeredOnly
518
+ registeredOnly: typeof section.registeredOnly.get() === 'boolean'
519
+ ? section.registeredOnly.get()
616
520
  : DEFAULT_SUBAGENT_LIMITS.registeredOnly,
617
521
  };
618
522
  }
523
+ /**
524
+ * Read the official `subagent` entry's resolved config through the settings
525
+ * forms descriptor (the only public channel for ANOTHER entry's live
526
+ * values). Best-effort: no settings service, no such entry, or a malformed
527
+ * value reads as `undefined` per field — the panel then annotates the row
528
+ * instead of showing a number it cannot vouch for.
529
+ */
530
+ export function readOfficialSubagentLimits(ctx) {
531
+ const settings = ctx.get('settings');
532
+ const value = settings?.describe().find((d) => d.ns === 'subagent')?.value;
533
+ const natural = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined;
534
+ if (value === null || typeof value !== 'object') {
535
+ return { maxActiveSubagents: undefined, maxDepth: undefined };
536
+ }
537
+ const section = value;
538
+ return {
539
+ maxActiveSubagents: natural(section.maxActiveSubagents),
540
+ maxDepth: natural(section.maxDepth),
541
+ };
542
+ }
619
543
  /**
620
544
  * Persist one subagent policy knob (maxAgents, maxRounds, or disableSubagent)
621
- * to the `dsh-tui` settings namespace. The namespace is `applies: 'live'`, so
622
- * the commit hot-applies without a restart — the policy reads
623
- * `readSubagentLimits` at the next decision point. Never throws: a deployment
624
- * without the settings provider, or an unregistered namespace, surfaces a
625
- * failure message for the caller.
545
+ * to the `dsh-tui` entry config. Volatile-only write: the commit hot-applies
546
+ * without a restart — the policy reads `readSubagentLimits` at the next
547
+ * decision point. Never throws: a deployment without the settings service
548
+ * surfaces a failure message for the caller.
626
549
  * @returns undefined on success, the failure message otherwise.
627
550
  */
628
551
  export async function writeSubagentLimit(ctx, key, value) {
629
552
  return writeDshTuiPreference(ctx, key, value);
630
553
  }
631
554
  /**
632
- * Read the persisted model favorites/hiddens (the startup snapshot). Both
633
- * lists narrow through `narrowStringList` — a malformed or missing field
634
- * degrades to an empty list.
555
+ * Read the persisted model favorites/hiddens. Both lists narrow through
556
+ * `narrowStringList` — a malformed field degrades to an empty list.
635
557
  */
636
- export async function readModelPrefs(ctx) {
637
- const section = await readResolvedSection(ctx);
558
+ export function readModelPrefs(_ctx) {
559
+ const section = tuiConfig();
638
560
  return {
639
- favoriteModels: narrowStringList(section?.favoriteModels),
640
- hiddenModels: narrowStringList(section?.hiddenModels),
561
+ favoriteModels: narrowStringList(section.favoriteModels.get()),
562
+ hiddenModels: narrowStringList(section.hiddenModels.get()),
641
563
  };
642
564
  }
643
565
  /**
644
566
  * Persist one model pref list (favoriteModels or hiddenModels) to the
645
- * `dsh-tui` settings namespace via `settings.mutate` (optimistic concurrency,
646
- * one retry on `SettingsConflictError`) — never a whole-file rewrite. The
647
- * caller invokes this on every f/h toggle, so each press lands immediately.
648
- * Best-effort: a deployment without the settings provider surfaces the
567
+ * `dsh-tui` entry config via `settings.mutate` (optimistic concurrency, one
568
+ * retry on `SettingsConflictError`) — never a whole-file rewrite. The caller
569
+ * invokes this on every f/h toggle, so each press lands immediately.
570
+ * Best-effort: a deployment without the settings service surfaces the
649
571
  * failure message; the in-panel state stays session-local either way.
650
572
  * @returns undefined on success, the failure message otherwise.
651
573
  */