dsh-ntr 0.1.3 → 0.1.5

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/bridge.js CHANGED
@@ -12,17 +12,70 @@ 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, validateApprovalDeadlines, } 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`. */
19
19
  const FS_MAX_READ_BYTES = 2 * 1024 * 1024;
20
20
  /** Single fs/write_text_file payload cap. */
21
21
  const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024;
22
+ /**
23
+ * The plugin's normalized MCP server shape → the ACP wire shape.
24
+ *
25
+ * THE WIRE SHAPE IS ASYMMETRIC, and getting it wrong is SILENT:
26
+ *
27
+ * - the stdio variant carries NO `type` field — an entry that carries
28
+ * `type:'stdio'` is accepted by the agent and then never connected
29
+ * (measured against codebuddy: the server never appeared, the agent just
30
+ * reported the tool as missing);
31
+ * - http/sse DO carry `type`;
32
+ * - `env` / `headers` are `[{name,value}]` arrays on the wire, while the
33
+ * plugin stores them as records (like `AcpAgentDef.env`).
34
+ *
35
+ * So the conversion lives here, once, and each branch is annotated with the
36
+ * SDK's OWN variant type — a wrong conversion does not compile. (The bare
37
+ * `McpServer` union is too loose to catch it: its excess-property check
38
+ * accepts any member's keys, so a stdio entry carrying `type:'stdio'` would
39
+ * type-check against the http variant and only fail at runtime, inside the
40
+ * agent, as a server that never connects.)
41
+ */
42
+ function toAcpMcpServers(defs) {
43
+ if (defs === undefined)
44
+ return [];
45
+ return defs.map(def => {
46
+ if (def.transport === 'stdio') {
47
+ // NO `type` here — see the note above.
48
+ const server = {
49
+ name: def.name,
50
+ command: def.command,
51
+ args: def.args ?? [],
52
+ env: Object.entries(def.env ?? {}).map(([name, value]) => ({ name, value })),
53
+ };
54
+ return server;
55
+ }
56
+ if (def.transport === 'http') {
57
+ const server = {
58
+ type: 'http',
59
+ name: def.name,
60
+ url: def.url,
61
+ headers: Object.entries(def.headers ?? {}).map(([name, value]) => ({ name, value })),
62
+ };
63
+ return server;
64
+ }
65
+ const server = {
66
+ type: 'sse',
67
+ name: def.name,
68
+ url: def.url,
69
+ headers: Object.entries(def.headers ?? {}).map(([name, value]) => ({ name, value })),
70
+ };
71
+ return server;
72
+ });
73
+ }
22
74
  const DEFAULT_TUNING = {
23
75
  startupTimeoutMs: 60_000,
24
76
  permissionTimeoutMs: 0,
25
77
  permissionFallbackTimeoutMs: 300_000,
78
+ nativeApprovalTimeoutMs: 300_000,
26
79
  cancelFallbackMs: 15_000,
27
80
  staleCancelRetryDelayMs: 600,
28
81
  staleCancelMaxRetries: 2,
@@ -64,7 +117,18 @@ export class AgentBridge {
64
117
  ledgerFallbacks = new Map;
65
118
  startupTimeoutMs;
66
119
  permissionTimeoutMs;
120
+ /**
121
+ * The two approval deadlines are NOT readonly, unlike their neighbors: the
122
+ * settings page rewrites them on a running bridge
123
+ * ({@link AgentBridge.applyApprovalDeadlines}), so a user editing the window
124
+ * does not have to kill every live session to see it take effect. Both are
125
+ * read per ask — `nativeApprovalTimeoutMs` when the native ask is armed,
126
+ * `permissionFallbackTimeoutMs` when the ledger ask's deadline is chosen — so
127
+ * a reassignment reaches every ask from that moment on. An ask already
128
+ * holding an armed timer keeps the window it was started with.
129
+ */
67
130
  permissionFallbackTimeoutMs;
131
+ nativeApprovalTimeoutMs;
68
132
  cancelFallbackMs;
69
133
  staleCancelRetryDelayMs;
70
134
  staleCancelMaxRetries;
@@ -101,7 +165,13 @@ export class AgentBridge {
101
165
  constructor(def, hub, transcriptMax,
102
166
  /** Called when the connection dies so the manager can settle outstanding prompts. */
103
167
  onConnectionLost,
104
- /** Observes `configOptions` from any real session so the manager's model catalog stays warm. */
168
+ /**
169
+ * Observes `configOptions` from any real session so the manager's model
170
+ * catalog stays warm. `shapeOnly` marks a per-session answer (a
171
+ * `session/set_config_option` response): only the option SHAPE belongs in
172
+ * the agent-level catalog, never its `currentValue` — see
173
+ * {@link BridgeManager.absorbCatalogShape}.
174
+ */
105
175
  onCatalog,
106
176
  /** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
107
177
  onCommands, tuning,
@@ -127,6 +197,7 @@ export class AgentBridge {
127
197
  this.startupTimeoutMs = merged.startupTimeoutMs;
128
198
  this.permissionTimeoutMs = merged.permissionTimeoutMs;
129
199
  this.permissionFallbackTimeoutMs = merged.permissionFallbackTimeoutMs;
200
+ this.nativeApprovalTimeoutMs = merged.nativeApprovalTimeoutMs;
130
201
  this.cancelFallbackMs = merged.cancelFallbackMs;
131
202
  this.staleCancelRetryDelayMs = merged.staleCancelRetryDelayMs;
132
203
  this.staleCancelMaxRetries = merged.staleCancelMaxRetries;
@@ -134,6 +205,22 @@ export class AgentBridge {
134
205
  this.defaultIdleTimeoutMs = merged.idleTimeoutMs;
135
206
  this.probeIdleTimeoutMs = merged.probeIdleTimeoutMs;
136
207
  }
208
+ /**
209
+ * Adopt a rewritten approval deadline (the settings page's write). Only the
210
+ * two degraded-route windows live here; every other tuning value is fixed at
211
+ * construction, because a live bridge cannot meaningfully adopt a new startup
212
+ * or cancel window mid-flight.
213
+ *
214
+ * Deliberately does NOT re-arm anything: an ask that already holds a timer
215
+ * keeps the window it was started with, and the next ask reads the new value.
216
+ * Re-arming would move the goalposts under a human who is already deciding.
217
+ */
218
+ applyApprovalDeadlines(patch) {
219
+ if (patch.nativeApprovalTimeoutMs !== undefined)
220
+ this.nativeApprovalTimeoutMs = patch.nativeApprovalTimeoutMs;
221
+ if (patch.permissionFallbackTimeoutMs !== undefined)
222
+ this.permissionFallbackTimeoutMs = patch.permissionFallbackTimeoutMs;
223
+ }
137
224
  /**
138
225
  * The idle-release window this agent honors for its CHAT process: its own
139
226
  * field, else the host default. This is the number the panel prefills and
@@ -464,7 +551,14 @@ export class AgentBridge {
464
551
  protocolVersion: PROTOCOL_VERSION,
465
552
  // The fs handlers are implemented below behind the same workspace
466
553
  // sandbox that gates session cwds; terminal is not implemented.
467
- clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false },
554
+ // `session.configOptions.boolean` advertises the toggle form of
555
+ // `configOptions`; agents that gate `type: "boolean"` entries on it
556
+ // would otherwise never publish them.
557
+ clientCapabilities: {
558
+ fs: { readTextFile: true, writeTextFile: true },
559
+ terminal: false,
560
+ session: { configOptions: { boolean: {} } },
561
+ },
468
562
  clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: '0.1.0' },
469
563
  }), 'initialize');
470
564
  this.initResult = init;
@@ -515,8 +609,9 @@ export class AgentBridge {
515
609
  if (conn === undefined)
516
610
  throw new AcpError('agent_unreachable', 'acp: connection is not open');
517
611
  const resolved = resolveSessionCwd(this.def, cwd, p => realpathSync(p), extraRoots);
518
- 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) {
612
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/new');
613
+ // An empty `configOptions` answer is a non-answer — see `usableConfigOptions`.
614
+ if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
520
615
  this.onCatalog(this.def.id, result.configOptions);
521
616
  }
522
617
  const session = {
@@ -541,7 +636,7 @@ export class AgentBridge {
541
636
  },
542
637
  }
543
638
  : {}),
544
- ...(result.configOptions !== undefined && result.configOptions !== null ? { configOptions: result.configOptions } : {}),
639
+ ...(usableConfigOptions(result.configOptions) ? { configOptions: result.configOptions } : {}),
545
640
  };
546
641
  this.sessions.set(session.sessionId, session);
547
642
  this.acpIndex.set(session.acpSessionId, session);
@@ -559,6 +654,15 @@ export class AgentBridge {
559
654
  * Spawn a throwaway ACP session purely to read its `configOptions` (the
560
655
  * model catalog probe). Deliberately bypasses the live-session bookkeeping
561
656
  * so probe sessions never surface in the browser session list.
657
+ *
658
+ * THE PROBE SENDS THE AGENT'S OWN MCP LIST, not an empty one. Measured
659
+ * against codebuddy: the servers handed to `session/new` land in the
660
+ * agent PROCESS's dynamic MCP scope, so a probe that sent an empty list
661
+ * would REPLACE that scope and drop the MCP servers of the live sessions
662
+ * on the same connection (the agent logged
663
+ * `serverManager.delete("injected")` and the live session's
664
+ * `mcp__injected__*` tools disappeared). One list per agent process is the
665
+ * only shape that keeps every session of that process consistent.
562
666
  */
563
667
  async probeConfigOptions() {
564
668
  // A probe is the reason an agent process can be resident with NOTHING else
@@ -571,7 +675,7 @@ export class AgentBridge {
571
675
  if (conn === undefined)
572
676
  throw new AcpError('agent_unreachable', 'acp: connection is not open');
573
677
  const resolved = resolveSessionCwd(this.def, undefined, p => realpathSync(p));
574
- const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: [] }), 'session/new');
678
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/new');
575
679
  await conn.agent.request(methods.agent.session.close, { sessionId: result.sessionId }).catch(() => { });
576
680
  return Array.isArray(result.configOptions) ? result.configOptions : [];
577
681
  }
@@ -626,6 +730,13 @@ export class AgentBridge {
626
730
  // own turn apart from a later one.
627
731
  session.cancelRequestedAtTurn = session.turnSeq;
628
732
  await this.conn.agent.notify(methods.agent.session.cancel, { sessionId: session.acpSessionId }).catch(() => { });
733
+ // The ACP spec's counterpart to the notification we just sent: a client that
734
+ // cancels MUST answer every pending `session/request_permission` with
735
+ // `cancelled`. Leaving them open would hold the agent's tool call blocked on
736
+ // an answer that can no longer arrive — the turn is over, the human's
737
+ // decision is "stop". OmniTerm does the same from `AcpClient::cancel`
738
+ // (`PermissionManager::cancel_all`).
739
+ this.settlePermissions(session);
629
740
  this.armCancelFallback(session);
630
741
  }
631
742
  /**
@@ -719,8 +830,8 @@ export class AgentBridge {
719
830
  // is in flight, and let the browser drop its blocks/watermark first.
720
831
  this.acpIndex.set(session.acpSessionId, session);
721
832
  this.hub.broadcast({ type: 'session/reset', sessionId: session.sessionId, session: snapshot(session) });
722
- 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) {
833
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.load, { sessionId: session.acpSessionId, cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/load');
834
+ if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
724
835
  this.onCatalog(this.def.id, result.configOptions);
725
836
  }
726
837
  session.modes = result.modes !== undefined && result.modes !== null
@@ -733,7 +844,7 @@ export class AgentBridge {
733
844
  })),
734
845
  }
735
846
  : session.modes;
736
- if (Array.isArray(result.configOptions))
847
+ if (usableConfigOptions(result.configOptions))
737
848
  session.configOptions = result.configOptions;
738
849
  this.sessions.set(session.sessionId, session);
739
850
  this.released.delete(session.sessionId);
@@ -825,15 +936,26 @@ export class AgentBridge {
825
936
  const conn = this.conn;
826
937
  if (conn === undefined)
827
938
  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 }));
939
+ // A toggle must say so on the wire: the request union discriminates on
940
+ // `type`, and a bare boolean `value` is not a select value id.
941
+ const params = typeof value === 'boolean'
942
+ ? { sessionId: session.acpSessionId, configId, value, type: 'boolean' }
943
+ : { sessionId: session.acpSessionId, configId, value };
944
+ const result = await this.runCommand(() => conn.agent.request(methods.agent.session.setConfigOption, params));
829
945
  // The response carries the full authoritative option set; some agents
830
946
  // (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))
947
+ // SESSION snapshot first — `currentValue` in a per-session answer describes
948
+ // this session's pick, and promoting it to the catalog would turn the
949
+ // seat's own choice into `reasoning.defaultEffort` for every other
950
+ // conversation (and back onto itself next turn). The option SHAPE still
951
+ // reaches the agent-level catalog (`shapeOnly`): this response is often the
952
+ // only place an agent publishes how its option set changes with the model
953
+ // (opencode offers `thought_level` just for models with variants), and the
954
+ // composer's thought chip is driven by the catalog.
955
+ if (usableConfigOptions(result.configOptions)) {
836
956
  session.configOptions = result.configOptions;
957
+ this.onCatalog?.(this.def.id, result.configOptions, true);
958
+ }
837
959
  this.publishMeta(session);
838
960
  }
839
961
  publishMeta(session) {
@@ -946,6 +1068,11 @@ export class AgentBridge {
946
1068
  resolve(outcome.outcome === 'selected'
947
1069
  ? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
948
1070
  : { outcome: { outcome: 'cancelled' } });
1071
+ }, {
1072
+ // Kept for the reconnect replay: a client that connects after the ask
1073
+ // was broadcast must be re-shown the very card it has to answer.
1074
+ toolCall,
1075
+ options: options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
949
1076
  });
950
1077
  // Deadline (`permissionTimeoutMs`): an approval left unanswered is
951
1078
  // answered `cancelled` on the human's behalf instead of hanging the turn
@@ -953,14 +1080,22 @@ export class AgentBridge {
953
1080
  // exception is a DEGRADED native route: the conversation just failed to
954
1081
  // show the question, so waiting forever would hang the turn behind an
955
1082
  // ask nobody can see there, and the fallback deadline applies instead
956
- // (see `BridgeTuning.permissionFallbackTimeoutMs`).
1083
+ // (see `BridgeTuning.permissionFallbackTimeoutMs`). `native-timeout` is
1084
+ // the same degradation reached from the other side: the host took the
1085
+ // ask and never answered it.
957
1086
  const timeoutMs = this.permissionTimeoutMs > 0
958
1087
  ? this.permissionTimeoutMs
959
- : native.reason === 'unavailable' ? this.permissionFallbackTimeoutMs : 0;
1088
+ : native.reason === 'unavailable' || native.reason === 'native-timeout'
1089
+ ? this.permissionFallbackTimeoutMs
1090
+ : 0;
960
1091
  // Recorded before the broadcast: the hub is synchronous, and the chat
961
1092
  // line `streamPrompt` folds from this frame words itself by the path
962
1093
  // this ask actually took.
963
- this.ledgerFallbacks.set(requestId, { reason: native.reason, timeoutMs });
1094
+ this.ledgerFallbacks.set(requestId, {
1095
+ reason: native.reason,
1096
+ timeoutMs,
1097
+ ...(native.reason === 'native-timeout' ? { waitedMs: this.nativeApprovalTimeoutMs } : {}),
1098
+ });
964
1099
  if (timeoutMs > 0) {
965
1100
  timer = setTimeout(() => {
966
1101
  if (!this.permissions.has(requestId))
@@ -969,7 +1104,10 @@ export class AgentBridge {
969
1104
  type: 'agent/notice',
970
1105
  agentId: this.def.id,
971
1106
  level: 'info',
972
- text: `acp: permission request timed out after ${String(Math.round(timeoutMs / 1000))}s; answered "cancelled"`,
1107
+ // Name what was missed, the way a human needs it: which operation
1108
+ // sat unanswered, and for how long (OmniTerm's reaper writes the
1109
+ // same "you missed this" notice before it acts).
1110
+ text: `acp: permission request for "${permissionToolName(toolCall)}" timed out after ${String(Math.round(timeoutMs / 1000))}s; answered "cancelled"`,
973
1111
  });
974
1112
  this.permissions.respond(requestId, { outcome: 'cancelled' });
975
1113
  }, timeoutMs);
@@ -1000,8 +1138,19 @@ export class AgentBridge {
1000
1138
  * open turn, so a session started over `/acp` (no chat turn) cannot be
1001
1139
  * routed and must stay on the ledger path.
1002
1140
  *
1003
- * No `permissionTimeoutMs` timer is armed here on purpose: the human answers
1004
- * inside the conversation, and a host-side deadline would answer for them.
1141
+ * The ask is raced against {@link BridgeTuning.nativeApprovalTimeoutMs}. A
1142
+ * routed ask has exactly one answer entry — the composer takeover — and no
1143
+ * broadcast at all, so a host that takes the question and never renders it
1144
+ * would leave the turn hanging behind something invisible. Past the deadline
1145
+ * the ask is WITHDRAWN (the signal handed to the host aborts, which settles
1146
+ * its waterfall `cancelled` and drops the composer panel) and reported as
1147
+ * `native-timeout`, sending the question to the ledger where the chat line
1148
+ * and the settings card are both visible. Withdrawing is not answering: a
1149
+ * human mid-decision finds the same question in the settings panel.
1150
+ *
1151
+ * No deadline is armed on the ledger paths (`no-requester`,
1152
+ * `no-chat-session`): those never reach the host, and their answer entry is
1153
+ * the settings panel the chat line already points at.
1005
1154
  *
1006
1155
  * @returns the agent-facing response, or the reason the ask must fall back
1007
1156
  * to the ledger (see {@link LedgerFallbackReason}).
@@ -1012,20 +1161,52 @@ export class AgentBridge {
1012
1161
  return { reason: 'no-requester' };
1013
1162
  if (session.dshSessionId === undefined)
1014
1163
  return { reason: 'no-chat-session' };
1164
+ // One signal carries both withdrawals: the agent's own cancel (whatever
1165
+ // aborted the ACP request) and the deadline. The host's waterfall races its
1166
+ // answerers against this signal, so aborting is a clean withdrawal rather
1167
+ // than a leak.
1168
+ const withdraw = new AbortController();
1169
+ const askSignal = signal === undefined ? withdraw.signal : AbortSignal.any([signal, withdraw.signal]);
1170
+ let timer;
1171
+ let timedOut = false;
1172
+ const deadline = this.nativeApprovalTimeoutMs > 0
1173
+ ? new Promise(resolve => {
1174
+ timer = setTimeout(() => {
1175
+ timedOut = true;
1176
+ withdraw.abort();
1177
+ resolve({ timedOut: true });
1178
+ }, this.nativeApprovalTimeoutMs);
1179
+ timer.unref?.();
1180
+ })
1181
+ : undefined;
1015
1182
  let outcome;
1016
1183
  try {
1017
- outcome = await requester({
1184
+ const asked = requester({
1018
1185
  dshSessionId: session.dshSessionId,
1019
1186
  agentId: this.def.id,
1020
1187
  toolName: permissionToolName(toolCall),
1021
- ...(signal === undefined ? {} : { signal }),
1188
+ signal: askSignal,
1022
1189
  });
1190
+ // A withdrawn ask settles on the host's own schedule; dropping it here
1191
+ // must not surface as an unhandled rejection later.
1192
+ asked.catch(() => { });
1193
+ const settled = deadline === undefined
1194
+ ? await asked.then(o => ({ outcome: o }))
1195
+ : await Promise.race([asked.then(o => ({ outcome: o })), deadline]);
1196
+ // The flag, not the race winner, decides: the host may settle `cancelled`
1197
+ // from the very abort the deadline caused, and that is still a timeout.
1198
+ if (timedOut || 'timedOut' in settled)
1199
+ return { reason: 'native-timeout' };
1200
+ outcome = settled.outcome;
1023
1201
  }
1024
1202
  catch {
1025
1203
  // Synchronous throw or rejection, either way: the ask falls back to the
1026
1204
  // ledger. An approval failure must never break the agent's turn.
1027
1205
  outcome = 'unavailable';
1028
1206
  }
1207
+ finally {
1208
+ clearTimeout(timer);
1209
+ }
1029
1210
  if (outcome === 'unavailable')
1030
1211
  return { reason: 'unavailable' };
1031
1212
  const response = nativeOutcomeResponse(outcome, options);
@@ -1115,7 +1296,10 @@ export class AgentBridge {
1115
1296
  session.modes = { ...session.modes, currentModeId: update.currentModeId };
1116
1297
  break;
1117
1298
  case 'config_option_update':
1118
- session.configOptions = update.configOptions ?? [];
1299
+ // Empty answers never overwrite the last known set — see `usableConfigOptions`.
1300
+ if (!usableConfigOptions(update.configOptions))
1301
+ break;
1302
+ session.configOptions = update.configOptions;
1119
1303
  this.onCatalog?.(this.def.id, session.configOptions);
1120
1304
  break;
1121
1305
  case 'available_commands_update':
@@ -1151,9 +1335,31 @@ export class AgentBridge {
1151
1335
  }
1152
1336
  }
1153
1337
  /** Human-facing identity of an ACP tool call (the approval headline). */
1154
- function permissionToolName(toolCall) {
1338
+ /**
1339
+ * Human-facing name of the tool an ask is about, for the chat line and the
1340
+ * timeout notice.
1341
+ *
1342
+ * The ACP `toolCall.title` is the declared field, but it is OPTIONAL and real
1343
+ * agents omit it: codebuddy puts the name in `_meta["codebuddy.ai/toolName"]`
1344
+ * (observed live — without the fallback the notice reads `for "tool call"`,
1345
+ * which tells the human nothing about what they missed). So read `title` first,
1346
+ * then any `_meta` key whose last segment is `toolName`, then the standard
1347
+ * `kind`, and only then give up with the generic phrase. Nothing is invented:
1348
+ * every step reads a key the agent itself declared.
1349
+ */
1350
+ export function permissionToolName(toolCall) {
1155
1351
  const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
1156
- return typeof row.title === 'string' && row.title !== '' ? row.title : 'tool call';
1352
+ if (typeof row.title === 'string' && row.title !== '')
1353
+ return row.title;
1354
+ const meta = (typeof row._meta === 'object' && row._meta !== null ? row._meta : {});
1355
+ for (const [key, value] of Object.entries(meta)) {
1356
+ const segment = key.split('/').at(-1) ?? key;
1357
+ if (segment.toLowerCase() === 'toolname' && typeof value === 'string' && value !== '')
1358
+ return value;
1359
+ }
1360
+ if (typeof row.kind === 'string' && row.kind !== '')
1361
+ return row.kind;
1362
+ return 'tool call';
1157
1363
  }
1158
1364
  /**
1159
1365
  * Translate a native approval outcome into the ACP option the agent must see.
@@ -2271,13 +2477,24 @@ class TurnStreamController {
2271
2477
  // The line must not point at a place with no answer entry, and the bridge
2272
2478
  // knows which path this ask took (`ledgerFallbackOf`, recorded before the
2273
2479
  // frame was broadcast). On the DESIGNED ledger paths the settings panel's
2274
- // card is the answer entry. A DEGRADED native route carries a deadline.
2480
+ // card is the answer entry. A DEGRADED native route carries a deadline and
2481
+ // has to say why the conversation is not showing the question — as an
2482
+ // OBSERVED FACT, not a guessed cause: "no answer within Ns" is true whether
2483
+ // the human was slow or the host never rendered the question.
2275
2484
  const fallback = this.bridge.ledgerFallbackOf(event.requestId);
2276
- return fallback?.reason === 'unavailable'
2277
- ? fallback.timeoutMs > 0
2278
- ? `\n> 🔐 [Permission Required: ${title}] - the approval service is unavailable; approve in Settings within ${String(Math.round(fallback.timeoutMs / 1000))}s or this request is cancelled\n`
2279
- : `\n> 🔐 [Permission Required: ${title}] - the approval service is unavailable; please approve in Settings\n`
2280
- : `\n> 🔐 [Permission Required: ${title}] - please approve in Settings\n`;
2485
+ const why = fallback === undefined
2486
+ ? undefined
2487
+ : fallback.reason === 'unavailable'
2488
+ ? 'the approval service is unavailable'
2489
+ : fallback.reason === 'native-timeout'
2490
+ ? `no answer in the conversation within ${String(Math.round((fallback.waitedMs ?? 0) / 1000))}s`
2491
+ : undefined;
2492
+ if (fallback === undefined || why === undefined) {
2493
+ return `\n> 🔐 [Permission Required: ${title}] - please approve in Settings\n`;
2494
+ }
2495
+ return fallback.timeoutMs > 0
2496
+ ? `\n> 🔐 [Permission Required: ${title}] - ${why}; approve in Settings within ${String(Math.round(fallback.timeoutMs / 1000))}s or this request is cancelled\n`
2497
+ : `\n> 🔐 [Permission Required: ${title}] - ${why}; please approve in Settings\n`;
2281
2498
  }
2282
2499
  }
2283
2500
  /** Chunk text shared by the controller's fold (string / object / array forms). */
@@ -2315,13 +2532,47 @@ export class BridgeManager {
2315
2532
  configStoragePath;
2316
2533
  catalogStoragePath;
2317
2534
  probeModelsOnBoot;
2318
- tuning;
2319
2535
  bridges = new Map();
2320
2536
  defsMap = new Map();
2321
2537
  /** The plugin's own default ACP agent id (persisted; see `persistAgents`). */
2322
2538
  defaultAgentId;
2323
2539
  /** The agent tag's halo preference (persisted; see `persistAgents`). */
2324
2540
  tagHaloSetting = { ...DEFAULT_TAG_HALO };
2541
+ /**
2542
+ * What `cordis.patch.yml` asked for, by key — ONLY the keys it actually
2543
+ * names. This is the deployment baseline, kept apart from
2544
+ * {@link deadlineOverride} so the settings card can tell the user which of
2545
+ * the two is winning. Empty when the file says nothing about either window,
2546
+ * in which case {@link baselineDeadlines} (the built-in default) applies.
2547
+ */
2548
+ configuredDeadlines = {};
2549
+ /**
2550
+ * The value each deadline had before any override — the tuning this manager
2551
+ * was constructed with, which already carries the validated config file (the
2552
+ * host entry passes `validateConfig`'s output) or the built-in default.
2553
+ *
2554
+ * Held separately from {@link configuredDeadlines} because the two answer
2555
+ * different questions: this one is what RUNS when nothing is overridden, that
2556
+ * one is what the config file NAMED (which the card shows as the reset target
2557
+ * and a bare default does not deserve). A manager built without tuning at all
2558
+ * — a direct consumer, a test — gets `DEFAULT_TUNING` here and stays correct.
2559
+ */
2560
+ baselineDeadlines;
2561
+ /**
2562
+ * The settings page's own write, persisted with the agent registry. Merged
2563
+ * OVER {@link baselineDeadlines}: once a user picks a window in the UI, that
2564
+ * pick is what runs, and the config file becomes the value the reset button
2565
+ * returns to. Without this split a hand-edited `cordis.patch.yml` and a UI
2566
+ * write would fight with no way to tell which one is live.
2567
+ */
2568
+ deadlineOverride = {};
2569
+ /**
2570
+ * Tuning in force, defaults applied. The two approval deadlines are rewritten
2571
+ * IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
2572
+ * rather than the constructor's parameter: every bridge built afterwards
2573
+ * inherits the rewrite instead of the boot-time value.
2574
+ */
2575
+ tuning;
2325
2576
  /**
2326
2577
  * The in-flight (or just-settled) segmented turn of one DSH chat session.
2327
2578
  * Keyed by chat session id because the continuation steps of a segmented
@@ -2368,7 +2619,18 @@ export class BridgeManager {
2368
2619
  this.configStoragePath = configStoragePath;
2369
2620
  this.catalogStoragePath = catalogStoragePath;
2370
2621
  this.probeModelsOnBoot = probeModelsOnBoot;
2371
- this.tuning = tuning;
2622
+ // Held as a live object rather than the raw parameter: the two approval
2623
+ // deadlines are rewritten in place when the settings page writes them (see
2624
+ // `applyDeadlineTuning`), and every bridge built afterwards must inherit
2625
+ // the rewrite instead of the boot-time value.
2626
+ this.tuning = { ...DEFAULT_TUNING, ...tuning };
2627
+ // The pre-override deadline values, snapshotted before anything can rewrite
2628
+ // them. Everything else in `tuning` is fixed for the manager's lifetime, so
2629
+ // only this pair needs a snapshot.
2630
+ this.baselineDeadlines = {
2631
+ nativeApprovalTimeoutMs: this.tuning.nativeApprovalTimeoutMs,
2632
+ permissionFallbackTimeoutMs: this.tuning.permissionFallbackTimeoutMs,
2633
+ };
2372
2634
  for (const def of defs) {
2373
2635
  this.defsMap.set(def.id, def);
2374
2636
  }
@@ -2381,6 +2643,9 @@ export class BridgeManager {
2381
2643
  }
2382
2644
  this.loadPersistedAgents();
2383
2645
  this.loadPersistedCatalogs();
2646
+ // A persisted deadline override must reach the tuning the bridges are built
2647
+ // from, not just the settings card's readout.
2648
+ this.applyDeadlineTuning();
2384
2649
  // The request-path rewrite falls back to this default for a conversation
2385
2650
  // the user never bound explicitly — the same agent the composer chip shows.
2386
2651
  routeBindingRegistry().defaultModel = this.defaultAgent();
@@ -2401,6 +2666,19 @@ export class BridgeManager {
2401
2666
  approvalRouting() {
2402
2667
  return this.approvalRequester !== undefined;
2403
2668
  }
2669
+ /**
2670
+ * Every outstanding ledger ask, across all agents, with the view the browser
2671
+ * was shown it — the source of the reconnect replay (see
2672
+ * `permissionReplayFrames`). Asks on the native route are deliberately
2673
+ * absent: they broadcast nothing and their answer entry is the composer
2674
+ * takeover the client already holds.
2675
+ */
2676
+ pendingPermissionViews() {
2677
+ const views = [];
2678
+ for (const bridge of this.bridges.values())
2679
+ views.push(...bridge.permissions.pendingViews());
2680
+ return views;
2681
+ }
2404
2682
  /**
2405
2683
  * Let the host resolve a chat session's workspace root — the sidebar's
2406
2684
  * selected workspace, which the agent's session and its process both run in.
@@ -2864,8 +3142,19 @@ export class BridgeManager {
2864
3142
  if (option === undefined) {
2865
3143
  throw new AcpError('unknown_config', `acp: agent "${bound}" publishes no extra config option "${configId}"`);
2866
3144
  }
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}"`);
3145
+ if (option.kind === 'boolean') {
3146
+ // The browser sends 'true' / 'false' from a checkbox row; the wire and
3147
+ // the persisted pick both use the real boolean.
3148
+ const toggle = typeof value === 'boolean'
3149
+ ? value
3150
+ : value === 'true' ? true : value === 'false' ? false : undefined;
3151
+ if (toggle === undefined) {
3152
+ throw new AcpError('bad_config', `acp: "${String(value)}" is not a boolean value of config option "${configId}"`);
3153
+ }
3154
+ value = toggle;
3155
+ }
3156
+ else if (typeof value !== 'string' || !option.choices.some(choice => choice.value === value)) {
3157
+ throw new AcpError('bad_config', `acp: "${String(value)}" is not a value of config option "${configId}"`);
2869
3158
  }
