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/lib/impl.js CHANGED
@@ -222,8 +222,9 @@ export function validateAgentDef(entry, label = 'agent') {
222
222
  const exposedModels = row.exposedModels === undefined
223
223
  ? undefined
224
224
  : normalizeExposedModels(row.exposedModels, `agent "${id}" exposedModels`, 'bad_config');
225
- // Optional boolean; stored only when true (absent and false are the same
226
- // "off" state, so the persisted file does not carry noise).
225
+ // Optional boolean, ON by default. Only the explicit opt-out is stored:
226
+ // `true` and absent are the same "on" state, so the persisted file does not
227
+ // carry noise.
227
228
  const autoDiscoverModels = row.autoDiscoverModels;
228
229
  if (autoDiscoverModels !== undefined && typeof autoDiscoverModels !== 'boolean') {
229
230
  throw new AcpError('bad_config', `acp: agent "${id}" autoDiscoverModels must be a boolean`);
@@ -245,7 +246,7 @@ export function validateAgentDef(entry, label = 'agent') {
245
246
  cwd: cwd,
246
247
  allowedCwdRoots: roots,
247
248
  ...(exposedModels !== undefined ? { exposedModels } : {}),
248
- ...(autoDiscoverModels === true ? { autoDiscoverModels: true } : {}),
249
+ ...(autoDiscoverModels === false ? { autoDiscoverModels: false } : {}),
249
250
  ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs: idleTimeoutMs }),
250
251
  };
251
252
  }
@@ -603,25 +604,113 @@ export function parseCatalogOptions(configOptions) {
603
604
  continue;
604
605
  const choices = [];
605
606
  collectCatalogChoices(row.options, choices);
607
+ // A boolean toggle has no `options` at all; its current value is a JSON
608
+ // boolean. It is encoded as 'true' / 'false' so the catalog stays
609
+ // string-typed for every existing reader, and carries `kind` so the
610
+ // composer renders a checkbox instead of a select.
611
+ const booleanKind = row.type === 'boolean'
612
+ || (typeof row.currentValue === 'boolean' && choices.length === 0);
606
613
  const option = {
607
614
  configId,
608
615
  name: typeof row.name === 'string' && row.name !== '' ? row.name : configId,
609
- currentValue: typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
616
+ ...(booleanKind ? { kind: 'boolean' } : {}),
617
+ currentValue: booleanKind
618
+ ? row.currentValue === true || row.currentValue === 'true'
619
+ ? 'true'
620
+ : row.currentValue === false || row.currentValue === 'false' ? 'false' : null
621
+ : typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
610
622
  choices,
611
623
  };
612
- if (slot !== undefined) {
624
+ // A toggle is never a model / thought / mode SELECT: those controls pick
625
+ // among choices. It belongs to the advanced base, where it is a checkbox.
626
+ if (slot !== undefined && !booleanKind) {
613
627
  out[slot] = option;
614
628
  continue;
615
629
  }
616
630
  // Choice-less options have nothing to pick; they would render an empty
617
- // section in the advanced menu, so they stay out of the catalog entirely.
618
- if (choices.length > 0)
631
+ // section in the advanced menu, so they stay out of the catalog entirely —
632
+ // except a boolean, whose whole point is that it has no choices.
633
+ if (choices.length > 0 || booleanKind)
619
634
  extras.set(configId, option);
620
635
  }
621
636
  if (extras.size > 0)
622
637
  out.extras = [...extras.values()];
623
638
  return out;
624
639
  }
