dsh-ntr 0.1.3 → 0.1.4

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/README.en.md CHANGED
@@ -22,7 +22,11 @@ In one sentence: your host agent acts as an ACP client, negotiates capabilities
22
22
 
23
23
  This plugin is a DSH **bundle**: `package.json`'s `dsh.bundle.patch` declares the two patches shipped with the package — the plugin's own `cordis.patch.yml`, and the NTR-mode preset at `presets/acp/acp.patch.yml`. After `dsh plugin` installs it, the package joins the profile's `dsh.profile.bundles` and the patch layers take effect **automatically** — no config file needs editing by hand.
24
24
 
25
- ### Option 1: from the npm registry
25
+ ### Option 1: from the official UI
26
+
27
+ Open the **Plugins** page in DSH's sidebar, click **Add plugin**, enter `dsh-ntr`, and click install. Refresh the page and it's ready to use.
28
+
29
+ ### Option 2: from the npm registry
26
30
 
27
31
  ```bash
28
32
  dsh plugin --profile web add dsh-ntr@latest
@@ -32,7 +36,7 @@ Run the same command again to upgrade.
32
36
 
33
37
  > **Always pass `@latest`**: without it the package manager keeps reusing the version it wrote the first time, and `update` won't get you off it afterwards.
34
38
 
35
- ### Option 2: via the [dsh-market](https://github.com/dsh-market/dsh-market) plugin market
39
+ ### Option 3: via the [dsh-market](https://github.com/dsh-market/dsh-market) plugin market
36
40
 
37
41
  First install the market itself:
38
42
 
@@ -42,9 +46,9 @@ dsh plugin --profile web add dshmarket
42
46
 
43
47
  Restart `dsh web` and open the **Plugins** page: on dsh 0.2.1+ the market is the **Plugin Market** tab next to **Installed**; on older hosts it lives under **Settings → Plugin Market**. Search for `dsh-ntr`, confirm the source, and install in one click — refresh the page and it's live; update checks and upgrades afterwards also happen in the market. The official DSH Desktop app ships with this market preset, so just search and install from the app's **Plugins** page.
44
48
 
45
- > The market's catalog comes from the curated [awesome-dsh-plugin](https://awesome-dsh-plugin.com) registry and refreshes daily. If `dsh-ntr` doesn't show up in search yet, install it from npm with Option 1 — nothing is lost.
49
+ > The market's catalog comes from the curated [awesome-dsh-plugin](https://awesome-dsh-plugin.com) registry and refreshes daily. If `dsh-ntr` doesn't show up in search yet, install it from npm with Option 2 — nothing is lost.
46
50
 
47
- ### Option 3: from source
51
+ ### Option 4: from source
48
52
 
49
53
  Requires Node.js ≥ 22.12.0.
50
54
 
package/README.md CHANGED
@@ -22,7 +22,11 @@
22
22
 
23
23
  本插件是一个 DSH **bundle**:`package.json` 的 `dsh.bundle.patch` 声明了随包分发的两层补丁——插件本体的 `cordis.patch.yml`,与 NTR 模式 preset 的 `presets/acp/acp.patch.yml`。用 `dsh plugin` 安装后,包会加入 profile 的 `dsh.profile.bundles`,补丁层**自动生效**,无需手动编辑任何配置文件。
24
24
 
25
- ### 方式一:从 npm registry 安装
25
+ ### 方式一:从官方界面安装
26
+
27
+ 打开 DSH 侧边栏的**插件**页,点**添加插件**,输入 `dsh-ntr`,点安装。装完刷新页面即可使用。
28
+
29
+ ### 方式二:从 npm registry 安装
26
30
 
27
31
  ```bash
28
32
  dsh plugin --profile web add dsh-ntr@latest
@@ -32,7 +36,7 @@ dsh plugin --profile web add dsh-ntr@latest
32
36
 
33
37
  > `add` 时**务必带 `@latest`**:不带的话包管理器会一直复用首次写入的版本号,之后连 `update` 也升不上去。
34
38
 
35
- ### 方式二:经 [dsh-market](https://github.com/dsh-market/dsh-market) 插件市场安装
39
+ ### 方式三:经 [dsh-market](https://github.com/dsh-market/dsh-market) 插件市场安装
36
40
 
37
41
  先装市场本体:
38
42
 
@@ -42,9 +46,9 @@ dsh plugin --profile web add dshmarket
42
46
 
43
47
  重启 `dsh web`,打开**插件**页:dsh 0.2.1 起市场是「已安装」旁边的**插件市场**标签页;更早的版本在**设置 → 插件市场**。搜索 `dsh-ntr`,确认来源后一键安装,装完刷新页面即可使用;之后的更新检查与升级也在市场里完成。官方 DSH 桌面 App 自带这个市场,直接在 App 的**插件**页里搜索安装即可。
44
48
 