2870
3159
  const current = this.chatAgentIntent.get(dshSessionId);
2871
3160
  const extraConfigs = current?.agentId === bound ? { ...current.extraConfigs } : {};
@@ -2962,12 +3251,17 @@ export class BridgeManager {
2962
3251
  });
2963
3252
  continue;
2964
3253
  }
2965
- if (!option.choices.some(entry => entry.value === value)) {
3254
+ // A toggle's truth is its boolean itself; a select's is membership in
3255
+ // the choices the agent still offers.
3256
+ const usable = option.kind === 'boolean'
3257
+ ? typeof value === 'boolean'
3258
+ : typeof value === 'string' && option.choices.some(entry => entry.value === value);
3259
+ if (!usable) {
2966
3260
  this.hub.broadcast({
2967
3261
  type: 'agent/notice',
2968
3262
  agentId: session.agentId,
2969
3263
  level: 'error',
2970
- text: `acp: agent "${session.agentId}" no longer offers "${value}" for config "${configId}"; keeping its own`,
3264
+ text: `acp: agent "${session.agentId}" no longer offers "${String(value)}" for config "${configId}"; keeping its own`,
2971
3265
  });
2972
3266
  continue;
2973
3267
  }
@@ -3169,6 +3463,17 @@ export class BridgeManager {
3169
3463
  catch {
3170
3464
  this.tagHaloSetting = { ...DEFAULT_TAG_HALO };
3171
3465
  }
3466
+ // The settings page's approval-deadline override. Re-validated on every
3467
+ // boot because the state file is user- and version-editable: an
3468
+ // unreadable value must not poison the deadline, and a value the
3469
+ // validator accepts must be the value that runs (a UI write that did
3470
+ // not survive a restart would read as a broken setting).
3471
+ try {
3472
+ this.deadlineOverride = validateApprovalDeadlines(parsed.approvalDeadlines ?? {});
3473
+ }
3474
+ catch {
3475
+ this.deadlineOverride = {};
3476
+ }
3172
3477
  this.loadPersistedChoices(parsed.conversations);
3173
3478
  }
3174
3479
  }
