dsh-ntr 0.1.2 → 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/src/bridge.ts CHANGED
@@ -37,7 +37,9 @@ import {
37
37
  childEnv,
38
38
  isAcpToolContinuation,
39
39
  isUnderPath,
40
+ mergeCatalogShape,
40
41
  parseCatalogOptions,
42
+ usableConfigOptions,
41
43
  permissionToolKind,
42
44
  quoteCommandToken,
43
45
  randomUUID,
@@ -73,7 +75,7 @@ const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024
73
75
  interface DisplayedChatDefaults {
74
76
  modelId?: string
75
77
  effort?: string
76
- extraConfigs?: Record<string, string>
78
+ extraConfigs?: Record<string, string | boolean>
77
79
  }
78
80
 
79
81
  interface LiveSession {
@@ -351,8 +353,14 @@ export class AgentBridge {
351
353
  private readonly transcriptMax: number,
352
354
  /** Called when the connection dies so the manager can settle outstanding prompts. */
353
355
  private readonly onConnectionLost: (bridge: AgentBridge, reason: string) => void,
354
- /** Observes `configOptions` from any real session so the manager's model catalog stays warm. */
355
- private readonly onCatalog?: (agentId: string, configOptions: unknown) => void,
356
+ /**
357
+ * Observes `configOptions` from any real session so the manager's model
358
+ * catalog stays warm. `shapeOnly` marks a per-session answer (a
359
+ * `session/set_config_option` response): only the option SHAPE belongs in
360
+ * the agent-level catalog, never its `currentValue` — see
361
+ * {@link BridgeManager.absorbCatalogShape}.
362
+ */
363
+ private readonly onCatalog?: (agentId: string, configOptions: unknown, shapeOnly?: boolean) => void,
356
364
  /** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
357
365
  private readonly onCommands?: (agentId: string, commands: NonNullable<SessionView['availableCommands']>) => void,
358
366
  tuning?: BridgeTuning,
@@ -707,7 +715,14 @@ export class AgentBridge {
707
715
  protocolVersion: PROTOCOL_VERSION,
708
716
  // The fs handlers are implemented below behind the same workspace
709
717
  // sandbox that gates session cwds; terminal is not implemented.
710
- clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false },
718
+ // `session.configOptions.boolean` advertises the toggle form of
719
+ // `configOptions`; agents that gate `type: "boolean"` entries on it
720
+ // would otherwise never publish them.
721
+ clientCapabilities: {
722
+ fs: { readTextFile: true, writeTextFile: true },
723
+ terminal: false,
724
+ session: { configOptions: { boolean: {} } },
725
+ },
711
726
  clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: '0.1.0' },
712
727
  }),
713
728
  'initialize',
@@ -765,7 +780,8 @@ export class AgentBridge {
765
780
  conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: [] }),
766
781
  'session/new',
767
782
  )
768
- if (this.onCatalog !== undefined && result.configOptions !== undefined && result.configOptions !== null) {
783
+ // An empty `configOptions` answer is a non-answer — see `usableConfigOptions`.
784
+ if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
769
785
  this.onCatalog(this.def.id, result.configOptions)
770
786
  }
771
787
  const session: LiveSession = {
@@ -790,7 +806,7 @@ export class AgentBridge {
790
806
  },
791
807
  }
792
808
  : {}),
793
- ...(result.configOptions !== undefined && result.configOptions !== null ? { configOptions: result.configOptions } : {}),
809
+ ...(usableConfigOptions(result.configOptions) ? { configOptions: result.configOptions } : {}),
794
810
  }
795
811
  this.sessions.set(session.sessionId, session)
796
812
  this.acpIndex.set(session.acpSessionId, session)
@@ -970,7 +986,7 @@ export class AgentBridge {
970
986
  conn.agent.request(methods.agent.session.load, { sessionId: session.acpSessionId, cwd: resolved, mcpServers: [] }),
971
987
  'session/load',
972
988
  )
973
- if (this.onCatalog !== undefined && result.configOptions !== undefined && result.configOptions !== null) {
989
+ if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
974
990
  this.onCatalog(this.def.id, result.configOptions)
975
991
  }
976
992
  session.modes = result.modes !== undefined && result.modes !== null
@@ -983,7 +999,7 @@ export class AgentBridge {
983
999
  })),
984
1000
  }
985
1001
  : session.modes
986
- if (Array.isArray(result.configOptions)) session.configOptions = result.configOptions
1002
+ if (usableConfigOptions(result.configOptions)) session.configOptions = result.configOptions
987
1003
  this.sessions.set(session.sessionId, session)
988
1004
  this.released.delete(session.sessionId)
989
1005
  // Same flip as `createSession`: a registered session makes this a chat
@@ -1077,14 +1093,26 @@ export class AgentBridge {
1077
1093
  async setConfigOption(session: LiveSession, configId: string, value: string | boolean): Promise<void> {
1078
1094
  const conn = this.conn
1079
1095
  if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
1080
- const result = await this.runCommand(() => conn.agent.request(methods.agent.session.setConfigOption, { sessionId: session.acpSessionId, configId, value })) as { configOptions?: unknown }
1096
+ // A toggle must say so on the wire: the request union discriminates on
1097
+ // `type`, and a bare boolean `value` is not a select value id.
1098
+ const params = typeof value === 'boolean'
1099
+ ? { sessionId: session.acpSessionId, configId, value, type: 'boolean' as const }
1100
+ : { sessionId: session.acpSessionId, configId, value }
1101
+ const result = await this.runCommand(() => conn.agent.request(methods.agent.session.setConfigOption, params)) as { configOptions?: unknown }
1081
1102
  // The response carries the full authoritative option set; some agents
1082
1103
  // (pi-acp observed) never re-notify after a client-driven set. It feeds the
1083
- // SESSION snapshot only — never the agent-level catalog: `currentValue` in
1084
- // a per-session answer describes this session's pick, and promoting it to
1085
- // the catalog would turn the seat's own choice into `reasoning.defaultEffort`
1086
- // for every other conversation (and back onto itself next turn).
1087
- if (Array.isArray(result.configOptions)) session.configOptions = result.configOptions as unknown[]
1104
+ // SESSION snapshot first — `currentValue` in a per-session answer describes
1105
+ // this session's pick, and promoting it to the catalog would turn the
1106
+ // seat's own choice into `reasoning.defaultEffort` for every other
1107
+ // conversation (and back onto itself next turn). The option SHAPE still
1108
+ // reaches the agent-level catalog (`shapeOnly`): this response is often the
1109
+ // only place an agent publishes how its option set changes with the model
1110
+ // (opencode offers `thought_level` just for models with variants), and the
1111
+ // composer's thought chip is driven by the catalog.
1112
+ if (usableConfigOptions(result.configOptions)) {
1113
+ session.configOptions = result.configOptions
1114
+ this.onCatalog?.(this.def.id, result.configOptions, true)
1115
+ }
1088
1116
  this.publishMeta(session)
1089
1117
  }
1090
1118
 
@@ -1372,7 +1400,9 @@ export class AgentBridge {
1372
1400
  session.modes = { ...session.modes, currentModeId: update.currentModeId }
1373
1401
  break
1374
1402
  case 'config_option_update':
1375
- session.configOptions = update.configOptions ?? []
1403
+ // Empty answers never overwrite the last known set — see `usableConfigOptions`.
1404
+ if (!usableConfigOptions(update.configOptions)) break
1405
+ session.configOptions = update.configOptions
1376
1406
  this.onCatalog?.(this.def.id, session.configOptions)
1377
1407
  break
1378
1408
  case 'available_commands_update':
@@ -2720,7 +2750,7 @@ export class BridgeManager {
2720
2750
  private makeBridge(
2721
2751
  def: AcpAgentDef,
2722
2752
  onConnectionLost: (bridge: AgentBridge, reason: string) => void,
2723
- onCatalog?: (agentId: string, configOptions: unknown) => void,
2753
+ onCatalog?: (agentId: string, configOptions: unknown, shapeOnly?: boolean) => void,
2724
2754
  onCommands?: (agentId: string, commands: NonNullable<SessionView['availableCommands']>) => void,
2725
2755
  ): AgentBridge {
2726
2756
  return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.approvalRequester, () => this.sessionWorkspaceResolver)
@@ -3173,7 +3203,7 @@ export class BridgeManager {
3173
3203
  * A live session is updated immediately; a failure there is warned about and
3174
3204
  * the pick still stands, exactly like the model/effort alignment path.
3175
3205
  */
3176
- async setChatSessionConfig(dshSessionId: string, configId: string, value: string, agentId?: string): Promise<void> {
3206
+ async setChatSessionConfig(dshSessionId: string, configId: string, value: string | boolean, agentId?: string): Promise<void> {
3177
3207
  // `agentId` is the route the composer showed — including the plugin
3178
3208
  // default, which `chatAgentFor` cannot report before the first session.
3179
3209
  const bound = agentId ?? this.chatAgentFor(dshSessionId)
@@ -3197,8 +3227,18 @@ export class BridgeManager {
3197
3227
  if (option === undefined) {
3198
3228
  throw new AcpError('unknown_config', `acp: agent "${bound}" publishes no extra config option "${configId}"`)
3199
3229
  }
3200
- if (!option.choices.some(choice => choice.value === value)) {
3201
- throw new AcpError('bad_config', `acp: "${value}" is not a value of config option "${configId}"`)
3230
+ if (option.kind === 'boolean') {
3231
+ // The browser sends 'true' / 'false' from a checkbox row; the wire and
3232
+ // the persisted pick both use the real boolean.
3233
+ const toggle = typeof value === 'boolean'
3234
+ ? value
3235
+ : value === 'true' ? true : value === 'false' ? false : undefined
3236
+ if (toggle === undefined) {
3237
+ throw new AcpError('bad_config', `acp: "${String(value)}" is not a boolean value of config option "${configId}"`)
3238
+ }
3239
+ value = toggle
3240
+ } else if (typeof value !== 'string' || !option.choices.some(choice => choice.value === value)) {
3241
+ throw new AcpError('bad_config', `acp: "${String(value)}" is not a value of config option "${configId}"`)
3202
3242
  }
3203
3243
  const current = this.chatAgentIntent.get(dshSessionId)
3204
3244
  const extraConfigs = current?.agentId === bound ? { ...current.extraConfigs } : {}
@@ -3290,12 +3330,17 @@ export class BridgeManager {
3290
3330
  })
3291
3331
  continue
3292
3332
  }
3293
- if (!option.choices.some(entry => entry.value === value)) {
3333
+ // A toggle's truth is its boolean itself; a select's is membership in
3334
+ // the choices the agent still offers.
3335
+ const usable = option.kind === 'boolean'
3336
+ ? typeof value === 'boolean'
3337
+ : typeof value === 'string' && option.choices.some(entry => entry.value === value)
3338
+ if (!usable) {
3294
3339
  this.hub.broadcast({
3295
3340
  type: 'agent/notice',
3296
3341
  agentId: session.agentId,
3297
3342
  level: 'error',
3298
- text: `acp: agent "${session.agentId}" no longer offers "${value}" for config "${configId}"; keeping its own`,
3343
+ text: `acp: agent "${session.agentId}" no longer offers "${String(value)}" for config "${configId}"; keeping its own`,
3299
3344
  })
3300
3345
  continue
3301
3346
  }
@@ -3513,7 +3558,7 @@ export class BridgeManager {
3513
3558
  if (typeof agentId !== 'string' || agentId === '') continue
3514
3559
  const configs = extraConfigs !== null && typeof extraConfigs === 'object' && !Array.isArray(extraConfigs)
3515
3560
  ? Object.fromEntries(Object.entries(extraConfigs as Record<string, unknown>)
3516
- .filter(([, value]) => typeof value === 'string' && value !== '')) as Record<string, string>
3561
+ .filter(([, value]) => typeof value === 'boolean' || (typeof value === 'string' && value !== ''))) as Record<string, string | boolean>
3517
3562
  : {}
3518
3563
  this.chatAgentIntent.set(dshSessionId, {
3519
3564
  agentId,
@@ -3674,6 +3719,8 @@ export class BridgeManager {
3674
3719
  * current `parseCatalogOptions` has now seen this agent's real answer.
3675
3720
  */
3676
3721
  private absorbCatalog(agentId: string, configOptions: unknown): { catalog: AcpAgentCatalog; updatedAt: number } {
3722
+ // Empty / absurd answers never overwrite known state — see `usableConfigOptions`.
3723
+ if (!usableConfigOptions(configOptions)) return this.catalogs.get(agentId) ?? { catalog: {}, updatedAt: 0 }
3677
3724
  const catalog = parseCatalogOptions(configOptions)
3678
3725
  this.catalogStale.delete(agentId)
3679
3726
  const existing = this.catalogs.get(agentId)
@@ -3700,6 +3747,29 @@ export class BridgeManager {
3700
3747
  return entry
3701
3748
  }
3702
3749
 
3750
+ /**
3751
+ * Fold a per-session option answer's SHAPE into the agent's catalog.
3752
+ *
3753
+ * The counterpart of {@link absorbCatalog} for `session/set_config_option`
3754
+ * responses: which options exist and which choices they offer is agent
3755
+ * knowledge the seat must show (the composer's thought chip only renders
3756
+ * when the catalog has `thought_level`, and some agents publish it only in
3757
+ * the answer to a model change), while a per-session `currentValue` stays
3758
+ * out of it. No auto-exposure and no staleness discharge either — this is
3759
+ * one session's answer, not the agent-level observation those marks track.
3760
+ */
3761
+ private absorbCatalogShape(agentId: string, configOptions: unknown): void {
3762
+ if (!usableConfigOptions(configOptions)) return
3763
+ const observed = parseCatalogOptions(configOptions)
3764
+ const existing = this.catalogs.get(agentId)
3765
+ const catalog = mergeCatalogShape(existing?.catalog, observed)
3766
+ if (existing !== undefined && JSON.stringify(existing.catalog) === JSON.stringify(catalog)) return
3767
+ const entry = { catalog, updatedAt: Date.now() }
3768
+ this.catalogs.set(agentId, entry)
3769
+ this.persistCatalogs()
3770
+ this.hub.broadcast({ type: 'catalog/updated', agentId, updatedAt: entry.updatedAt })
3771
+ }
3772
+
3703
3773
  /**
3704
3774
  * Remember the slash commands an agent declared in a real session.
3705
3775
  *
@@ -3732,11 +3802,12 @@ export class BridgeManager {
3732
3802
  * selection would resurrect a model the user deliberately unchecked every
3733
3803
  * time the agent repeats its catalog. The first observation has no baseline
3734
3804
  * (`before === undefined`), so it adds nothing; the user can still add the
3735
- * whole list with one click in the settings card.
3805
+ * whole list with one click in the settings card. Discovery is ON unless the
3806
+ * agent definition explicitly opts out with `autoDiscoverModels: false`.
3736
3807
  */
3737
3808
  private autoExposeNewModels(agentId: string, next: AcpAgentCatalog, before: AcpAgentCatalog | undefined): boolean {
3738
3809
  const def = this.defsMap.get(agentId)
3739
- if (def?.autoDiscoverModels !== true || before === undefined) return false
3810
+ if (def === undefined || def.autoDiscoverModels === false || before === undefined) return false
3740
3811
  const known = new Set((before.model?.choices ?? []).map(choice => choice.value))
3741
3812
  const exposed = new Set(def.exposedModels ?? [])
3742
3813
  const added = (next.model?.choices ?? [])
@@ -3756,7 +3827,7 @@ export class BridgeManager {
3756
3827
  type: 'agent/exposure',
3757
3828
  agentId,
3758
3829
  exposedModels: [...(def.exposedModels ?? [])],
3759
- autoDiscoverModels: def.autoDiscoverModels === true,
3830
+ autoDiscoverModels: def.autoDiscoverModels !== false,
3760
3831
  })
3761
3832
  }
3762
3833
 
@@ -3932,6 +4003,7 @@ export class BridgeManager {
3932
4003
  extras: extraOptions.map(option => ({
3933
4004
  configId: option.configId,
3934
4005
  name: option.name,
4006
+ ...(option.kind === 'boolean' ? { kind: 'boolean' as const } : {}),
3935
4007
  ...(option.currentValue === null ? {} : { currentValue: option.currentValue }),
3936
4008
  choices: option.choices.map(choice => ({
3937
4009
  id: choice.value,
@@ -4142,8 +4214,9 @@ export class BridgeManager {
4142
4214
  if (bridge === undefined) {
4143
4215
  bridge = this.makeBridge(def, (b, reason) => {
4144
4216
  this.connectionLost(b, reason)
4145
- }, (observedAgentId, configOptions) => {
4146
- this.absorbCatalog(observedAgentId, configOptions)
4217
+ }, (observedAgentId, configOptions, shapeOnly) => {
4218
+ if (shapeOnly) this.absorbCatalogShape(observedAgentId, configOptions)
4219
+ else this.absorbCatalog(observedAgentId, configOptions)
4147
4220
  }, (observedAgentId, commands) => {
4148
4221
  this.absorbCommands(observedAgentId, commands)
4149
4222
  })
@@ -4277,13 +4350,14 @@ export class BridgeManager {
4277
4350
  * Toggle auto-discovery of newly published models. Lightweight like
4278
4351
  * `setExposedModels`: the def is mutated in place (live bridges share the
4279
4352
  * object) and persisted; no process is restarted, and the current selection
4280
- * is left exactly as it is.
4353
+ * is left exactly as it is. On is the default, so only the opt-out
4354
+ * (`false`) is written to the file.
4281
4355
  */
4282
4356
  async setAutoDiscoverModels(agentId: string, autoDiscoverModels: boolean): Promise<void> {
4283
4357
  const def = this.defsMap.get(agentId)
4284
4358
  if (def === undefined) throw new AcpError('unknown_agent', `acp: agent "${agentId}" is not configured`)
4285
- if (autoDiscoverModels) def.autoDiscoverModels = true
4286
- else delete def.autoDiscoverModels
4359
+ if (autoDiscoverModels) delete def.autoDiscoverModels
4360
+ else def.autoDiscoverModels = false
4287
4361
  this.persistAgents()
4288
4362
  this.broadcastExposure(agentId)
4289
4363
  }
@@ -4546,9 +4620,15 @@ export class BridgeManager {
4546
4620
  // `defaultEffort` only when the current value is one of the agent's levels.
4547
4621
  const effort = this.reasoningMeta(agentId)?.defaultEffort
4548
4622
  if (effort !== undefined) out.effort = effort
4549
- const extraConfigs: Record<string, string> = {}
4623
+ const extraConfigs: Record<string, string | boolean> = {}
4550
4624
  for (const option of catalog.extras ?? []) {
4551
4625
  const value = option.currentValue
4626
+ if (option.kind === 'boolean') {
4627
+ // The catalog encodes a toggle as 'true' / 'false'; the pick stores the
4628
+ // real boolean, exactly like an explicit checkbox click.
4629
+ if (value === 'true' || value === 'false') extraConfigs[option.configId] = value === 'true'
4630
+ continue
4631
+ }
4552
4632
  if (value !== null && value !== undefined && value !== '') extraConfigs[option.configId] = value
4553
4633
  }
4554
4634
  if (Object.keys(extraConfigs).length > 0) out.extraConfigs = extraConfigs
@@ -4602,7 +4682,11 @@ export class BridgeManager {
4602
4682
  if (extraConfigs[configId] !== undefined) continue
4603
4683
  const option = options.extras?.find(entry => entry.configId === configId)
4604
4684
  if (option === undefined) continue
4605
- if (option.choices.length > 0 && !option.choices.some(choice => choice.value === value)) continue
4685
+ // The fresh session's own option set is the authority for BOTH forms:
4686
+ // a toggle must still be a toggle, a select must still offer the value.
4687
+ if (option.kind === 'boolean'
4688
+ ? typeof value !== 'boolean'
4689
+ : typeof value !== 'string' || (option.choices.length > 0 && !option.choices.some(choice => choice.value === value))) continue
4606
4690
  extraConfigs[configId] = value
4607
4691
  changed = true
4608
4692
  }
@@ -494,6 +494,12 @@ export interface AcpMenuItem {
494
494
  disabled?: boolean
495
495
  /** Leading status dot color (agent process state). */
496
496
  dot?: string
497
+ /**
498
+ * Render as a checkbox row (`menuitemcheckbox`) rather than a radio row.
499
+ * The advanced base uses it for the agent's boolean config options: one row
500
+ * whose check IS the current value, whose `id` is the value a click sets.
501
+ */
502
+ checkbox?: boolean
497
503
  }
498
504
 
499
505
  /** One titled block of rows. */
@@ -623,15 +629,18 @@ export function AcpMenu({ model, anchor, onPick, onClose }: AcpMenuProps) {
623
629
  <button
624
630
  key={`${section.id}/${item.id}`}
625
631
  type="button"
626
- role="menuitemradio"
632
+ role={item.checkbox === true ? 'menuitemcheckbox' : 'menuitemradio'}
627
633
  aria-checked={item.active === true}
628
634
  className={`ntr-acp-menu-item${item.active === true ? ' is-active' : ''}`}
629
635
  disabled={item.disabled === true}
630
636
  onClick={() => { onPick(item.id, section.id) }}
631
637
  >
632
638
  {item.dot !== undefined && <span className="ntr-acp-dot" style={{ background: item.dot }} aria-hidden />}
639
+ {item.checkbox === true && (
640
+ <span className={`ntr-acp-check${item.active === true ? ' is-on' : ''}`} aria-hidden>{item.active === true ? '✓' : ''}</span>
641
+ )}
633
642
  <span className="ntr-acp-menu-item-label">{item.label}</span>
634
- {item.active === true && <span className="ntr-acp-tick" aria-hidden>✓</span>}
643
+ {item.active === true && item.checkbox !== true && <span className="ntr-acp-tick" aria-hidden>✓</span>}
635
644
  {item.detail !== undefined && <span className="ntr-acp-menu-item-detail">{item.detail}</span>}
636
645
  </button>
637
646
  ))}
@@ -853,6 +862,8 @@ const STYLE = `
853
862
  .ntr-acp-seat-sub { color: var(--dsw-alias-text-secondary, #6b7280); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
854
863
  .ntr-acp-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
855
864
  .ntr-acp-tick { margin-left: auto; color: var(--dsw-alias-state-business-primary, #2563eb); }
865
+ .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); }
866
+ .ntr-acp-check.is-on { border-color: var(--dsw-alias-state-business-primary, #2563eb); }
856
867
  .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; }
857
868
  .ntr-acp-menu-head { padding: 8px 10px 6px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
858
869
  .ntr-acp-menu-title { flex: 1; }
@@ -358,7 +358,7 @@ function payloadEquals(payload: AgentSavePayload, current: AcpAgentDef): boolean
358
358
  if (!sameStringArray(payload.allowedCwdRoots ?? [], current.allowedCwdRoots ?? [])) return false
359
359
  if (!sameStringMap(payload.env ?? {}, current.env ?? {})) return false
360
360
  if (exposureKey(payload.exposedModels) !== exposureKey(current.exposedModels)) return false
361
- if ((payload.autoDiscoverModels === true) !== (current.autoDiscoverModels === true)) return false
361
+ if ((payload.autoDiscoverModels !== false) !== (current.autoDiscoverModels !== false)) return false
362
362
  // An absent field means "use the host default" — which is what `current`
363
363
  // already resolved to (see `resolvedIdleTimeoutMs`), so it is not a change.
364
364
  if (payload.idleTimeoutMs !== undefined && payload.idleTimeoutMs !== current.idleTimeoutMs) return false
@@ -701,7 +701,7 @@ function AgentRow({
701
701
  // background resyncs never clobber in-progress checkbox edits.
702
702
  }, [serverExposureKey])
703
703
 
704
- const serverAutoDiscover = detected.currentConfig?.autoDiscoverModels === true
704
+ const serverAutoDiscover = detected.currentConfig?.autoDiscoverModels !== false
705
705
  useEffect(() => {
706
706
  // The switch saves itself through the lightweight RPC, so the host is the
707
707
  // truth; this keeps the form (and therefore a later full save) in step.
@@ -956,7 +956,7 @@ function initialForm(detected: DetectedAgentInfo, activeAgent: AgentView | undef
956
956
  roots,
957
957
  envRows: Object.entries(config?.env ?? {}).map(([key, value]) => ({ key, value })),
958
958
  exposed: config?.exposedModels === undefined ? undefined : [...config.exposedModels],
959
- autoDiscoverModels: config?.autoDiscoverModels === true,
959
+ autoDiscoverModels: config?.autoDiscoverModels !== false,
960
960
  // Prefer the host's resolved value: it is the one actually in effect.
961
961
  idleStr: idleText(activeAgent?.idleTimeoutMs ?? config?.idleTimeoutMs),
962
962
  }
@@ -1011,7 +1011,9 @@ function buildPayload(form: AgentForm, detected: DetectedAgentInfo, newCustom: b
1011
1011
  ...(form.npmPackage.trim() === '' ? {} : { npmPackage: form.npmPackage.trim() }),
1012
1012
  env,
1013
1013
  ...(form.exposed === undefined ? {} : { exposedModels: form.exposed }),
1014
- ...(form.autoDiscoverModels ? { autoDiscoverModels: true } : {}),
1014
+ // Auto-discovery is ON by default: only an unchecked switch records the
1015
+ // opt-out, otherwise a full save would silently re-enable it.
1016
+ ...(form.autoDiscoverModels ? {} : { autoDiscoverModels: false }),
1015
1017
  // `''` sends nothing: the host then applies its own default.
1016
1018
  ...(idleRaw === '' || !idleValid(idleRaw) ? {} : { idleTimeoutMs: Number(idleRaw) }),
1017
1019
  }
@@ -347,6 +347,8 @@ export function describeRoute(
347
347
  export interface AdvancedOption {
348
348
  configId: string
349
349
  name: string
350
+ /** `'boolean'` for an on/off toggle (`value` is `'true'` / `'false'`, no choices). */
351
+ kind?: 'boolean'
350
352
  /** The conversation's pick, else the value the agent reports as current. */
351
353
  value?: string
352
354
  choices: readonly { id: string; name: string; description?: string }[]
@@ -370,10 +372,14 @@ export function advancedOptions(
370
372
  if (catalog === undefined || extras.length === 0) return []
371
373
  const picked = choice !== undefined && choice.agentId === catalog.agentId ? choice.extraConfigs : undefined
372
374
  return extras.map(option => {
373
- const value = picked?.[option.configId] ?? option.currentValue
375
+ const raw = picked?.[option.configId] ?? option.currentValue
376
+ // A toggle's catalog value is 'true' / 'false'; a conversation pick stores
377
+ // the real boolean. Both collapse to the string form the menu speaks.
378
+ const value = typeof raw === 'boolean' ? (raw ? 'true' : 'false') : raw
374
379
  return {
375
380
  configId: option.configId,
376
381
  name: option.name,
382
+ ...(option.kind === 'boolean' ? { kind: 'boolean' as const } : {}),
377
383
  ...(value === undefined ? {} : { value }),
378
384
  choices: option.choices,
379
385
  }
@@ -883,16 +889,28 @@ function thoughtSection(context: MenuContext): AcpMenuSection {
883
889
  * and the pick would then be read as the primary model axis.
884
890
  */
885
891
  function advancedSections(context: MenuContext): AcpMenuSection[] {
886
- return advancedOptions(context.catalog, context.choice).map(option => ({
887
- id: `config:${option.configId}`,
888
- title: option.name,
889
- items: option.choices.map(choice => ({
890
- id: choice.id,
891
- label: choice.name,
892
- ...(choice.description === undefined ? {} : { detail: choice.description }),
893
- active: choice.id === option.value,
894
- })),
895
- }))
892
+ return advancedOptions(context.catalog, context.choice).map(option => {
893
+ if (option.kind === 'boolean') {
894
+ // A toggle is ONE checkbox row, not an On/Off pair: the row's check is
895
+ // the current value and the pick sends the OPPOSITE, which `onPick`
896
+ // forwards verbatim (the host normalizes 'true' / 'false' to a boolean).
897
+ const checked = option.value === 'true'
898
+ return {
899
+ id: `config:${option.configId}`,
900
+ items: [{ id: checked ? 'false' : 'true', label: option.name, active: checked, checkbox: true }],
901
+ }
902
+ }
903
+ return {
904
+ id: `config:${option.configId}`,
905
+ title: option.name,
906
+ items: option.choices.map(choice => ({
907
+ id: choice.id,
908
+ label: choice.name,
909
+ ...(choice.description === undefined ? {} : { detail: choice.description }),
910
+ active: choice.id === option.value,
911
+ })),
912
+ }
913
+ })
896
914
  }
897
915
 
898
916
  /**
@@ -1565,7 +1565,7 @@ export class AcpStore {
1565
1565
  }
1566
1566
 
1567
1567
  /** Set one of the bound agent's EXTRA config options for one chat session. */
1568
- async setChatSessionConfig(dshSessionId: string, configId: string, value: string, agentId?: string): Promise<void> {
1568
+ async setChatSessionConfig(dshSessionId: string, configId: string, value: string | boolean, agentId?: string): Promise<void> {
1569
1569
  try {
1570
1570
  await this.call('session/chat-config-set', {
1571
1571
  dshSessionId,
package/src/impl.ts CHANGED
@@ -226,8 +226,9 @@ export function validateAgentDef(entry: unknown, label = 'agent'): AcpAgentDef {
226
226
  const exposedModels = row.exposedModels === undefined
227
227
  ? undefined
228
228
  : normalizeExposedModels(row.exposedModels, `agent "${id}" exposedModels`, 'bad_config')
229
- // Optional boolean; stored only when true (absent and false are the same
230
- // "off" state, so the persisted file does not carry noise).
229
+ // Optional boolean, ON by default. Only the explicit opt-out is stored:
230
+ // `true` and absent are the same "on" state, so the persisted file does not
231
+ // carry noise.
231
232
  const autoDiscoverModels = row.autoDiscoverModels
232
233
  if (autoDiscoverModels !== undefined && typeof autoDiscoverModels !== 'boolean') {
233
234
  throw new AcpError('bad_config', `acp: agent "${id}" autoDiscoverModels must be a boolean`)
@@ -249,7 +250,7 @@ export function validateAgentDef(entry: unknown, label = 'agent'): AcpAgentDef {
249
250
  cwd: cwd as string | undefined,
250
251
  allowedCwdRoots: roots as string[],
251
252
  ...(exposedModels !== undefined ? { exposedModels } : {}),
252
- ...(autoDiscoverModels === true ? { autoDiscoverModels: true } : {}),
253
+ ...(autoDiscoverModels === false ? { autoDiscoverModels: false } : {}),
253
254
  ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs: idleTimeoutMs as number }),
254
255
  }
255
256
  }
@@ -613,24 +614,110 @@ export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
613
614
  if (configId === '') continue
614
615
  const choices: NonNullable<AcpAgentCatalog['model']>['choices'] = []
615
616
  collectCatalogChoices(row.options, choices)
617
+ // A boolean toggle has no `options` at all; its current value is a JSON
618
+ // boolean. It is encoded as 'true' / 'false' so the catalog stays
619
+ // string-typed for every existing reader, and carries `kind` so the
620
+ // composer renders a checkbox instead of a select.
621
+ const booleanKind = row.type === 'boolean'
622
+ || (typeof row.currentValue === 'boolean' && choices.length === 0)
616
623
  const option: AcpCatalogOption = {
617
624
  configId,
618
625
  name: typeof row.name === 'string' && row.name !== '' ? row.name : configId,
619
- currentValue: typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
626
+ ...(booleanKind ? { kind: 'boolean' as const } : {}),
627
+ currentValue: booleanKind
628
+ ? row.currentValue === true || row.currentValue === 'true'
629
+ ? 'true'
630
+ : row.currentValue === false || row.currentValue === 'false' ? 'false' : null
631
+ : typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
620
632
  choices,
621
633
  }
622
- if (slot !== undefined) {
634
+ // A toggle is never a model / thought / mode SELECT: those controls pick
635
+ // among choices. It belongs to the advanced base, where it is a checkbox.
636
+ if (slot !== undefined && !booleanKind) {
623
637
  out[slot] = option
624
638
  continue
625
639
  }
626
640
  // Choice-less options have nothing to pick; they would render an empty
627
- // section in the advanced menu, so they stay out of the catalog entirely.
628
- if (choices.length > 0) extras.set(configId, option)
641
+ // section in the advanced menu, so they stay out of the catalog entirely —
642
+ // except a boolean, whose whole point is that it has no choices.
643
+ if (choices.length > 0 || booleanKind) extras.set(configId, option)
629
644
  }
630
645
  if (extras.size > 0) out.extras = [...extras.values()]
631
646
  return out
632
647
  }
633
648
 
649
+ /**
650
+ * Fold one per-session option answer's SHAPE into an agent-level catalog.
651
+ *
652
+ * `session/set_config_option` answers are often the ONLY place an agent
653
+ * publishes how its option set changes with the model — opencode offers
654
+ * `thought_level` (`effort`) just for models with variants and never
655
+ * re-notifies after a client-driven set — and the composer's thought chip is
656
+ * driven by the agent-level catalog. What is agent knowledge there is the
657
+ * SHAPE (which slots exist, which choices they offer); `currentValue` is not:
658
+ * in a per-session answer it is that session's pick, and promoting it would
659
+ * turn one conversation's choice into `reasoning.defaultEffort` for every
660
+ * other one. A merged slot therefore keeps the catalog's known current value
661
+ * while the observed choices still offer it, and `null` ("the agent's own
662
+ * default") otherwise. Slots the answer does not mention are left alone — a
663
+ * different model may still offer them.
664
+ */
665
+ export function mergeCatalogShape(existing: AcpAgentCatalog | undefined, observed: AcpAgentCatalog): AcpAgentCatalog {
666
+ const out: AcpAgentCatalog = {}
667
+ const merge = (prev: AcpCatalogOption | undefined, next: AcpCatalogOption): AcpCatalogOption => {
668
+ const kept = prev?.currentValue ?? null
669
+ return {
670
+ configId: next.configId,
671
+ name: next.name,
672
+ ...(next.kind === 'boolean' ? { kind: 'boolean' as const } : {}),
673
+ // A boolean has no choices to test `kept` against; it keeps the
674
+ // catalog's known default like any other slot (the answer's value is
675
+ // still one session's truth, not the agent's).
676
+ currentValue: kept !== null && (next.choices.length === 0 || next.choices.some(choice => choice.value === kept)) ? kept : null,
677
+ choices: next.choices,
678
+ }
679
+ }
680
+ for (const slot of ['model', 'thought', 'mode'] as const) {
681
+ const prev = existing?.[slot]
682
+ const next = observed[slot]
683
+ if (next === undefined) {
684
+ if (prev !== undefined) out[slot] = prev
685
+ continue
686
+ }
687
+ out[slot] = merge(prev, next)
688
+ }
689
+ // Extras merge by configId: observed entries refresh their shape, entries
690
+ // only the earlier answer knew survive (another model may still offer them).
691
+ const extras = new Map<string, AcpCatalogOption>()
692
+ for (const option of existing?.extras ?? []) extras.set(option.configId, option)
693
+ for (const option of observed.extras ?? []) extras.set(option.configId, merge(extras.get(option.configId), option))
694
+ if (extras.size > 0) out.extras = [...extras.values()]
695
+ return out
696
+ }
697
+
698
+ /**
699
+ * Size ceiling for one `configOptions` answer to be believed. Normal answers
700
+ * are a few KB (a 500-model catalog is well under this); beyond it the agent is
701
+ * misbehaving and absorbing/persisting it would turn every notification into
702
+ * an unbounded write.
703
+ */
704
+ export const MAX_CONFIG_OPTIONS_BYTES = 1 << 20
705
+
706
+ /**
707
+ * Whether a `configOptions` answer may overwrite known state.
708
+ *
709
+ * An EMPTY (or absent) answer is a non-answer: observed agents return
710
+ * `configOptions: []` in intermediate states, and treating that as "this agent
711
+ * now has no options" would wipe a perfectly good catalog and session
712
+ * snapshot for a state that passes (the rule OmniTerm settles on for the same
713
+ * observation — empty sets never overwrite the last known state). Oversized
714
+ * answers are dropped for the same reason: see {@link MAX_CONFIG_OPTIONS_BYTES}.
715
+ */
716
+ export function usableConfigOptions(configOptions: unknown): configOptions is unknown[] {
717
+ if (!Array.isArray(configOptions) || configOptions.length === 0) return false
718
+ return JSON.stringify(configOptions).length <= MAX_CONFIG_OPTIONS_BYTES
719
+ }
720
+
634
721
  /** Flatten select `options` (plain values or `SessionConfigSelectGroup`s) into choices, deduped by value. */
635
722
  function collectCatalogChoices(options: unknown, out: NonNullable<AcpAgentCatalog['model']>['choices']): void {
636
723
  if (!Array.isArray(options)) return
@@ -1433,7 +1520,7 @@ export interface AcpManager {
1433
1520
  /** Switch the reasoning effort for one chat session (agentId names the default-agent case). */
1434
1521
  setChatSessionEffort?(dshSessionId: string, effort: string | undefined, agentId?: string): Promise<void>
1435
1522
  /** Set one of the agent's EXTRA config options for one chat session. */
1436
- setChatSessionConfig?(dshSessionId: string, configId: string, value: string, agentId?: string): Promise<void>
1523
+ setChatSessionConfig?(dshSessionId: string, configId: string, value: string | boolean, agentId?: string): Promise<void>
1437
1524
  /**
1438
1525
  * State of the ACP-mode agent preset in the harness's OWN roster. Read-only
1439
1526
  * report for the settings panel: the preset is a declaration this package
@@ -1604,8 +1691,12 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
1604
1691
  case 'session/chat-config-set': {
1605
1692
  const dshSessionId = str(body, 'dshSessionId', 128)
1606
1693
  const configId = str(body, 'configId', 128)
1607
- const value = str(body, 'value', 512)
1608
- await manager.setChatSessionConfig?.(dshSessionId, configId, value, optionalStr(body, 'agentId', 64))
1694
+ const value = body.value
1695
+ if (typeof value === 'boolean') {
1696
+ await manager.setChatSessionConfig?.(dshSessionId, configId, value, optionalStr(body, 'agentId', 64))
1697
+ return ok({ done: true })
1698
+ }
1699
+ await manager.setChatSessionConfig?.(dshSessionId, configId, str(body, 'value', 512), optionalStr(body, 'agentId', 64))
1609
1700
  return ok({ done: true })
1610
1701
  }
1611
1702
  case 'preset/status':