45
- > 市场的插件清单来自 [awesome-dsh-plugin](https://awesome-dsh-plugin.com) 精选目录,每天自动刷新。若搜索结果里暂时没有 `dsh-ntr`,用方式一从 npm 安装,不影响使用。
49
+ > 市场的插件清单来自 [awesome-dsh-plugin](https://awesome-dsh-plugin.com) 精选目录,每天自动刷新。若搜索结果里暂时没有 `dsh-ntr`,用方式二从 npm 安装,不影响使用。
46
50
 
47
- ### 方式三:从源码安装
51
+ ### 方式四:从源码安装
48
52
 
49
53
  需要 Node.js ≥ 22.12.0。
50
54
 
package/lib/bridge.js CHANGED
@@ -12,7 +12,7 @@ import { delimiter, dirname, join } from 'node:path';
12
12
  import { Writable, Readable } from 'node:stream';
13
13
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
15
- import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, parseCatalogOptions, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, } from "./impl.js";
15
+ import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, } from "./impl.js";
16
16
  import { DEFAULT_TAG_HALO } from "./protocol.js";
17
17
  const STDERR_TAIL_LINES = 40;
18
18
  /** Single fs/read_text_file answer cap — agents reading huge files must page via `line`/`limit`. */
@@ -101,7 +101,13 @@ export class AgentBridge {
101
101
  constructor(def, hub, transcriptMax,
102
102
  /** Called when the connection dies so the manager can settle outstanding prompts. */
103
103
  onConnectionLost,
104
- /** Observes `configOptions` from any real session so the manager's model catalog stays warm. */
104
+ /**
105
+ * Observes `configOptions` from any real session so the manager's model
106
+ * catalog stays warm. `shapeOnly` marks a per-session answer (a
107
+ * `session/set_config_option` response): only the option SHAPE belongs in
108
+ * the agent-level catalog, never its `currentValue` — see
109
+ * {@link BridgeManager.absorbCatalogShape}.
110
+ */
105
111
  onCatalog,
106
112
  /** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
107
113
  onCommands, tuning,
@@ -464,7 +470,14 @@ export class AgentBridge {
464
470
  protocolVersion: PROTOCOL_VERSION,
465
471
  // The fs handlers are implemented below behind the same workspace
466
472
  // sandbox that gates session cwds; terminal is not implemented.
467
- clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false },
473
+ // `session.configOptions.boolean` advertises the toggle form of
474
+ // `configOptions`; agents that gate `type: "boolean"` entries on it
475
+ // would otherwise never publish them.
476
+ clientCapabilities: {
477
+ fs: { readTextFile: true, writeTextFile: true },
478
+ terminal: false,
479
+ session: { configOptions: { boolean: {} } },
480
+ },
468
481
  clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: '0.1.0' },
469
482
  }), 'initialize');
470
483
  this.initResult = init;
@@ -516,7 +529,8 @@ export class AgentBridge {
516
529
  throw new AcpError('agent_unreachable', 'acp: connection is not open');
517
530
  const resolved = resolveSessionCwd(this.def, cwd, p => realpathSync(p), extraRoots);
518
531
  const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: [] }), 'session/new');
519
- if (this.onCatalog !== undefined && result.configOptions !== undefined && result.configOptions !== null) {
532
+ // An empty `configOptions` answer is a non-answer — see `usableConfigOptions`.
533
+ if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
520
534
  this.onCatalog(this.def.id, result.configOptions);
521
535
  }
522
536
  const session = {
@@ -541,7 +555,7 @@ export class AgentBridge {
541
555
  },
542
556
  }
543
557
  : {}),
544
- ...(result.configOptions !== undefined && result.configOptions !== null ? { configOptions: result.configOptions } : {}),
558
+ ...(usableConfigOptions(result.configOptions) ? { configOptions: result.configOptions } : {}),
545
559
  };
546
560
  this.sessions.set(session.sessionId, session);
547
561
  this.acpIndex.set(session.acpSessionId, session);
@@ -720,7 +734,7 @@ export class AgentBridge {
720
734
  this.acpIndex.set(session.acpSessionId, session);
721
735
  this.hub.broadcast({ type: 'session/reset', sessionId: session.sessionId, session: snapshot(session) });
722
736
  const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.load, { sessionId: session.acpSessionId, cwd: resolved, mcpServers: [] }), 'session/load');
723
- if (this.onCatalog !== undefined && result.configOptions !== undefined && result.configOptions !== null) {
737
+ if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
724
738
  this.onCatalog(this.def.id, result.configOptions);
725
739
  }
726
740
  session.modes = result.modes !== undefined && result.modes !== null
@@ -733,7 +747,7 @@ export class AgentBridge {
733
747
  })),
734
748
  }
735
749
  : session.modes;
736
- if (Array.isArray(result.configOptions))
750
+ if (usableConfigOptions(result.configOptions))
737
751
  session.configOptions = result.configOptions;
738
752
  this.sessions.set(session.sessionId, session);
739
753
  this.released.delete(session.sessionId);
@@ -825,15 +839,26 @@ export class AgentBridge {
825
839
  const conn = this.conn;
826
840
  if (conn === undefined)
827
841
  throw new AcpError('agent_unreachable', 'acp: connection is not open');
828
- const result = await this.runCommand(() => conn.agent.request(methods.agent.session.setConfigOption, { sessionId: session.acpSessionId, configId, value }));
842
+ // A toggle must say so on the wire: the request union discriminates on
843
+ // `type`, and a bare boolean `value` is not a select value id.
844
+ const params = typeof value === 'boolean'
845
+ ? { sessionId: session.acpSessionId, configId, value, type: 'boolean' }
846
+ : { sessionId: session.acpSessionId, configId, value };
847
+ const result = await this.runCommand(() => conn.agent.request(methods.agent.session.setConfigOption, params));
829
848
  // The response carries the full authoritative option set; some agents
830
849
  // (pi-acp observed) never re-notify after a client-driven set. It feeds the
831
- // SESSION snapshot only — never the agent-level catalog: `currentValue` in
832
- // a per-session answer describes this session's pick, and promoting it to
833
- // the catalog would turn the seat's own choice into `reasoning.defaultEffort`
834
- // for every other conversation (and back onto itself next turn).
835
- if (Array.isArray(result.configOptions))
850
+ // SESSION snapshot first — `currentValue` in a per-session answer describes
851
+ // this session's pick, and promoting it to the catalog would turn the
852
+ // seat's own choice into `reasoning.defaultEffort` for every other
853
+ // conversation (and back onto itself next turn). The option SHAPE still
854
+ // reaches the agent-level catalog (`shapeOnly`): this response is often the
855
+ // only place an agent publishes how its option set changes with the model
856
+ // (opencode offers `thought_level` just for models with variants), and the
857
+ // composer's thought chip is driven by the catalog.
858
+ if (usableConfigOptions(result.configOptions)) {
836
859
  session.configOptions = result.configOptions;
860
+ this.onCatalog?.(this.def.id, result.configOptions, true);
861
+ }
837
862
  this.publishMeta(session);
838
863
  }
839
864
  publishMeta(session) {
@@ -1115,7 +1140,10 @@ export class AgentBridge {
1115
1140
  session.modes = { ...session.modes, currentModeId: update.currentModeId };
1116
1141
  break;
1117
1142
  case 'config_option_update':
1118
- session.configOptions = update.configOptions ?? [];
1143
+ // Empty answers never overwrite the last known set — see `usableConfigOptions`.
1144
+ if (!usableConfigOptions(update.configOptions))
1145
+ break;
1146
+ session.configOptions = update.configOptions;
1119
1147
  this.onCatalog?.(this.def.id, session.configOptions);
1120
1148
  break;
1121
1149
  case 'available_commands_update':
@@ -2864,8 +2892,19 @@ export class BridgeManager {
2864
2892
  if (option === undefined) {
2865
2893
  throw new AcpError('unknown_config', `acp: agent "${bound}" publishes no extra config option "${configId}"`);
2866
2894
  }
2867
- if (!option.choices.some(choice => choice.value === value)) {
2868
- throw new AcpError('bad_config', `acp: "${value}" is not a value of config option "${configId}"`);
2895
+ if (option.kind === 'boolean') {
2896
+ // The browser sends 'true' / 'false' from a checkbox row; the wire and
2897
+ // the persisted pick both use the real boolean.
2898
+ const toggle = typeof value === 'boolean'
2899
+ ? value
2900
+ : value === 'true' ? true : value === 'false' ? false : undefined;
2901
+ if (toggle === undefined) {
2902
+ throw new AcpError('bad_config', `acp: "${String(value)}" is not a boolean value of config option "${configId}"`);
2903
+ }
2904
+ value = toggle;
2905
+ }
2906
+ else if (typeof value !== 'string' || !option.choices.some(choice => choice.value === value)) {
2907
+ throw new AcpError('bad_config', `acp: "${String(value)}" is not a value of config option "${configId}"`);
2869
2908
  }
2870
2909
  const current = this.chatAgentIntent.get(dshSessionId);
2871
2910
  const extraConfigs = current?.agentId === bound ? { ...current.extraConfigs } : {};
@@ -2962,12 +3001,17 @@ export class BridgeManager {
2962
3001
  });
2963
3002
  continue;
2964
3003
  }
2965
- if (!option.choices.some(entry => entry.value === value)) {
3004
+ // A toggle's truth is its boolean itself; a select's is membership in
3005
+ // the choices the agent still offers.
3006
+ const usable = option.kind === 'boolean'
3007
+ ? typeof value === 'boolean'
3008
+ : typeof value === 'string' && option.choices.some(entry => entry.value === value);
3009
+ if (!usable) {
2966
3010
  this.hub.broadcast({
2967
3011
  type: 'agent/notice',
2968
3012
  agentId: session.agentId,
2969
3013
  level: 'error',
2970
- text: `acp: agent "${session.agentId}" no longer offers "${value}" for config "${configId}"; keeping its own`,
3014
+ text: `acp: agent "${session.agentId}" no longer offers "${String(value)}" for config "${configId}"; keeping its own`,
2971
3015
  });
2972
3016
  continue;
2973
3017
  }
@@ -3194,7 +3238,7 @@ export class BridgeManager {
3194
3238
  continue;
3195
3239
  const configs = extraConfigs !== null && typeof extraConfigs === 'object' && !Array.isArray(extraConfigs)
3196
3240
  ? Object.fromEntries(Object.entries(extraConfigs)
3197
- .filter(([, value]) => typeof value === 'string' && value !== ''))
3241
+ .filter(([, value]) => typeof value === 'boolean' || (typeof value === 'string' && value !== '')))
3198
3242
  : {};
3199
3243
  this.chatAgentIntent.set(dshSessionId, {
3200
3244
  agentId,
@@ -3359,6 +3403,9 @@ export class BridgeManager {
3359
3403
  * current `parseCatalogOptions` has now seen this agent's real answer.
3360
3404
  */
3361
3405
  absorbCatalog(agentId, configOptions) {
3406
+ // Empty / absurd answers never overwrite known state — see `usableConfigOptions`.
3407
+ if (!usableConfigOptions(configOptions))
3408
+ return this.catalogs.get(agentId) ?? { catalog: {}, updatedAt: 0 };
3362
3409
  const catalog = parseCatalogOptions(configOptions);
3363
3410
  this.catalogStale.delete(agentId);
3364
3411
  const existing = this.catalogs.get(agentId);
@@ -3386,6 +3433,30 @@ export class BridgeManager {
3386
3433
  this.broadcastExposure(agentId);
3387
3434
  return entry;
3388
3435
  }
3436
+ /**
3437
+ * Fold a per-session option answer's SHAPE into the agent's catalog.
3438
+ *
3439
+ * The counterpart of {@link absorbCatalog} for `session/set_config_option`
3440
+ * responses: which options exist and which choices they offer is agent
3441
+ * knowledge the seat must show (the composer's thought chip only renders
3442
+ * when the catalog has `thought_level`, and some agents publish it only in
3443
+ * the answer to a model change), while a per-session `currentValue` stays
3444
+ * out of it. No auto-exposure and no staleness discharge either — this is
3445
+ * one session's answer, not the agent-level observation those marks track.
3446
+ */
3447
+ absorbCatalogShape(agentId, configOptions) {
3448
+ if (!usableConfigOptions(configOptions))
3449
+ return;
3450
+ const observed = parseCatalogOptions(configOptions);
3451
+ const existing = this.catalogs.get(agentId);
3452
+ const catalog = mergeCatalogShape(existing?.catalog, observed);
3453
+ if (existing !== undefined && JSON.stringify(existing.catalog) === JSON.stringify(catalog))
3454
+ return;
3455
+ const entry = { catalog, updatedAt: Date.now() };
3456
+ this.catalogs.set(agentId, entry);
3457
+ this.persistCatalogs();
3458
+ this.hub.broadcast({ type: 'catalog/updated', agentId, updatedAt: entry.updatedAt });
3459
+ }
3389
3460
  /**
3390
3461
  * Remember the slash commands an agent declared in a real session.
3391
3462
  *
@@ -3418,11 +3489,12 @@ export class BridgeManager {
3418
3489
  * selection would resurrect a model the user deliberately unchecked every
3419
3490
  * time the agent repeats its catalog. The first observation has no baseline
3420
3491
  * (`before === undefined`), so it adds nothing; the user can still add the
3421
- * whole list with one click in the settings card.
3492
+ * whole list with one click in the settings card. Discovery is ON unless the
3493
+ * agent definition explicitly opts out with `autoDiscoverModels: false`.
3422
3494
  */
3423
3495
  autoExposeNewModels(agentId, next, before) {
3424
3496
  const def = this.defsMap.get(agentId);
3425
- if (def?.autoDiscoverModels !== true || before === undefined)
3497
+ if (def === undefined || def.autoDiscoverModels === false || before === undefined)
3426
3498
  return false;
3427
3499
  const known = new Set((before.model?.choices ?? []).map(choice => choice.value));
3428
3500
  const exposed = new Set(def.exposedModels ?? []);
@@ -3444,7 +3516,7 @@ export class BridgeManager {
3444
3516
  type: 'agent/exposure',
3445
3517
  agentId,
3446
3518
  exposedModels: [...(def.exposedModels ?? [])],
3447
- autoDiscoverModels: def.autoDiscoverModels === true,
3519
+ autoDiscoverModels: def.autoDiscoverModels !== false,
3448
3520
  });
3449
3521
  }
3450
3522
  catalogFor(agentId) {
@@ -3618,6 +3690,7 @@ export class BridgeManager {
3618
3690
  extras: extraOptions.map(option => ({
3619
3691
  configId: option.configId,
3620
3692
  name: option.name,
3693
+ ...(option.kind === 'boolean' ? { kind: 'boolean' } : {}),
3621
3694
  ...(option.currentValue === null ? {} : { currentValue: option.currentValue }),
3622
3695
  choices: option.choices.map(choice => ({
3623
3696
  id: choice.value,
@@ -3841,8 +3914,11 @@ export class BridgeManager {
3841
3914
  if (bridge === undefined) {
3842
3915
  bridge = this.makeBridge(def, (b, reason) => {
3843
3916
  this.connectionLost(b, reason);
3844
- }, (observedAgentId, configOptions) => {
3845
- this.absorbCatalog(observedAgentId, configOptions);
3917
+ }, (observedAgentId, configOptions, shapeOnly) => {
3918
+ if (shapeOnly)
3919
+ this.absorbCatalogShape(observedAgentId, configOptions);
3920
+ else
3921
+ this.absorbCatalog(observedAgentId, configOptions);
3846
3922
  }, (observedAgentId, commands) => {
3847
3923
  this.absorbCommands(observedAgentId, commands);
3848
3924
  });
@@ -3970,16 +4046,17 @@ export class BridgeManager {
3970
4046
  * Toggle auto-discovery of newly published models. Lightweight like
3971
4047
  * `setExposedModels`: the def is mutated in place (live bridges share the
3972
4048
  * object) and persisted; no process is restarted, and the current selection
3973
- * is left exactly as it is.
4049
+ * is left exactly as it is. On is the default, so only the opt-out
4050
+ * (`false`) is written to the file.
3974
4051
  */
3975
4052
  async setAutoDiscoverModels(agentId, autoDiscoverModels) {
3976
4053
  const def = this.defsMap.get(agentId);
3977
4054
  if (def === undefined)
3978
4055
  throw new AcpError('unknown_agent', `acp: agent "${agentId}" is not configured`);
3979
4056
  if (autoDiscoverModels)
3980
- def.autoDiscoverModels = true;
3981
- else
3982
4057
  delete def.autoDiscoverModels;
4058
+ else
4059
+ def.autoDiscoverModels = false;
3983
4060
  this.persistAgents();
3984
4061
  this.broadcastExposure(agentId);
3985
4062
  }
@@ -4237,6 +4314,13 @@ export class BridgeManager {
4237
4314
  const extraConfigs = {};
4238
4315
  for (const option of catalog.extras ?? []) {
4239
4316
  const value = option.currentValue;
4317
+ if (option.kind === 'boolean') {
4318
+ // The catalog encodes a toggle as 'true' / 'false'; the pick stores the
4319
+ // real boolean, exactly like an explicit checkbox click.
4320
+ if (value === 'true' || value === 'false')
4321
+ extraConfigs[option.configId] = value === 'true';
4322
+ continue;
4323
+ }
4240
4324
  if (value !== null && value !== undefined && value !== '')
4241
4325
  extraConfigs[option.configId] = value;
4242
4326
  }
@@ -4290,7 +4374,11 @@ export class BridgeManager {
4290
4374
  const option = options.extras?.find(entry => entry.configId === configId);
4291
4375
  if (option === undefined)
4292
4376
  continue;
4293
- if (option.choices.length > 0 && !option.choices.some(choice => choice.value === value))
4377
+ // The fresh session's own option set is the authority for BOTH forms:
4378
+ // a toggle must still be a toggle, a select must still offer the value.
4379
+ if (option.kind === 'boolean'
4380
+ ? typeof value !== 'boolean'
4381
+ : typeof value !== 'string' || (option.choices.length > 0 && !option.choices.some(choice => choice.value === value)))
4294
4382
  continue;
4295
4383
  extraConfigs[configId] = value;
4296
4384
  changed = true;
@@ -350,7 +350,7 @@ export function AcpMenu({ model, anchor, onPick, onClose }) {
350
350
  }, [model, onClose]);
351
351
  if (model === undefined)
352
352
  return null;
353
- return (_jsxs("div", { ref: rootRef, className: "ntr-acp-menu", role: "menu", "aria-label": model.title, children: [_jsxs("div", { className: "ntr-acp-menu-head", children: [_jsx("span", { className: "ntr-acp-menu-title", children: model.title }), model.loading && _jsx("span", { className: "ntr-acp-menu-spin", "aria-hidden": true, children: "\u2026" })] }), model.note !== undefined && _jsx("div", { className: "ntr-acp-menu-note", children: model.note }), model.error !== undefined && _jsx("div", { className: "ntr-acp-menu-error", children: model.error }), _jsxs("div", { className: "ntr-acp-menu-body", children: [model.sections.map(section => (_jsxs("div", { className: "ntr-acp-menu-section", children: [section.title !== undefined && _jsx("div", { className: "ntr-acp-menu-section-title", children: section.title }), section.note !== undefined && _jsx("div", { className: "ntr-acp-menu-note", children: section.note }), section.items.map(item => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": item.active === true, className: `ntr-acp-menu-item${item.active === true ? ' is-active' : ''}`, disabled: item.disabled === true, onClick: () => { onPick(item.id, section.id); }, children: [item.dot !== undefined && _jsx("span", { className: "ntr-acp-dot", style: { background: item.dot }, "aria-hidden": true }), _jsx("span", { className: "ntr-acp-menu-item-label", children: item.label }), item.active === true && _jsx("span", { className: "ntr-acp-tick", "aria-hidden": true, children: "\u2713" }), item.detail !== undefined && _jsx("span", { className: "ntr-acp-menu-item-detail", children: item.detail })] }, `${section.id}/${item.id}`)))] }, section.id))), model.sections.every(section => section.items.length === 0) && (_jsx("div", { className: "ntr-acp-menu-empty", children: "\u2014" }))] })] }));
353
+ return (_jsxs("div", { ref: rootRef, className: "ntr-acp-menu", role: "menu", "aria-label": model.title, children: [_jsxs("div", { className: "ntr-acp-menu-head", children: [_jsx("span", { className: "ntr-acp-menu-title", children: model.title }), model.loading && _jsx("span", { className: "ntr-acp-menu-spin", "aria-hidden": true, children: "\u2026" })] }), model.note !== undefined && _jsx("div", { className: "ntr-acp-menu-note", children: model.note }), model.error !== undefined && _jsx("div", { className: "ntr-acp-menu-error", children: model.error }), _jsxs("div", { className: "ntr-acp-menu-body", children: [model.sections.map(section => (_jsxs("div", { className: "ntr-acp-menu-section", children: [section.title !== undefined && _jsx("div", { className: "ntr-acp-menu-section-title", children: section.title }), section.note !== undefined && _jsx("div", { className: "ntr-acp-menu-note", children: section.note }), section.items.map(item => (_jsxs("button", { type: "button", role: item.checkbox === true ? 'menuitemcheckbox' : 'menuitemradio', "aria-checked": item.active === true, className: `ntr-acp-menu-item${item.active === true ? ' is-active' : ''}`, disabled: item.disabled === true, onClick: () => { onPick(item.id, section.id); }, children: [item.dot !== undefined && _jsx("span", { className: "ntr-acp-dot", style: { background: item.dot }, "aria-hidden": true }), item.checkbox === true && (_jsx("span", { className: `ntr-acp-check${item.active === true ? ' is-on' : ''}`, "aria-hidden": true, children: item.active === true ? '✓' : '' })), _jsx("span", { className: "ntr-acp-menu-item-label", children: item.label }), item.active === true && item.checkbox !== true && _jsx("span", { className: "ntr-acp-tick", "aria-hidden": true, children: "\u2713" }), item.detail !== undefined && _jsx("span", { className: "ntr-acp-menu-item-detail", children: item.detail })] }, `${section.id}/${item.id}`)))] }, section.id))), model.sections.every(section => section.items.length === 0) && (_jsx("div", { className: "ntr-acp-menu-empty", children: "\u2014" }))] })] }));
354
354
  }
355
355
  // ---- Chips ----
356
356
  /**
@@ -490,6 +490,8 @@ const STYLE = `
490
490
  .ntr-acp-seat-sub { color: var(--dsw-alias-text-secondary, #6b7280); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
491
491
  .ntr-acp-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
492
492
  .ntr-acp-tick { margin-left: auto; color: var(--dsw-alias-state-business-primary, #2563eb); }
493
+ .ntr-acp-check { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 13px; height: 13px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); border-radius: 3px; font-size: 10px; line-height: 1; color: var(--dsw-alias-state-business-primary, #2563eb); }
494
+ .ntr-acp-check.is-on { border-color: var(--dsw-alias-state-business-primary, #2563eb); }
493
495
  .ntr-acp-menu { position: absolute; bottom: calc(100% + 8px); left: 8px; z-index: 100; width: min(360px, 100%); max-height: min(420px, 60vh); display: flex; flex-direction: column; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16); font-size: 12px; overflow: hidden; }
494
496
  .ntr-acp-menu-head { padding: 8px 10px 6px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
495
497
  .ntr-acp-menu-title { flex: 1; }
@@ -349,7 +349,7 @@ function payloadEquals(payload, current) {
349
349
  return false;
350
350
  if (exposureKey(payload.exposedModels) !== exposureKey(current.exposedModels))
351
351
  return false;
352
- if ((payload.autoDiscoverModels === true) !== (current.autoDiscoverModels === true))
352
+ if ((payload.autoDiscoverModels !== false) !== (current.autoDiscoverModels !== false))
353
353
  return false;
354
354
  // An absent field means "use the host default" — which is what `current`
355
355
  // already resolved to (see `resolvedIdleTimeoutMs`), so it is not a change.
@@ -428,7 +428,7 @@ function AgentRow({ detected, activeAgent, acp, isDefault, synthesized, newCusto
428
428
  // Re-sync the editor only when the persisted filter actually changes, so
429
429
  // background resyncs never clobber in-progress checkbox edits.
430
430
  }, [serverExposureKey]);
431
- const serverAutoDiscover = detected.currentConfig?.autoDiscoverModels === true;
431
+ const serverAutoDiscover = detected.currentConfig?.autoDiscoverModels !== false;
432
432
  useEffect(() => {
433
433
  // The switch saves itself through the lightweight RPC, so the host is the
434
434
  // truth; this keeps the form (and therefore a later full save) in step.
@@ -536,7 +536,7 @@ function initialForm(detected, activeAgent) {
536
536
  roots,
537
537
  envRows: Object.entries(config?.env ?? {}).map(([key, value]) => ({ key, value })),
538
538
  exposed: config?.exposedModels === undefined ? undefined : [...config.exposedModels],
539
- autoDiscoverModels: config?.autoDiscoverModels === true,
539
+ autoDiscoverModels: config?.autoDiscoverModels !== false,
540
540
  // Prefer the host's resolved value: it is the one actually in effect.
541
541
  idleStr: idleText(activeAgent?.idleTimeoutMs ?? config?.idleTimeoutMs),
542
542
  };
@@ -590,7 +590,9 @@ function buildPayload(form, detected, newCustom) {
590
590
  ...(form.npmPackage.trim() === '' ? {} : { npmPackage: form.npmPackage.trim() }),
591
591
  env,
592
592
  ...(form.exposed === undefined ? {} : { exposedModels: form.exposed }),
593
- ...(form.autoDiscoverModels ? { autoDiscoverModels: true } : {}),
593
+ // Auto-discovery is ON by default: only an unchecked switch records the
594
+ // opt-out, otherwise a full save would silently re-enable it.
595
+ ...(form.autoDiscoverModels ? {} : { autoDiscoverModels: false }),
594
596
  // `''` sends nothing: the host then applies its own default.
595
597
  ...(idleRaw === '' || !idleValid(idleRaw) ? {} : { idleTimeoutMs: Number(idleRaw) }),
596
598
  };
@@ -286,10 +286,14 @@ export function advancedOptions(catalog, choice) {
286
286
  return [];
287
287
  const picked = choice !== undefined && choice.agentId === catalog.agentId ? choice.extraConfigs : undefined;
288
288
  return extras.map(option => {
289
- const value = picked?.[option.configId] ?? option.currentValue;
289
+ const raw = picked?.[option.configId] ?? option.currentValue;
290
+ // A toggle's catalog value is 'true' / 'false'; a conversation pick stores
291
+ // the real boolean. Both collapse to the string form the menu speaks.
292
+ const value = typeof raw === 'boolean' ? (raw ? 'true' : 'false') : raw;
290
293
  return {
291
294
  configId: option.configId,
292
295
  name: option.name,
296
+ ...(option.kind === 'boolean' ? { kind: 'boolean' } : {}),
293
297
  ...(value === undefined ? {} : { value }),
294
298
  choices: option.choices,
295
299
  };
@@ -694,16 +698,28 @@ function thoughtSection(context) {
694
698
  * and the pick would then be read as the primary model axis.
695
699
  */
696
700
  function advancedSections(context) {
697
- return advancedOptions(context.catalog, context.choice).map(option => ({
698
- id: `config:${option.configId}`,
699
- title: option.name,
700
- items: option.choices.map(choice => ({
701
- id: choice.id,
702
- label: choice.name,
703
- ...(choice.description === undefined ? {} : { detail: choice.description }),
704
- active: choice.id === option.value,
705
- })),
706
- }));
701
+ return advancedOptions(context.catalog, context.choice).map(option => {
702
+ if (option.kind === 'boolean') {
703
+ // A toggle is ONE checkbox row, not an On/Off pair: the row's check is
704
+ // the current value and the pick sends the OPPOSITE, which `onPick`
705
+ // forwards verbatim (the host normalizes 'true' / 'false' to a boolean).
706
+ const checked = option.value === 'true';
707
+ return {
708
+ id: `config:${option.configId}`,
709
+ items: [{ id: checked ? 'false' : 'true', label: option.name, active: checked, checkbox: true }],
710
+ };
711
+ }
712
+ return {
713
+ id: `config:${option.configId}`,
714
+ title: option.name,
715
+ items: option.choices.map(choice => ({
716
+ id: choice.id,
717
+ label: choice.name,
718
+ ...(choice.description === undefined ? {} : { detail: choice.description }),
719
+ active: choice.id === option.value,
720
+ })),
721
+ };
722
+ });
707
723
  }
708
724
  /**
709
725
  * REMOVED on purpose (2026-10-17): the agent's own `session/set_mode` list used
package/lib/client.js CHANGED
@@ -1988,7 +1988,7 @@ window.__ModuleLoader__.load({
1988
1988
  }),
1989
1989
  section.items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1990
1990
  type: "button",
1991
- role: "menuitemradio",
1991
+ role: item.checkbox === true ? "menuitemcheckbox" : "menuitemradio",
1992
1992
  "aria-checked": item.active === true,
1993
1993
  className: `ntr-acp-menu-item${item.active === true ? " is-active" : ""}`,
1994
1994
  disabled: item.disabled === true,
@@ -2001,11 +2001,16 @@ window.__ModuleLoader__.load({
2001
2001
  style: { background: item.dot },
2002
2002
  "aria-hidden": true
2003
2003
  }),
2004
+ item.checkbox === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2005
+ className: `ntr-acp-check${item.active === true ? " is-on" : ""}`,
2006
+ "aria-hidden": true,
2007
+ children: item.active === true ? "✓" : ""
2008
+ }),
2004
2009
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2005
2010
  className: "ntr-acp-menu-item-label",
2006
2011
  children: item.label
2007
2012
  }),
2008
- item.active === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2013
+ item.active === true && item.checkbox !== true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2009
2014
  className: "ntr-acp-tick",
2010
2015
  "aria-hidden": true,
2011
2016
  children: "✓"
@@ -2331,6 +2336,8 @@ window.__ModuleLoader__.load({
2331
2336
  .ntr-acp-seat-sub { color: var(--dsw-alias-text-secondary, #6b7280); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2332
2337
  .ntr-acp-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
2333
2338
  .ntr-acp-tick { margin-left: auto; color: var(--dsw-alias-state-business-primary, #2563eb); }
2339
+ .ntr-acp-check { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 13px; height: 13px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); border-radius: 3px; font-size: 10px; line-height: 1; color: var(--dsw-alias-state-business-primary, #2563eb); }
2340
+ .ntr-acp-check.is-on { border-color: var(--dsw-alias-state-business-primary, #2563eb); }
2334
2341
  .ntr-acp-menu { position: absolute; bottom: calc(100% + 8px); left: 8px; z-index: 100; width: min(360px, 100%); max-height: min(420px, 60vh); display: flex; flex-direction: column; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16); font-size: 12px; overflow: hidden; }
2335
2342
  .ntr-acp-menu-head { padding: 8px 10px 6px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
2336
2343
  .ntr-acp-menu-title { flex: 1; }
@@ -2777,7 +2784,7 @@ window.__ModuleLoader__.load({
2777
2784
  if (!sameStringArray(payload.allowedCwdRoots ?? [], current.allowedCwdRoots ?? [])) return false;
2778
2785
  if (!sameStringMap(payload.env ?? {}, current.env ?? {})) return false;
2779
2786
  if (exposureKey(payload.exposedModels) !== exposureKey(current.exposedModels)) return false;
2780
- if (payload.autoDiscoverModels === true !== (current.autoDiscoverModels === true)) return false;
2787
+ if (payload.autoDiscoverModels !== false !== (current.autoDiscoverModels !== false)) return false;
2781
2788
  if (payload.idleTimeoutMs !== void 0 && payload.idleTimeoutMs !== current.idleTimeoutMs) return false;
2782
2789
  return true;
2783
2790
  }
@@ -3142,7 +3149,7 @@ window.__ModuleLoader__.load({
3142
3149
  exposed: server === void 0 ? void 0 : [...server]
3143
3150
  }));
3144
3151
  }, [serverExposureKey]);
3145
- const serverAutoDiscover = detected.currentConfig?.autoDiscoverModels === true;
3152
+ const serverAutoDiscover = detected.currentConfig?.autoDiscoverModels !== false;
3146
3153
  (0, react.useEffect)(() => {
3147
3154
  setForm((prev) => ({
3148
3155
  ...prev,
@@ -3396,7 +3403,7 @@ window.__ModuleLoader__.load({
3396
3403
  value
3397
3404
  })),
3398
3405
  exposed: config?.exposedModels === void 0 ? void 0 : [...config.exposedModels],
3399
- autoDiscoverModels: config?.autoDiscoverModels === true,
3406
+ autoDiscoverModels: config?.autoDiscoverModels !== false,
3400
3407
  idleStr: idleText(activeAgent?.idleTimeoutMs ?? config?.idleTimeoutMs)
3401
3408
  };
3402
3409
  }
@@ -3444,7 +3451,7 @@ window.__ModuleLoader__.load({
3444
3451
  ...form.npmPackage.trim() === "" ? {} : { npmPackage: form.npmPackage.trim() },
3445
3452
  env,
3446
3453
  ...form.exposed === void 0 ? {} : { exposedModels: form.exposed },
3447
- ...form.autoDiscoverModels ? { autoDiscoverModels: true } : {},
3454
+ ...form.autoDiscoverModels ? {} : { autoDiscoverModels: false },
3448
3455
  ...idleRaw === "" || !idleValid(idleRaw) ? {} : { idleTimeoutMs: Number(idleRaw) }
3449
3456
  };
3450
3457
  if (form.rootsOpen) {
@@ -4794,10 +4801,12 @@ window.__ModuleLoader__.load({
4794
4801
  if (catalog === void 0 || extras.length === 0) return [];
4795
4802
  const picked = choice !== void 0 && choice.agentId === catalog.agentId ? choice.extraConfigs : void 0;
4796
4803
  return extras.map((option) => {
4797
- const value = picked?.[option.configId] ?? option.currentValue;
4804
+ const raw = picked?.[option.configId] ?? option.currentValue;
4805
+ const value = typeof raw === "boolean" ? raw ? "true" : "false" : raw;
4798
4806
  return {
4799
4807
  configId: option.configId,
4800
4808
  name: option.name,
4809
+ ...option.kind === "boolean" ? { kind: "boolean" } : {},
4801
4810
  ...value === void 0 ? {} : { value },
4802
4811
  choices: option.choices
4803
4812
  };
@@ -5140,16 +5149,30 @@ window.__ModuleLoader__.load({
5140
5149
  * and the pick would then be read as the primary model axis.
5141
5150
  */
5142
5151
  function advancedSections(context) {
5143
- return advancedOptions(context.catalog, context.choice).map((option) => ({
5144
- id: `config:${option.configId}`,
5145
- title: option.name,
5146
- items: option.choices.map((choice) => ({
5147
- id: choice.id,
5148
- label: choice.name,
5149
- ...choice.description === void 0 ? {} : { detail: choice.description },
5150
- active: choice.id === option.value
5151
- }))
5152
- }));
5152
+ return advancedOptions(context.catalog, context.choice).map((option) => {
5153
+ if (option.kind === "boolean") {
5154
+ const checked = option.value === "true";
5155
+ return {
5156
+ id: `config:${option.configId}`,
5157
+ items: [{
5158
+ id: checked ? "false" : "true",
5159
+ label: option.name,
5160
+ active: checked,
5161
+ checkbox: true
5162
+ }]
5163
+ };
5164
+ }
5165
+ return {
5166
+ id: `config:${option.configId}`,
5167
+ title: option.name,
5168
+ items: option.choices.map((choice) => ({
5169
+ id: choice.id,
5170
+ label: choice.name,
5171
+ ...choice.description === void 0 ? {} : { detail: choice.description },
5172
+ active: choice.id === option.value
5173
+ }))
5174
+ };
5175
+ });
5153
5176
  }
5154
5177
  /**
5155
5178
  * REMOVED on purpose (2026-10-17): the agent's own `session/set_mode` list used