640
+ /**
641
+ * Fold one per-session option answer's SHAPE into an agent-level catalog.
642
+ *
643
+ * `session/set_config_option` answers are often the ONLY place an agent
644
+ * publishes how its option set changes with the model — opencode offers
645
+ * `thought_level` (`effort`) just for models with variants and never
646
+ * re-notifies after a client-driven set — and the composer's thought chip is
647
+ * driven by the agent-level catalog. What is agent knowledge there is the
648
+ * SHAPE (which slots exist, which choices they offer); `currentValue` is not:
649
+ * in a per-session answer it is that session's pick, and promoting it would
650
+ * turn one conversation's choice into `reasoning.defaultEffort` for every
651
+ * other one. A merged slot therefore keeps the catalog's known current value
652
+ * while the observed choices still offer it, and `null` ("the agent's own
653
+ * default") otherwise. Slots the answer does not mention are left alone — a
654
+ * different model may still offer them.
655
+ */
656
+ export function mergeCatalogShape(existing, observed) {
657
+ const out = {};
658
+ const merge = (prev, next) => {
659
+ const kept = prev?.currentValue ?? null;
660
+ return {
661
+ configId: next.configId,
662
+ name: next.name,
663
+ ...(next.kind === 'boolean' ? { kind: 'boolean' } : {}),
664
+ // A boolean has no choices to test `kept` against; it keeps the
665
+ // catalog's known default like any other slot (the answer's value is
666
+ // still one session's truth, not the agent's).
667
+ currentValue: kept !== null && (next.choices.length === 0 || next.choices.some(choice => choice.value === kept)) ? kept : null,
668
+ choices: next.choices,
669
+ };
670
+ };
671
+ for (const slot of ['model', 'thought', 'mode']) {
672
+ const prev = existing?.[slot];
673
+ const next = observed[slot];
674
+ if (next === undefined) {
675
+ if (prev !== undefined)
676
+ out[slot] = prev;
677
+ continue;
678
+ }
679
+ out[slot] = merge(prev, next);
680
+ }
681
+ // Extras merge by configId: observed entries refresh their shape, entries
682
+ // only the earlier answer knew survive (another model may still offer them).
683
+ const extras = new Map();
684
+ for (const option of existing?.extras ?? [])
685
+ extras.set(option.configId, option);
686
+ for (const option of observed.extras ?? [])
687
+ extras.set(option.configId, merge(extras.get(option.configId), option));
688
+ if (extras.size > 0)
689
+ out.extras = [...extras.values()];
690
+ return out;
691
+ }
692
+ /**
693
+ * Size ceiling for one `configOptions` answer to be believed. Normal answers
694
+ * are a few KB (a 500-model catalog is well under this); beyond it the agent is
695
+ * misbehaving and absorbing/persisting it would turn every notification into
696
+ * an unbounded write.
697
+ */
698
+ export const MAX_CONFIG_OPTIONS_BYTES = 1 << 20;
699
+ /**
700
+ * Whether a `configOptions` answer may overwrite known state.
701
+ *
702
+ * An EMPTY (or absent) answer is a non-answer: observed agents return
703
+ * `configOptions: []` in intermediate states, and treating that as "this agent
704
+ * now has no options" would wipe a perfectly good catalog and session
705
+ * snapshot for a state that passes (the rule OmniTerm settles on for the same
706
+ * observation — empty sets never overwrite the last known state). Oversized
707
+ * answers are dropped for the same reason: see {@link MAX_CONFIG_OPTIONS_BYTES}.
708
+ */
709
+ export function usableConfigOptions(configOptions) {
710
+ if (!Array.isArray(configOptions) || configOptions.length === 0)
711
+ return false;
712
+ return JSON.stringify(configOptions).length <= MAX_CONFIG_OPTIONS_BYTES;
713
+ }
625
714
  /** Flatten select `options` (plain values or `SessionConfigSelectGroup`s) into choices, deduped by value. */