@@ -3194,7 +3499,7 @@ export class BridgeManager {
3194
3499
  continue;
3195
3500
  const configs = extraConfigs !== null && typeof extraConfigs === 'object' && !Array.isArray(extraConfigs)
3196
3501
  ? Object.fromEntries(Object.entries(extraConfigs)
3197
- .filter(([, value]) => typeof value === 'string' && value !== ''))
3502
+ .filter(([, value]) => typeof value === 'boolean' || (typeof value === 'string' && value !== '')))
3198
3503
  : {};
3199
3504
  this.chatAgentIntent.set(dshSessionId, {
3200
3505
  agentId,
@@ -3223,6 +3528,11 @@ export class BridgeManager {
3223
3528
  ...(this.defaultAgentId === undefined ? {} : { defaultAgentId: this.defaultAgentId }),
3224
3529
  tagHalo: this.tagHaloSetting,
3225
3530
  agents: [...this.defsMap.values()],
3531
+ // The settings page's approval deadlines. Absent while none was ever
3532
+ // written, so a fresh deployment's state file stays exactly as it was.
3533
+ ...(Object.keys(this.deadlineOverride).length === 0
3534
+ ? {}
3535
+ : { approvalDeadlines: { ...this.deadlineOverride } }),
3226
3536
  // The per-conversation picks: agent + model + thought level + mode +
3227
3537
  // extra config values chosen in the composer, including for
3228
3538
  // conversations with no live session yet.
@@ -3359,6 +3669,9 @@ export class BridgeManager {
3359
3669
  * current `parseCatalogOptions` has now seen this agent's real answer.
3360
3670
  */
3361
3671
  absorbCatalog(agentId, configOptions) {
3672
+ // Empty / absurd answers never overwrite known state — see `usableConfigOptions`.
3673
+ if (!usableConfigOptions(configOptions))
3674
+ return this.catalogs.get(agentId) ?? { catalog: {}, updatedAt: 0 };
3362
3675
  const catalog = parseCatalogOptions(configOptions);
3363
3676
  this.catalogStale.delete(agentId);
3364
3677
  const existing = this.catalogs.get(agentId);
@@ -3386,6 +3699,30 @@ export class BridgeManager {
3386
3699
  this.broadcastExposure(agentId);
3387
3700
  return entry;
3388
3701
  }
3702
+ /**
3703
+ * Fold a per-session option answer's SHAPE into the agent's catalog.
3704
+ *
3705
+ * The counterpart of {@link absorbCatalog} for `session/set_config_option`
3706
+ * responses: which options exist and which choices they offer is agent
3707
+ * knowledge the seat must show (the composer's thought chip only renders
3708
+ * when the catalog has `thought_level`, and some agents publish it only in
3709
+ * the answer to a model change), while a per-session `currentValue` stays
3710
+ * out of it. No auto-exposure and no staleness discharge either — this is
3711
+ * one session's answer, not the agent-level observation those marks track.
3712
+ */
3713
+ absorbCatalogShape(agentId, configOptions) {
3714
+ if (!usableConfigOptions(configOptions))
3715
+ return;
3716
+ const observed = parseCatalogOptions(configOptions);
3717
+ const existing = this.catalogs.get(agentId);
3718
+ const catalog = mergeCatalogShape(existing?.catalog, observed);
3719
+ if (existing !== undefined && JSON.stringify(existing.catalog) === JSON.stringify(catalog))
3720
+ return;
3721
+ const entry = { catalog, updatedAt: Date.now() };
3722
+ this.catalogs.set(agentId, entry);
3723
+ this.persistCatalogs();
3724
+ this.hub.broadcast({ type: 'catalog/updated', agentId, updatedAt: entry.updatedAt });
3725
+ }
3389
3726
  /**
3390
3727
  * Remember the slash commands an agent declared in a real session.
3391
3728
  *
@@ -3418,11 +3755,12 @@ export class BridgeManager {
3418
3755
  * selection would resurrect a model the user deliberately unchecked every
3419
3756
  * time the agent repeats its catalog. The first observation has no baseline
3420
3757
  * (`before === undefined`), so it adds nothing; the user can still add the
3421
- * whole list with one click in the settings card.
3758
+ * whole list with one click in the settings card. Discovery is ON unless the
3759
+ * agent definition explicitly opts out with `autoDiscoverModels: false`.
3422
3760
  */
3423
3761
  autoExposeNewModels(agentId, next, before) {
3424
3762
  const def = this.defsMap.get(agentId);
3425
- if (def?.autoDiscoverModels !== true || before === undefined)
3763
+ if (def === undefined || def.autoDiscoverModels === false || before === undefined)
3426
3764
  return false;
3427
3765
  const known = new Set((before.model?.choices ?? []).map(choice => choice.value));
3428
3766
  const exposed = new Set(def.exposedModels ?? []);
@@ -3444,7 +3782,7 @@ export class BridgeManager {
3444
3782
  type: 'agent/exposure',
3445
3783
  agentId,
3446
3784
  exposedModels: [...(def.exposedModels ?? [])],
3447
- autoDiscoverModels: def.autoDiscoverModels === true,
3785
+ autoDiscoverModels: def.autoDiscoverModels !== false,
3448
3786
  });
3449
3787
  }
3450
3788
  catalogFor(agentId) {
@@ -3618,6 +3956,7 @@ export class BridgeManager {
3618
3956
  extras: extraOptions.map(option => ({
3619
3957
  configId: option.configId,
3620
3958
  name: option.name,
3959
+ ...(option.kind === 'boolean' ? { kind: 'boolean' } : {}),
3621
3960
  ...(option.currentValue === null ? {} : { currentValue: option.currentValue }),
3622
3961
  choices: option.choices.map(choice => ({
3623
3962
  id: choice.value,
@@ -3841,8 +4180,11 @@ export class BridgeManager {
3841
4180
  if (bridge === undefined) {
3842
4181
  bridge = this.makeBridge(def, (b, reason) => {
3843
4182
  this.connectionLost(b, reason);
3844
- }, (observedAgentId, configOptions) => {
3845
- this.absorbCatalog(observedAgentId, configOptions);
4183
+ }, (observedAgentId, configOptions, shapeOnly) => {
4184
+ if (shapeOnly)
4185
+ this.absorbCatalogShape(observedAgentId, configOptions);
4186
+ else
4187
+ this.absorbCatalog(observedAgentId, configOptions);
3846
4188
  }, (observedAgentId, commands) => {
3847
4189
  this.absorbCommands(observedAgentId, commands);
3848
4190
  });
@@ -3970,16 +4312,17 @@ export class BridgeManager {
3970
4312
  * Toggle auto-discovery of newly published models. Lightweight like
3971
4313
  * `setExposedModels`: the def is mutated in place (live bridges share the
3972
4314
  * object) and persisted; no process is restarted, and the current selection
3973
- * is left exactly as it is.
4315
+ * is left exactly as it is. On is the default, so only the opt-out
4316
+ * (`false`) is written to the file.
3974
4317
  */
3975
4318
  async setAutoDiscoverModels(agentId, autoDiscoverModels) {
3976
4319
  const def = this.defsMap.get(agentId);
3977
4320
  if (def === undefined)
3978
4321
  throw new AcpError('unknown_agent', `acp: agent "${agentId}" is not configured`);
3979
4322
  if (autoDiscoverModels)
3980
- def.autoDiscoverModels = true;
3981
- else
3982
4323
  delete def.autoDiscoverModels;
4324
+ else
4325
+ def.autoDiscoverModels = false;
3983
4326
  this.persistAgents();
3984
4327
  this.broadcastExposure(agentId);
3985
4328
  }
@@ -4183,6 +4526,79 @@ export class BridgeManager {
4183
4526
  this.tagHaloSetting = validateTagHalo(halo);
4184
4527
  this.persistAgents();
4185
4528
  }
4529
+ /**
4530
+ * Declare the deployment baseline for the two approval deadlines — the values
4531
+ * `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
4532
+ *
4533
+ * Kept separate from the validated tuning because "the file says 600000" and
4534
+ * "the file says nothing, so 300000 applies" are different facts: the settings
4535
+ * card shows the first as the reset target and the second as a built-in
4536
+ * default. Called once by the host entry, before any bridge exists — and it
4537
+ * also moves the baseline, so a manager built without tuning (a direct
4538
+ * consumer, a test) still runs what the deployment asked for.
4539
+ */
4540
+ setConfiguredApprovalDeadlines(values) {
4541
+ this.configuredDeadlines = { ...values };
4542
+ if (values.nativeApprovalTimeoutMs !== undefined)
4543
+ this.baselineDeadlines.nativeApprovalTimeoutMs = values.nativeApprovalTimeoutMs;
4544
+ if (values.permissionFallbackTimeoutMs !== undefined)
4545
+ this.baselineDeadlines.permissionFallbackTimeoutMs = values.permissionFallbackTimeoutMs;
4546
+ this.applyDeadlineTuning();
4547
+ }
4548
+ /**
4549
+ * The approval deadlines in force, plus the two facts that explain a value the
4550
+ * user did not pick: the config-file baseline an override is outranking, and a
4551
+ * `permissionTimeoutMs` that overrides BOTH windows when non-zero. Both are
4552
+ * reported only when they are actually in play, so the card can stay silent
4553
+ * about a plain default.
4554
+ */
4555
+ approvalDeadlines() {
4556
+ const deadlines = {
4557
+ nativeApprovalTimeoutMs: this.tuning.nativeApprovalTimeoutMs,
4558
+ permissionFallbackTimeoutMs: this.tuning.permissionFallbackTimeoutMs,
4559
+ };
4560
+ if (Object.keys(this.configuredDeadlines).length > 0)
4561
+ deadlines.configured = { ...this.configuredDeadlines };
4562
+ if (this.tuning.permissionTimeoutMs > 0)
4563
+ deadlines.permissionTimeoutMs = this.tuning.permissionTimeoutMs;
4564
+ return deadlines;
4565
+ }
4566
+ /**
4567
+ * Write one or both approval deadlines (omitted keys keep their value), or
4568
+ * drop the persisted override with `reset` so the config-file baseline — or
4569
+ * the built-in default — applies again.
4570
+ *
4571
+ * HOT: the rewrite reaches every live bridge, so a user changing the window
4572
+ * never has to kill a running session to see it take effect (the one thing an
4573
+ * `agent/save`-style restart would cost them). Only asks armed AFTER the
4574
+ * write see the new window; an ask already holding a timer keeps the one it
4575
+ * started with.
4576
+ */
4577
+ async setApprovalDeadlines(patch, reset = false) {
4578
+ // Merged BEFORE validating, so the whole resulting override passes the same
4579
+ // gate the config file does (an override written by an older build is
4580
+ // re-gated here rather than trusted). `validateApprovalDeadlines` also drops
4581
+ // unknown keys, which is what keeps a stale payload from poisoning state.
4582
+ const merged = reset ? {} : { ...this.deadlineOverride, ...patch };
4583
+ this.deadlineOverride = validateApprovalDeadlines(merged);
4584
+ this.applyDeadlineTuning();
4585
+ this.persistAgents();
4586
+ }
4587
+ /**
4588
+ * Recompute the two deadlines from override → deployment baseline, and push
4589
+ * the result to every live bridge.
4590
+ */
4591
+ applyDeadlineTuning() {
4592
+ const nativeApprovalTimeoutMs = this.deadlineOverride.nativeApprovalTimeoutMs
4593
+ ?? this.baselineDeadlines.nativeApprovalTimeoutMs;
4594
+ const permissionFallbackTimeoutMs = this.deadlineOverride.permissionFallbackTimeoutMs
4595
+ ?? this.baselineDeadlines.permissionFallbackTimeoutMs;
4596
+ this.tuning.nativeApprovalTimeoutMs = nativeApprovalTimeoutMs;
4597
+ this.tuning.permissionFallbackTimeoutMs = permissionFallbackTimeoutMs;
4598
+ const patch = { nativeApprovalTimeoutMs, permissionFallbackTimeoutMs };
4599
+ for (const bridge of this.bridges.values())
4600
+ bridge.applyApprovalDeadlines(patch);
4601
+ }
4186
4602
  dshSessionMap = new Map();
4187
4603
  /**
4188
4604
  * What each DSH chat session has chosen for its ACP agent — the agent itself
@@ -4237,6 +4653,13 @@ export class BridgeManager {
4237
4653
  const extraConfigs = {};
4238
4654
  for (const option of catalog.extras ?? []) {
4239
4655
  const value = option.currentValue;
4656
+ if (option.kind === 'boolean') {
4657
+ // The catalog encodes a toggle as 'true' / 'false'; the pick stores the
4658
+ // real boolean, exactly like an explicit checkbox click.
4659
+ if (value === 'true' || value === 'false')
4660
+ extraConfigs[option.configId] = value === 'true';
4661
+ continue;
4662
+ }
4240
4663
  if (value !== null && value !== undefined && value !== '')
4241
4664
  extraConfigs[option.configId] = value;
4242
4665
  }
@@ -4290,7 +4713,11 @@ export class BridgeManager {
4290
4713
  const option = options.extras?.find(entry => entry.configId === configId);
4291
4714
  if (option === undefined)
4292
4715
  continue;
4293
- if (option.choices.length > 0 && !option.choices.some(choice => choice.value === value))
4716
+ // The fresh session's own option set is the authority for BOTH forms:
4717
+ // a toggle must still be a toggle, a select must still offer the value.
4718
+ if (option.kind === 'boolean'
4719
+ ? typeof value !== 'boolean'
4720
+ : typeof value !== 'string' || (option.choices.length > 0 && !option.choices.some(choice => choice.value === value)))
4294
4721
  continue;
4295
4722
  extraConfigs[configId] = value;
4296
4723
  changed = true;