626
715
  function collectCatalogChoices(options, out) {
627
716
  if (!Array.isArray(options))
@@ -1353,8 +1442,12 @@ export async function dispatchRpc(manager, endpoint, payload) {
1353
1442
  case 'session/chat-config-set': {
1354
1443
  const dshSessionId = str(body, 'dshSessionId', 128);
1355
1444
  const configId = str(body, 'configId', 128);
1356
- const value = str(body, 'value', 512);
1357
- await manager.setChatSessionConfig?.(dshSessionId, configId, value, optionalStr(body, 'agentId', 64));
1445
+ const value = body.value;
1446
+ if (typeof value === 'boolean') {
1447
+ await manager.setChatSessionConfig?.(dshSessionId, configId, value, optionalStr(body, 'agentId', 64));
1448
+ return ok({ done: true });
1449
+ }
1450
+ await manager.setChatSessionConfig?.(dshSessionId, configId, str(body, 'value', 512), optionalStr(body, 'agentId', 64));
1358
1451
  return ok({ done: true });
1359
1452
  }
1360
1453
  case 'preset/status':
@@ -203,7 +203,13 @@ export declare class AgentBridge {
203
203
  private readonly transcriptMax;
204
204
  /** Called when the connection dies so the manager can settle outstanding prompts. */
205
205
  private readonly onConnectionLost;
206
- /** Observes `configOptions` from any real session so the manager's model catalog stays warm. */
206
+ /**
207
+ * Observes `configOptions` from any real session so the manager's model
208
+ * catalog stays warm. `shapeOnly` marks a per-session answer (a
209
+ * `session/set_config_option` response): only the option SHAPE belongs in
210
+ * the agent-level catalog, never its `currentValue` — see
211
+ * {@link BridgeManager.absorbCatalogShape}.
212
+ */
207
213
  private readonly onCatalog?;
208
214
  /** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
209
215
  private readonly onCommands?;
@@ -279,8 +285,14 @@ export declare class AgentBridge {
279
285
  constructor(def: AcpAgentDef, hub: EventHub, transcriptMax: number,
280
286
  /** Called when the connection dies so the manager can settle outstanding prompts. */
281
287
  onConnectionLost: (bridge: AgentBridge, reason: string) => void,
282
- /** Observes `configOptions` from any real session so the manager's model catalog stays warm. */
283
- onCatalog?: ((agentId: string, configOptions: unknown) => void) | undefined,
288
+ /**
289
+ * Observes `configOptions` from any real session so the manager's model
290
+ * catalog stays warm. `shapeOnly` marks a per-session answer (a
291
+ * `session/set_config_option` response): only the option SHAPE belongs in
292
+ * the agent-level catalog, never its `currentValue` — see
293
+ * {@link BridgeManager.absorbCatalogShape}.
294
+ */
295
+ onCatalog?: ((agentId: string, configOptions: unknown, shapeOnly?: boolean) => void) | undefined,
284
296
  /** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
285
297
  onCommands?: ((agentId: string, commands: NonNullable<SessionView["availableCommands"]>) => void) | undefined, tuning?: BridgeTuning,
286
298
  /**
@@ -1095,7 +1107,7 @@ export declare class BridgeManager {
1095
1107
  * A live session is updated immediately; a failure there is warned about and
1096
1108
  * the pick still stands, exactly like the model/effort alignment path.
1097
1109
  */
1098
- setChatSessionConfig(dshSessionId: string, configId: string, value: string, agentId?: string): Promise<void>;
1110
+ setChatSessionConfig(dshSessionId: string, configId: string, value: string | boolean, agentId?: string): Promise<void>;
1099
1111
  /**
1100
1112
  * Apply a conversation's persisted pick to a session that just materialized —
1101
1113
  * created by the first turn, or resumed through `session/load` after a crash.
@@ -1209,6 +1221,18 @@ export declare class BridgeManager {
1209
1221
  * current `parseCatalogOptions` has now seen this agent's real answer.
1210
1222
  */
1211
1223
  private absorbCatalog;
1224
+ /**
1225
+ * Fold a per-session option answer's SHAPE into the agent's catalog.
1226
+ *
1227
+ * The counterpart of {@link absorbCatalog} for `session/set_config_option`
1228
+ * responses: which options exist and which choices they offer is agent
1229
+ * knowledge the seat must show (the composer's thought chip only renders
1230
+ * when the catalog has `thought_level`, and some agents publish it only in
1231
+ * the answer to a model change), while a per-session `currentValue` stays
1232
+ * out of it. No auto-exposure and no staleness discharge either — this is
1233
+ * one session's answer, not the agent-level observation those marks track.
1234
+ */
1235
+ private absorbCatalogShape;
1212
1236
  /**
1213
1237
  * Remember the slash commands an agent declared in a real session.
1214
1238
  *
@@ -1229,7 +1253,8 @@ export declare class BridgeManager {
1229
1253
  * selection would resurrect a model the user deliberately unchecked every
1230
1254
  * time the agent repeats its catalog. The first observation has no baseline
1231
1255
  * (`before === undefined`), so it adds nothing; the user can still add the
1232
- * whole list with one click in the settings card.
1256
+ * whole list with one click in the settings card. Discovery is ON unless the
1257
+ * agent definition explicitly opts out with `autoDiscoverModels: false`.
1233
1258
  */
1234
1259
  private autoExposeNewModels;
1235
1260
  /** Tell the browser an agent's model-visibility surface changed outside `agent/save`. */
@@ -1382,7 +1407,8 @@ export declare class BridgeManager {
1382
1407
  * Toggle auto-discovery of newly published models. Lightweight like
1383
1408
  * `setExposedModels`: the def is mutated in place (live bridges share the
1384
1409
  * object) and persisted; no process is restarted, and the current selection
1385
- * is left exactly as it is.
1410
+ * is left exactly as it is. On is the default, so only the opt-out
1411
+ * (`false`) is written to the file.
1386
1412
  */
1387
1413
  setAutoDiscoverModels(agentId: string, autoDiscoverModels: boolean): Promise<void>;
1388
1414
  deleteAgent(agentId: string): Promise<void>;
@@ -299,6 +299,12 @@ export interface AcpMenuItem {
299
299
  disabled?: boolean;
300
300
  /** Leading status dot color (agent process state). */
301
301
  dot?: string;
302
+ /**
303
+ * Render as a checkbox row (`menuitemcheckbox`) rather than a radio row.
304
+ * The advanced base uses it for the agent's boolean config options: one row
305
+ * whose check IS the current value, whose `id` is the value a click sets.
306
+ */
307
+ checkbox?: boolean;
302
308
  }
303
309
  /** One titled block of rows. */
304
310
  export interface AcpMenuSection {
@@ -182,6 +182,8 @@ export declare function describeRoute(agents: readonly AgentView[], bound: ChatS
182
182
  export interface AdvancedOption {
183
183
  configId: string;
184
184
  name: string;
185
+ /** `'boolean'` for an on/off toggle (`value` is `'true'` / `'false'`, no choices). */
186
+ kind?: 'boolean';
185
187
  /** The conversation's pick, else the value the agent reports as current. */
186
188
  value?: string;
187
189
  choices: readonly {
@@ -579,7 +579,7 @@ export declare class AcpStore {
579
579
  /** Switch the bound agent's reasoning effort for one chat session. */
580
580
  setChatSessionEffort(dshSessionId: string, effort: string | undefined, agentId?: string): Promise<void>;
581
581
  /** Set one of the bound agent's EXTRA config options for one chat session. */
582
- setChatSessionConfig(dshSessionId: string, configId: string, value: string, agentId?: string): Promise<void>;
582
+ setChatSessionConfig(dshSessionId: string, configId: string, value: string | boolean, agentId?: string): Promise<void>;
583
583
  /**
584
584
  * Patch one conversation's optimistic pick. The agent half is required by the
585
585
  * record (a model or thought level belongs to an agent). Its owner is the
@@ -145,6 +145,41 @@ export declare function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string
145
145
  * anything unusable is skipped silently.
146
146
  */
147
147
  export declare function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog;
148
+ /**
149
+ * Fold one per-session option answer's SHAPE into an agent-level catalog.
150
+ *
151
+ * `session/set_config_option` answers are often the ONLY place an agent
152
+ * publishes how its option set changes with the model — opencode offers
153
+ * `thought_level` (`effort`) just for models with variants and never
154
+ * re-notifies after a client-driven set — and the composer's thought chip is
155
+ * driven by the agent-level catalog. What is agent knowledge there is the
156
+ * SHAPE (which slots exist, which choices they offer); `currentValue` is not:
157
+ * in a per-session answer it is that session's pick, and promoting it would
158
+ * turn one conversation's choice into `reasoning.defaultEffort` for every
159
+ * other one. A merged slot therefore keeps the catalog's known current value
160
+ * while the observed choices still offer it, and `null` ("the agent's own
161
+ * default") otherwise. Slots the answer does not mention are left alone — a
162
+ * different model may still offer them.
163
+ */
164
+ export declare function mergeCatalogShape(existing: AcpAgentCatalog | undefined, observed: AcpAgentCatalog): AcpAgentCatalog;
165
+ /**
166
+ * Size ceiling for one `configOptions` answer to be believed. Normal answers
167
+ * are a few KB (a 500-model catalog is well under this); beyond it the agent is
168
+ * misbehaving and absorbing/persisting it would turn every notification into
169
+ * an unbounded write.
170
+ */
171
+ export declare const MAX_CONFIG_OPTIONS_BYTES: number;
172
+ /**
173
+ * Whether a `configOptions` answer may overwrite known state.
174
+ *
175
+ * An EMPTY (or absent) answer is a non-answer: observed agents return
176
+ * `configOptions: []` in intermediate states, and treating that as "this agent
177
+ * now has no options" would wipe a perfectly good catalog and session
178
+ * snapshot for a state that passes (the rule OmniTerm settles on for the same
179
+ * observation — empty sets never overwrite the last known state). Oversized
180
+ * answers are dropped for the same reason: see {@link MAX_CONFIG_OPTIONS_BYTES}.
181
+ */
182
+ export declare function usableConfigOptions(configOptions: unknown): configOptions is unknown[];
148
183
  /**
149
184
  * Split an adapter model id into its agent id and optional model choice.
150
185
  * `@` is the separator because agent ids are restricted to `[a-z0-9._-]`.
@@ -598,7 +633,7 @@ export interface AcpManager {
598
633
  /** Switch the reasoning effort for one chat session (agentId names the default-agent case). */
599
634
  setChatSessionEffort?(dshSessionId: string, effort: string | undefined, agentId?: string): Promise<void>;
600
635
  /** Set one of the agent's EXTRA config options for one chat session. */
601
- setChatSessionConfig?(dshSessionId: string, configId: string, value: string, agentId?: string): Promise<void>;
636
+ setChatSessionConfig?(dshSessionId: string, configId: string, value: string | boolean, agentId?: string): Promise<void>;
602
637
  /**
603
638
  * State of the ACP-mode agent preset in the harness's OWN roster. Read-only
604
639
  * report for the settings panel: the preset is a declaration this package
@@ -62,12 +62,13 @@ export interface AcpAgentDef {
62
62
  */
63
63
  exposedModels?: string[];
64
64
  /**
65
- * Auto-discovery for {@link exposedModels}: when true, every model the agent
66
- * NEWLY publishes (present in an observed catalog but absent from the one
67
- * known before it) is appended to `exposedModels` automatically. It never
65
+ * Auto-discovery for {@link exposedModels}, ON by default: every model the
66
+ * agent NEWLY publishes (present in an observed catalog but absent from the
67
+ * one known before it) is appended to `exposedModels` automatically. It never
68
68
  * touches models the user already had selected, and the very first catalog
69
- * observation is a baseline (nothing is "new" yet). Set from the settings
70
- * card's lightweight `agent/set-auto-discover-models` RPC — never by
69
+ * observation is a baseline (nothing is "new" yet). `false` is the explicit
70
+ * opt-out and the only value persisted; unset means "on". Flipped from the
71
+ * settings card's lightweight `agent/set-auto-discover-models` RPC — never by
71
72
  * `agent/save`, which restarts the process for connection changes.
72
73
  */
73
74
  autoDiscoverModels?: boolean;
@@ -566,7 +567,7 @@ export interface ChatEffortSetPayload {
566
567
  export interface ChatConfigSetPayload {
567
568
  dshSessionId: string;
568
569
  configId: string;
569
- value: string;
570
+ value: string | boolean;
570
571
  agentId?: string;
571
572
  }
572
573
  /** Host-detected agent presence and tested defaults. */
@@ -593,7 +594,7 @@ export interface AgentSavePayload {
593
594
  allowedCwdRoots?: string[];
594
595
  env?: Record<string, string>;
595
596
  exposedModels?: string[];
596
- /** Keep newer models the agent publishes in the selection (see {@link AcpAgentDef.autoDiscoverModels}). */
597
+ /** Opt out of auto-discovery with `false`; absent keeps the default (on). See {@link AcpAgentDef.autoDiscoverModels}. */
597
598
  autoDiscoverModels?: boolean;
598
599
  /** Absent → keep the host default (30 minutes); `0` = never release. */
599
600
  idleTimeoutMs?: number;
@@ -636,6 +637,12 @@ export interface AcpCatalogChoice {
636
637
  export interface AcpCatalogOption {
637
638
  configId: string;
638
639
  name: string;
640
+ /**
641
+ * `'boolean'` for an on/off toggle: `currentValue` is the string `'true'` /
642
+ * `'false'` (the catalog stays string-typed so every existing reader keeps
643
+ * working) and `choices` is empty. Absent means the ordinary select.
644
+ */
645
+ kind?: 'boolean';
639
646
  /** Currently selected value at parse time (may be absent from `choices`). */
640
647
  currentValue: string | null;
641
648
  choices: AcpCatalogChoice[];
@@ -713,7 +720,7 @@ export interface AcpConversationChoice {
713
720
  * accepts, persisted per conversation so a pick survives a restart and is
714
721
  * re-applied when its ACP session is recreated or resumed.
715
722
  */
716
- extraConfigs?: Record<string, string>;
723
+ extraConfigs?: Record<string, string | boolean>;
717
724
  }
718
725
  /** Result of `agent/probe-models`: the freshly probed (or cached) catalog. */
719
726
  export interface AgentProbeModelsResult {
@@ -726,6 +733,13 @@ export interface AgentCatalogExtraOption {
726
733
  configId: string;
727
734
  /** The agent's own option name (falls back to the config id host-side). */
728
735
  name: string;
736
+ /**
737
+ * `'boolean'` for an on/off toggle (`currentValue` is `'true'` / `'false'`,
738
+ * `choices` is empty and the composer renders a checkbox row). Absent means
739
+ * the ordinary select. Kept optional so cached catalogs written before
740
+ * toggles existed still read.
741
+ */
742
+ kind?: 'boolean';
729
743
  /** The value the agent reports as current, when it reports one. */
730
744
  currentValue?: string;
731
745
  choices: Array<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ntr",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent and stream its session.",
5
5
  "repository": "https://github.com/GDWhisper/dsh-ntr.git",
6
6
  "type": "module",