dsh-ntr 0.1.4 → 0.1.6

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,69 @@ 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, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, 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, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } 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
- permissionTimeoutMs: 0,
25
- permissionFallbackTimeoutMs: 300_000,
76
+ permissionTimeoutMs: 300_000,
77
+ permissionTimeoutMode: 'wait',
26
78
  cancelFallbackMs: 15_000,
27
79
  staleCancelRetryDelayMs: 600,
28
80
  staleCancelMaxRetries: 2,
@@ -63,8 +115,17 @@ export class AgentBridge {
63
115
  */
64
116
  ledgerFallbacks = new Map;
65
117
  startupTimeoutMs;
118
+ /**
119
+ * The ONE approval deadline, and the mode it settles with. Neither is
120
+ * readonly, unlike their neighbors: the settings page rewrites them on a
121
+ * running bridge ({@link AgentBridge.applyApprovalDeadlines}), so a user
122
+ * editing the card does not have to kill every live session to see it take
123
+ * effect. Both are read per ask, so a reassignment reaches every ask from
124
+ * that moment on. An ask already holding an armed timer keeps the window it
125
+ * was started with.
126
+ */
66
127
  permissionTimeoutMs;
67
- permissionFallbackTimeoutMs;
128
+ permissionTimeoutMode;
68
129
  cancelFallbackMs;
69
130
  staleCancelRetryDelayMs;
70
131
  staleCancelMaxRetries;
@@ -131,8 +192,10 @@ export class AgentBridge {
131
192
  this.sessionWorkspace = sessionWorkspace;
132
193
  const merged = { ...DEFAULT_TUNING, ...tuning };
133
194
  this.startupTimeoutMs = merged.startupTimeoutMs;
134
- this.permissionTimeoutMs = merged.permissionTimeoutMs;
135
- this.permissionFallbackTimeoutMs = merged.permissionFallbackTimeoutMs;
195
+ this.permissionTimeoutMode = merged.permissionTimeoutMode;
196
+ // 不等待 (0) is the `auto` mode's business; under `abort` it would refuse
197
+ // every ask on arrival, so the window is floored to the shortest real wait.
198
+ this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, merged.permissionTimeoutMs);
136
199
  this.cancelFallbackMs = merged.cancelFallbackMs;
137
200
  this.staleCancelRetryDelayMs = merged.staleCancelRetryDelayMs;
138
201
  this.staleCancelMaxRetries = merged.staleCancelMaxRetries;
@@ -140,6 +203,26 @@ export class AgentBridge {
140
203
  this.defaultIdleTimeoutMs = merged.idleTimeoutMs;
141
204
  this.probeIdleTimeoutMs = merged.probeIdleTimeoutMs;
142
205
  }
206
+ /**
207
+ * Adopt a rewritten approval deadline or mode (the settings page's write).
208
+ * Only the window and the mode live here; every other tuning value is
209
+ * fixed at construction, because a live bridge cannot meaningfully adopt a
210
+ * new startup or cancel window mid-flight.
211
+ *
212
+ * Deliberately does NOT re-arm anything: an ask that already holds a timer
213
+ * keeps the window it was started with, and the next ask reads the new value.
214
+ * Re-arming would move the goalposts under a human who is already deciding.
215
+ */
216
+ applyApprovalDeadlines(patch) {
217
+ if (patch.permissionTimeoutMs !== undefined)
218
+ this.permissionTimeoutMs = patch.permissionTimeoutMs;
219
+ if (patch.mode !== undefined)
220
+ this.permissionTimeoutMode = patch.mode;
221
+ // A mode switch can leave a window behind that the new mode cannot run
222
+ // (不等待 under `abort`): floor it, so the ask waits instead of refusing
223
+ // on arrival.
224
+ this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, this.permissionTimeoutMs);
225
+ }
143
226
  /**
144
227
  * The idle-release window this agent honors for its CHAT process: its own
145
228
  * field, else the host default. This is the number the panel prefills and
@@ -528,7 +611,7 @@ export class AgentBridge {
528
611
  if (conn === undefined)
529
612
  throw new AcpError('agent_unreachable', 'acp: connection is not open');
530
613
  const resolved = resolveSessionCwd(this.def, cwd, p => realpathSync(p), extraRoots);
531
- const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: [] }), 'session/new');
614
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/new');
532
615
  // An empty `configOptions` answer is a non-answer — see `usableConfigOptions`.
533
616
  if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
534
617
  this.onCatalog(this.def.id, result.configOptions);
@@ -573,6 +656,15 @@ export class AgentBridge {
573
656
  * Spawn a throwaway ACP session purely to read its `configOptions` (the
574
657
  * model catalog probe). Deliberately bypasses the live-session bookkeeping
575
658
  * so probe sessions never surface in the browser session list.
659
+ *
660
+ * THE PROBE SENDS THE AGENT'S OWN MCP LIST, not an empty one. Measured
661
+ * against codebuddy: the servers handed to `session/new` land in the
662
+ * agent PROCESS's dynamic MCP scope, so a probe that sent an empty list
663
+ * would REPLACE that scope and drop the MCP servers of the live sessions
664
+ * on the same connection (the agent logged
665
+ * `serverManager.delete("injected")` and the live session's
666
+ * `mcp__injected__*` tools disappeared). One list per agent process is the
667
+ * only shape that keeps every session of that process consistent.
576
668
  */
577
669
  async probeConfigOptions() {
578
670
  // A probe is the reason an agent process can be resident with NOTHING else
@@ -585,7 +677,7 @@ export class AgentBridge {
585
677
  if (conn === undefined)
586
678
  throw new AcpError('agent_unreachable', 'acp: connection is not open');
587
679
  const resolved = resolveSessionCwd(this.def, undefined, p => realpathSync(p));
588
- const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: [] }), 'session/new');
680
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/new');
589
681
  await conn.agent.request(methods.agent.session.close, { sessionId: result.sessionId }).catch(() => { });
590
682
  return Array.isArray(result.configOptions) ? result.configOptions : [];
591
683
  }
@@ -640,6 +732,13 @@ export class AgentBridge {
640
732
  // own turn apart from a later one.
641
733
  session.cancelRequestedAtTurn = session.turnSeq;
642
734
  await this.conn.agent.notify(methods.agent.session.cancel, { sessionId: session.acpSessionId }).catch(() => { });
735
+ // The ACP spec's counterpart to the notification we just sent: a client that
736
+ // cancels MUST answer every pending `session/request_permission` with
737
+ // `cancelled`. Leaving them open would hold the agent's tool call blocked on
738
+ // an answer that can no longer arrive — the turn is over, the human's
739
+ // decision is "stop". OmniTerm does the same from `AcpClient::cancel`
740
+ // (`PermissionManager::cancel_all`).
741
+ this.settlePermissions(session);
643
742
  this.armCancelFallback(session);
644
743
  }
645
744
  /**
@@ -733,7 +832,7 @@ export class AgentBridge {
733
832
  // is in flight, and let the browser drop its blocks/watermark first.
734
833
  this.acpIndex.set(session.acpSessionId, session);
735
834
  this.hub.broadcast({ type: 'session/reset', sessionId: session.sessionId, session: snapshot(session) });
736
- const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.load, { sessionId: session.acpSessionId, cwd: resolved, mcpServers: [] }), 'session/load');
835
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.load, { sessionId: session.acpSessionId, cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/load');
737
836
  if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
738
837
  this.onCatalog(this.def.id, result.configOptions);
739
838
  }
@@ -956,6 +1055,23 @@ export class AgentBridge {
956
1055
  this.touch();
957
1056
  return native.response;
958
1057
  }
1058
+ // Deadline: how long this ask may sit unanswered. ONE window for every
1059
+ // route — an ask that never reached the conversation included. They have
1060
+ // no other answer entry, so exempting them would be the very invisible
1061
+ // hang this mechanism exists to bound.
1062
+ //
1063
+ // What happens when it fires is the MODE's business: refused, answered
1064
+ // with the option the agent's own kinds imply, or (in `wait`) never.
1065
+ // The mode is read once, here, for the same reason the window is: an ask
1066
+ // that started under one mode keeps it.
1067
+ const mode = this.permissionTimeoutMode;
1068
+ const timeoutMs = this.permissionTimeoutMs;
1069
+ // 不等待(0)只属自动推进:请求到达即按那一档收场,不建 ledger 条目、不发卡
1070
+ // ——问都来不及问。超时中止没有这一档(它会立刻拒绝每一问,agent 在每个门口
1071
+ // 都停住),它的窗口最短是真实等待。
1072
+ if (mode === 'auto' && timeoutMs === 0) {
1073
+ return this.settleExpiredAsk(session, mode, toolCall, options, 0);
1074
+ }
959
1075
  return new Promise(resolve => {
960
1076
  let timer;
961
1077
  const requestId = this.permissions.create(session.sessionId, (outcome) => {
@@ -971,32 +1087,38 @@ export class AgentBridge {
971
1087
  resolve(outcome.outcome === 'selected'
972
1088
  ? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
973
1089
  : { outcome: { outcome: 'cancelled' } });
1090
+ }, {
1091
+ // Kept for the reconnect replay: a client that connects after the ask
1092
+ // was broadcast must be re-shown the very card it has to answer.
1093
+ toolCall,
1094
+ options: options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
974
1095
  });
975
- // Deadline (`permissionTimeoutMs`): an approval left unanswered is
976
- // answered `cancelled` on the human's behalf instead of hanging the turn
977
- // forever. Off by default — approvals belong to a person. The one
978
- // exception is a DEGRADED native route: the conversation just failed to
979
- // show the question, so waiting forever would hang the turn behind an
980
- // ask nobody can see there, and the fallback deadline applies instead
981
- // (see `BridgeTuning.permissionFallbackTimeoutMs`).
982
- const timeoutMs = this.permissionTimeoutMs > 0
983
- ? this.permissionTimeoutMs
984
- : native.reason === 'unavailable' ? this.permissionFallbackTimeoutMs : 0;
985
1096
  // Recorded before the broadcast: the hub is synchronous, and the chat
986
1097
  // line `streamPrompt` folds from this frame words itself by the path
987
1098
  // this ask actually took.
988
- this.ledgerFallbacks.set(requestId, { reason: native.reason, timeoutMs });
989
- if (timeoutMs > 0) {
1099
+ this.ledgerFallbacks.set(requestId, { reason: native.reason, timeoutMs, mode });
1100
+ if (timeoutMs > 0 && mode !== 'wait') {
990
1101
  timer = setTimeout(() => {
991
1102
  if (!this.permissions.has(requestId))
992
1103
  return;
1104
+ const picked = mode === 'auto'
1105
+ ? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
1106
+ : undefined;
1107
+ const tool = permissionToolName(toolCall);
993
1108
  this.hub.broadcast({
994
1109
  type: 'agent/notice',
995
1110
  agentId: this.def.id,
996
1111
  level: 'info',
997
- text: `acp: permission request timed out after ${String(Math.round(timeoutMs / 1000))}s; answered "cancelled"`,
1112
+ // Name what was missed and what was done about it, the way a human
1113
+ // needs it: which operation sat unanswered, and what the plugin
1114
+ // answered instead (OmniTerm's reaper writes the same notice).
1115
+ text: picked === undefined
1116
+ ? `acp: permission request for "${tool}" timed out after ${String(Math.round(timeoutMs / 1000))}s; refused`
1117
+ : `acp: permission request for "${tool}" timed out after ${String(Math.round(timeoutMs / 1000))}s; answered "${picked.name}"`,
998
1118
  });
999
- this.permissions.respond(requestId, { outcome: 'cancelled' });
1119
+ this.permissions.respond(requestId, picked === undefined
1120
+ ? { outcome: 'cancelled' }
1121
+ : { outcome: 'selected', optionId: picked.optionId });
1000
1122
  }, timeoutMs);
1001
1123
  timer.unref?.();
1002
1124
  }
@@ -1025,8 +1147,19 @@ export class AgentBridge {
1025
1147
  * open turn, so a session started over `/acp` (no chat turn) cannot be
1026
1148
  * routed and must stay on the ledger path.
1027
1149
  *
1028
- * No `permissionTimeoutMs` timer is armed here on purpose: the human answers
1029
- * inside the conversation, and a host-side deadline would answer for them.
1150
+ * The ask is raced against {@link BridgeTuning.permissionTimeoutMs}. A
1151
+ * routed ask has exactly one answer entry — the composer takeover — and no
1152
+ * broadcast at all, so a host that takes the question and never renders it
1153
+ * would leave the turn hanging behind something invisible. Past the deadline
1154
+ * the ask is settled RIGHT THERE, by
1155
+ * {@link BridgeTuning.permissionTimeoutMode}: refused (`abort`), answered
1156
+ * with the option the agent's own kinds imply (`auto`), or never expiring
1157
+ * at all (`wait`).
1158
+ *
1159
+ * It is NOT handed somewhere else. An earlier revision withdrew the ask and
1160
+ * re-asked it on the settings ledger, which made the question travel to a
1161
+ * page the user was not looking at — and then cancelled it there a few
1162
+ * minutes later anyway. A deadline now ends the ask where it was asked.
1030
1163
  *
1031
1164
  * @returns the agent-facing response, or the reason the ask must fall back
1032
1165
  * to the ledger (see {@link LedgerFallbackReason}).
@@ -1037,20 +1170,73 @@ export class AgentBridge {
1037
1170
  return { reason: 'no-requester' };
1038
1171
  if (session.dshSessionId === undefined)
1039
1172
  return { reason: 'no-chat-session' };
1173
+ // The mode is read ONCE, here: it decides both whether a deadline exists
1174
+ // and what happens when it fires. An ask that started under one mode keeps
1175
+ // it — the same rule the window follows (an ask already holding a timer
1176
+ // keeps the one it started with).
1177
+ const mode = this.permissionTimeoutMode;
1178
+ // 不等待(0)只属自动推进:没有问人的必要。直接按那一档收场,composer 接管
1179
+ // 根本不必出现——它只会闪一下就没了,问了也来不及答。超时中止没有这一档。
1180
+ if (mode === 'auto' && this.permissionTimeoutMs === 0) {
1181
+ return { response: this.settleExpiredAsk(session, mode, toolCall, options, 0) };
1182
+ }
1183
+ // One signal carries both withdrawals: the agent's own cancel (whatever
1184
+ // aborted the ACP request) and the deadline. The host's waterfall races its
1185
+ // answerers against this signal, so aborting is a clean withdrawal rather
1186
+ // than a leak.
1187
+ const withdraw = new AbortController();
1188
+ const askSignal = signal === undefined ? withdraw.signal : AbortSignal.any([signal, withdraw.signal]);
1189
+ let timer;
1190
+ let timedOut = false;
1191
+ // `wait` never expires, so it never arms a timer at all. A window of `0`
1192
+ // never reaches here: 不等待 was already settled above.
1193
+ const windowMs = this.permissionTimeoutMs;
1194
+ const deadline = mode !== 'wait' && windowMs > 0
1195
+ ? new Promise(resolve => {
1196
+ timer = setTimeout(() => {
1197
+ timedOut = true;
1198
+ withdraw.abort();
1199
+ resolve({ timedOut: true });
1200
+ }, windowMs);
1201
+ timer.unref?.();
1202
+ })
1203
+ : undefined;
1040
1204
  let outcome;
1041
1205
  try {
1042
- outcome = await requester({
1206
+ const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
1207
+ const asked = requester({
1043
1208
  dshSessionId: session.dshSessionId,
1044
1209
  agentId: this.def.id,
1045
1210
  toolName: permissionToolName(toolCall),
1046
- ...(signal === undefined ? {} : { signal }),
1211
+ reason: permissionAskReason(toolCall),
1212
+ // The ACP call id is the id the adapter streamed on the host tool card
1213
+ // (`TurnStreamController` fills `tool-call` blocks from
1214
+ // `snapshot.callId`), so the host's approval detail slot can attach
1215
+ // the operation's own card to this ask.
1216
+ ...(typeof row.toolCallId === 'string' && row.toolCallId !== '' ? { callId: row.toolCallId } : {}),
1217
+ signal: askSignal,
1047
1218
  });
1219
+ // A withdrawn ask settles on the host's own schedule; dropping it here
1220
+ // must not surface as an unhandled rejection later.
1221
+ asked.catch(() => { });
1222
+ const settled = deadline === undefined
1223
+ ? await asked.then(o => ({ outcome: o }))
1224
+ : await Promise.race([asked.then(o => ({ outcome: o })), deadline]);
1225
+ // The flag, not the race winner, decides: the host may settle `cancelled`
1226
+ // from the very abort the deadline caused, and that is still a timeout.
1227
+ if (timedOut || 'timedOut' in settled) {
1228
+ return { response: this.settleExpiredAsk(session, mode, toolCall, options, windowMs) };
1229
+ }
1230
+ outcome = settled.outcome;
1048
1231
  }
1049
1232
  catch {
1050
1233
  // Synchronous throw or rejection, either way: the ask falls back to the
1051
1234
  // ledger. An approval failure must never break the agent's turn.
1052
1235
  outcome = 'unavailable';
1053
1236
  }
1237
+ finally {
1238
+ clearTimeout(timer);
1239
+ }
1054
1240
  if (outcome === 'unavailable')
1055
1241
  return { reason: 'unavailable' };
1056
1242
  const response = nativeOutcomeResponse(outcome, options);
@@ -1068,6 +1254,57 @@ export class AgentBridge {
1068
1254
  });
1069
1255
  return { response };
1070
1256
  }
1257
+ /**
1258
+ * Settle an ask whose deadline fired, WHERE IT WAS ASKED.
1259
+ *
1260
+ * `abort` refuses it — the operation does not happen and the agent carries
1261
+ * on with that answer. `auto` answers it with the option the agent's own
1262
+ * kinds imply ({@link pickAutoOption}) so the agent CONTINUES; it says which
1263
+ * option it picked, because an unattended approval nobody looked at has to
1264
+ * be visible afterwards. A mode that cannot pick an option (no options, or
1265
+ * none with a usable id) degrades to `abort`: refusing is safer than
1266
+ * guessing, and hanging is worse than either.
1267
+ *
1268
+ * The `permission/resolved` frame keeps the event stream symmetric with the
1269
+ * ledger path, so the browser drops any card it may hold for this session.
1270
+ */
1271
+ settleExpiredAsk(session, mode, toolCall, options, timeoutMs) {
1272
+ const picked = mode === 'auto'
1273
+ ? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
1274
+ : undefined;
1275
+ const response = picked === undefined
1276
+ ? { outcome: { outcome: 'cancelled' } }
1277
+ : { outcome: { outcome: 'selected', optionId: picked.optionId } };
1278
+ const tool = permissionToolName(toolCall);
1279
+ // 不等待(0)不是「等了 0 秒」,是没等:两种说法的区别就在这里。
1280
+ const waited = timeoutMs > 0
1281
+ ? `timed out after ${String(Math.round(timeoutMs / 1000))}s`
1282
+ : 'was not waited for';
1283
+ this.hub.broadcast({
1284
+ type: 'agent/notice',
1285
+ agentId: this.def.id,
1286
+ level: 'info',
1287
+ // Name what was missed and what was done about it — the way a human
1288
+ // needs it: which operation sat unanswered, and what the plugin answered
1289
+ // instead. OmniTerm's reaper writes the same "you missed this" notice.
1290
+ text: picked === undefined
1291
+ ? `acp: permission request for "${tool}" ${waited}; refused`
1292
+ : `acp: permission request for "${tool}" ${waited}; answered "${picked.name}"`,
1293
+ });
1294
+ // The conversation's only trace of the expiry: the composer card is going
1295
+ // away and nothing else says why.
1296
+ this.hub.broadcast({
1297
+ type: 'permission/expired',
1298
+ sessionId: session.sessionId,
1299
+ toolCall,
1300
+ timeoutMs,
1301
+ outcome: response.outcome.outcome === 'selected'
1302
+ ? { outcome: 'selected', optionId: response.outcome.optionId }
1303
+ : { outcome: 'cancelled' },
1304
+ ...(picked === undefined ? {} : { optionName: picked.name }),
1305
+ });
1306
+ return response;
1307
+ }
1071
1308
  settlePermissions(session) {
1072
1309
  this.permissions.settleFor(p => p.sessionId === session.sessionId);
1073
1310
  }
@@ -1179,9 +1416,84 @@ export class AgentBridge {
1179
1416
  }
1180
1417
  }
1181
1418
  /** Human-facing identity of an ACP tool call (the approval headline). */
1182
- function permissionToolName(toolCall) {
1419
+ /**
1420
+ * Human-facing name of the tool an ask is about, for the chat line and the
1421
+ * timeout notice.
1422
+ *
1423
+ * The ACP `toolCall.title` is the declared field, but it is OPTIONAL and real
1424
+ * agents omit it: codebuddy puts the name in `_meta["codebuddy.ai/toolName"]`
1425
+ * (observed live — without the fallback the notice reads `for "tool call"`,
1426
+ * which tells the human nothing about what they missed). So read `title` first,
1427
+ * then any `_meta` key whose last segment is `toolName`, then the standard
1428
+ * `kind`, and only then give up with the generic phrase. Nothing is invented:
1429
+ * every step reads a key the agent itself declared.
1430
+ */
1431
+ export function permissionToolName(toolCall) {
1432
+ const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
1433
+ if (typeof row.title === 'string' && row.title !== '')
1434
+ return row.title;
1435
+ const meta = (typeof row._meta === 'object' && row._meta !== null ? row._meta : {});
1436
+ for (const [key, value] of Object.entries(meta)) {
1437
+ const segment = key.split('/').at(-1) ?? key;
1438
+ if (segment.toLowerCase() === 'toolname' && typeof value === 'string' && value !== '')
1439
+ return value;
1440
+ }
1441
+ if (typeof row.kind === 'string' && row.kind !== '')
1442
+ return row.kind;
1443
+ return 'tool call';
1444
+ }
1445
+ /** Longest reason line the approval card headline carries. */
1446
+ const APPROVAL_REASON_MAX = 240;
1447
+ /** Input keys that name the operation in one string, most specific first. */
1448
+ const APPROVAL_REASON_INPUT_KEYS = [
1449
+ 'command',
1450
+ 'cmd',
1451
+ 'path',
1452
+ 'file_path',
1453
+ 'filePath',
1454
+ 'pattern',
1455
+ 'query',
1456
+ 'url',
1457
+ 'description',
1458
+ ];
1459
+ /**
1460
+ * One line saying WHAT the native approval card is about.
1461
+ *
1462
+ * WHY: the host renders `reason ?? "工具 <name> 请求越权执行"`. Without a
1463
+ * reason the human only sees the tool's NAME ("Bash") and cannot judge the
1464
+ * request; the ACP tool call already declares the missing half — its title and
1465
+ * (for execute/edit tools) a short scalar in `rawInput`. Only ONE declared
1466
+ * string is used, never a serialized object or a whole file, and the line is
1467
+ * capped so a pathological input cannot fill the card or the audit log.
1468
+ */
1469
+ export function permissionAskReason(toolCall) {
1183
1470
  const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
1184
- return typeof row.title === 'string' && row.title !== '' ? row.title : 'tool call';
1471
+ const title = permissionToolName(toolCall);
1472
+ const raw = row.rawInput;
1473
+ const input = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
1474
+ ? raw
1475
+ : typeof raw === 'string' && raw.length <= APPROVAL_REASON_MAX * 4
1476
+ // Some agents send the arguments as a JSON string; read one scalar out
1477
+ // of it instead of echoing the blob.
1478
+ ? (() => {
1479
+ try {
1480
+ const parsed = JSON.parse(raw);
1481
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
1482
+ ? parsed
1483
+ : undefined;
1484
+ }
1485
+ catch {
1486
+ return undefined;
1487
+ }
1488
+ })()
1489
+ : undefined;
1490
+ const detail = input === undefined
1491
+ ? undefined
1492
+ : APPROVAL_REASON_INPUT_KEYS
1493
+ .map(key => input[key])
1494
+ .find((value) => typeof value === 'string' && value !== '');
1495
+ const line = detail === undefined || detail === title ? title : `${title} · ${detail}`;
1496
+ return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line;
1185
1497
  }
1186
1498
  /**
1187
1499
  * Translate a native approval outcome into the ACP option the agent must see.
@@ -2141,24 +2453,36 @@ class TurnStreamController {
2141
2453
  }
2142
2454
  else if (row.type === 'permission/request') {
2143
2455
  const typed = event;
2144
- const seg = this.segment;
2145
- const line = this.permissionLine(typed);
2146
- if (seg === undefined || seg.finished) {
2147
- if (line !== '')
2148
- this.pendingProse.push(line);
2149
- return;
2150
- }
2151
- if (seg.hasCard) {
2152
- // The card must stay the segment's last block: park the line for the
2153
- // next segment instead of streaming it below the card.
2154
- this.pendingProse.push(line);
2155
- this.finishSegment(seg, { kind: 'stop' });
2156
- return;
2157
- }
2158
- if (line !== '')
2159
- this.emitProse(seg, 'reasoning', line);
2456
+ this.pushProse(this.permissionLine(typed));
2457
+ }
2458
+ else if (row.type === 'permission/expired') {
2459
+ const typed = event;
2460
+ this.pushProse(this.expiredLine(typed));
2160
2461
  }
2161
2462
  }
2463
+ /**
2464
+ * Put one narrative line into the turn's prose stream.
2465
+ *
2466
+ * A card-bearing segment must stay the segment's LAST block, so a line that
2467
+ * arrives while a card is open is parked for the next segment instead of
2468
+ * streaming below the card. A line that arrives between segments waits for
2469
+ * the next one to open.
2470
+ */
2471
+ pushProse(line) {
2472
+ if (line === '')
2473
+ return;
2474
+ const seg = this.segment;
2475
+ if (seg === undefined || seg.finished) {
2476
+ this.pendingProse.push(line);
2477
+ return;
2478
+ }
2479
+ if (seg.hasCard) {
2480
+ this.pendingProse.push(line);
2481
+ this.finishSegment(seg, { kind: 'stop' });
2482
+ return;
2483
+ }
2484
+ this.emitProse(seg, 'reasoning', line);
2485
+ }
2162
2486
  /** Events that arrive between segments: recorded, replayed into the next one. */
2163
2487
  recordWithoutSegment(update) {
2164
2488
  const kind = String(update.sessionUpdate ?? '');
@@ -2298,14 +2622,44 @@ class TurnStreamController {
2298
2622
  : 'permission';
2299
2623
  // The line must not point at a place with no answer entry, and the bridge
2300
2624
  // knows which path this ask took (`ledgerFallbackOf`, recorded before the
2301
- // frame was broadcast). On the DESIGNED ledger paths the settings panel's
2302
- // card is the answer entry. A DEGRADED native route carries a deadline.
2625
+ // frame was broadcast). On the ledger paths the settings panel's card is
2626
+ // the answer entry. A DEGRADED native route has to say why the
2627
+ // conversation is not showing the question — as an OBSERVED FACT, not a
2628
+ // guessed cause.
2629
+ //
2630
+ // The last clause says what happens if nobody answers, and it follows the
2631
+ // MODE: refused, answered automatically, or (in `wait`) nothing at all.
2303
2632
  const fallback = this.bridge.ledgerFallbackOf(event.requestId);
2304
- return fallback?.reason === 'unavailable'
2305
- ? fallback.timeoutMs > 0
2306
- ? `\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`
2307
- : `\n> 🔐 [Permission Required: ${title}] - the approval service is unavailable; please approve in Settings\n`
2308
- : `\n> 🔐 [Permission Required: ${title}] - please approve in Settings\n`;
2633
+ const why = fallback?.reason === 'unavailable' ? 'the approval service is unavailable' : undefined;
2634
+ const lead = why === undefined ? 'please approve in Settings' : `${why}; approve in Settings`;
2635
+ if (fallback === undefined || fallback.timeoutMs <= 0 || fallback.mode === 'wait') {
2636
+ return `\n> 🔐 [Permission Required: ${title}] - ${lead}\n`;
2637
+ }
2638
+ const otherwise = fallback.mode === 'auto'
2639
+ ? 'this request is answered automatically'
2640
+ : 'this request is cancelled';
2641
+ return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(Math.round(fallback.timeoutMs / 1000))}s or ${otherwise}\n`;
2642
+ }
2643
+ /**
2644
+ * The line for an ask the plugin settled itself when its deadline fired.
2645
+ *
2646
+ * The composer card is disappearing, so the conversation has to say what
2647
+ * happened to the question — otherwise the user is left with a vanished
2648
+ * card and no explanation. The wording follows the outcome: refused, or
2649
+ * answered with the option the agent's own kinds implied.
2650
+ */
2651
+ expiredLine(event) {
2652
+ const title = typeof event.toolCall?.title === 'string'
2653
+ ? String(event.toolCall.title)
2654
+ : 'permission';
2655
+ const waited = event.timeoutMs > 0
2656
+ ? `no answer within ${String(Math.round(event.timeoutMs / 1000))}s`
2657
+ : 'not waited for';
2658
+ if (event.outcome.outcome === 'selected') {
2659
+ const picked = event.optionName === undefined ? event.outcome.optionId : event.optionName;
2660
+ return `\n> 🔐 [Permission Required: ${title}] - ${waited}; answered "${picked}" automatically, continuing\n`;
2661
+ }
2662
+ return `\n> 🔐 [Permission Required: ${title}] - ${waited}; refused\n`;
2309
2663
  }
2310
2664
  }
2311
2665
  /** Chunk text shared by the controller's fold (string / object / array forms). */
@@ -2343,13 +2697,59 @@ export class BridgeManager {
2343
2697
  configStoragePath;
2344
2698
  catalogStoragePath;
2345
2699
  probeModelsOnBoot;
2346
- tuning;
2347
2700
  bridges = new Map();
2348
2701
  defsMap = new Map();
2349
2702
  /** The plugin's own default ACP agent id (persisted; see `persistAgents`). */
2350
2703
  defaultAgentId;
2351
2704
  /** The agent tag's halo preference (persisted; see `persistAgents`). */
2352
2705
  tagHaloSetting = { ...DEFAULT_TAG_HALO };
2706
+ /**
2707
+ * What `cordis.patch.yml` asked for — ONLY if it actually named the key.
2708
+ * This is the deployment baseline, kept apart from
2709
+ * {@link deadlineOverride} so the settings card can tell the user which of
2710
+ * the two is winning. Empty when the file says nothing about the window, in
2711
+ * which case {@link baselineDeadline} (the built-in default) applies.
2712
+ */
2713
+ configuredDeadlines = {};
2714
+ /**
2715
+ * The mode the config file named, if it named one. Kept apart from the
2716
+ * override for the same reason the window is: the card has to be able
2717
+ * to say "the file asks for `abort`, the value set here wins".
2718
+ */
2719
+ configuredMode;
2720
+ /**
2721
+ * The window before any override — the tuning this manager was constructed
2722
+ * with, which already carries the validated config file (the host entry
2723
+ * passes `validateConfig`'s output) or the built-in default.
2724
+ *
2725
+ * Held separately from {@link configuredDeadlines} because the two answer
2726
+ * different questions: this one is what RUNS when nothing is overridden, that
2727
+ * one is what the config file NAMED (which the card shows as the reset target
2728
+ * and a bare default does not deserve). A manager built without tuning at all
2729
+ * — a direct consumer, a test — gets `DEFAULT_TUNING` here and stays correct.
2730
+ */
2731
+ baselineDeadline;
2732
+ /**
2733
+ * The mode before any override — the tuning this manager was constructed
2734
+ * with, which already carries the validated config file (the host entry
2735
+ * passes `validateConfig`'s output) or the built-in default.
2736
+ */
2737
+ baselineMode;
2738
+ /**
2739
+ * The settings page's own write, persisted with the agent registry. Merged
2740
+ * OVER {@link baselineDeadline}: once a user picks a window in the UI, that
2741
+ * pick is what runs, and the config file becomes the value the reset button
2742
+ * returns to. Without this split a hand-edited `cordis.patch.yml` and a UI
2743
+ * write would fight with no way to tell which one is live.
2744
+ */
2745
+ deadlineOverride = {};
2746
+ /**
2747
+ * Tuning in force, defaults applied. The approval deadline is rewritten
2748
+ * IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
2749
+ * rather than the constructor's parameter: every bridge built afterwards
2750
+ * inherits the rewrite instead of the boot-time value.
2751
+ */
2752
+ tuning;
2353
2753
  /**
2354
2754
  * The in-flight (or just-settled) segmented turn of one DSH chat session.
2355
2755
  * Keyed by chat session id because the continuation steps of a segmented
@@ -2396,7 +2796,16 @@ export class BridgeManager {
2396
2796
  this.configStoragePath = configStoragePath;
2397
2797
  this.catalogStoragePath = catalogStoragePath;
2398
2798
  this.probeModelsOnBoot = probeModelsOnBoot;
2399
- this.tuning = tuning;
2799
+ // Held as a live object rather than the raw parameter: the two approval
2800
+ // deadlines are rewritten in place when the settings page writes them (see
2801
+ // `applyDeadlineTuning`), and every bridge built afterwards must inherit
2802
+ // the rewrite instead of the boot-time value.
2803
+ this.tuning = { ...DEFAULT_TUNING, ...tuning };
2804
+ // The pre-override deadline value, snapshotted before anything can rewrite
2805
+ // it. Everything else in `tuning` is fixed for the manager's lifetime, so
2806
+ // only this needs a snapshot.
2807
+ this.baselineDeadline = this.tuning.permissionTimeoutMs;
2808
+ this.baselineMode = this.tuning.permissionTimeoutMode;
2400
2809
  for (const def of defs) {
2401
2810
  this.defsMap.set(def.id, def);
2402
2811
  }
@@ -2409,6 +2818,9 @@ export class BridgeManager {
2409
2818
  }
2410
2819
  this.loadPersistedAgents();
2411
2820
  this.loadPersistedCatalogs();
2821
+ // A persisted deadline override must reach the tuning the bridges are built
2822
+ // from, not just the settings card's readout.
2823
+ this.applyDeadlineTuning();
2412
2824
  // The request-path rewrite falls back to this default for a conversation
2413
2825
  // the user never bound explicitly — the same agent the composer chip shows.
2414
2826
  routeBindingRegistry().defaultModel = this.defaultAgent();
@@ -2429,6 +2841,19 @@ export class BridgeManager {
2429
2841
  approvalRouting() {
2430
2842
  return this.approvalRequester !== undefined;
2431
2843
  }
2844
+ /**
2845
+ * Every outstanding ledger ask, across all agents, with the view the browser
2846
+ * was shown it — the source of the reconnect replay (see
2847
+ * `permissionReplayFrames`). Asks on the native route are deliberately
2848
+ * absent: they broadcast nothing and their answer entry is the composer
2849
+ * takeover the client already holds.
2850
+ */
2851
+ pendingPermissionViews() {
2852
+ const views = [];
2853
+ for (const bridge of this.bridges.values())
2854
+ views.push(...bridge.permissions.pendingViews());
2855
+ return views;
2856
+ }
2432
2857
  /**
2433
2858
  * Let the host resolve a chat session's workspace root — the sidebar's
2434
2859
  * selected workspace, which the agent's session and its process both run in.
@@ -3213,6 +3638,41 @@ export class BridgeManager {
3213
3638
  catch {
3214
3639
  this.tagHaloSetting = { ...DEFAULT_TAG_HALO };
3215
3640
  }
3641
+ // The settings page's approval-deadline override. Re-validated on every
3642
+ // boot because the state file is user- and version-editable: an
3643
+ // unreadable value must not poison the deadline, and a value the
3644
+ // validator accepts must be the value that runs (a UI write that did
3645
+ // not survive a restart would read as a broken setting).
3646
+ try {
3647
+ const raw = parsed.approvalDeadlines ?? {};
3648
+ // The OLD card wrote the two legacy windows, and its 「关」 preset
3649
+ // wrote a zero — which then meant "this window never fires". The only
3650
+ // way to say that now is the `wait` MODE (a zero means 不等待 today),
3651
+ // so a legacy zero carries over as the mode instead of silently
3652
+ // turning "wait forever" into "answer immediately".
3653
+ const legacy = raw;
3654
+ const legacyWindow = legacy.nativeApprovalTimeoutMs ?? legacy.permissionFallbackTimeoutMs;
3655
+ const legacyNever = legacyWindow === 0;
3656
+ const window = legacyNever
3657
+ ? undefined
3658
+ : validateApprovalDeadlines(raw).permissionTimeoutMs;
3659
+ const mode = legacyNever
3660
+ ? 'wait'
3661
+ : (raw.mode === undefined
3662
+ ? undefined
3663
+ : validatePermissionTimeoutMode(raw.mode, 'bad_payload'));
3664
+ // A stored window must be legal for the mode it runs under: 不等待
3665
+ // (0) belongs to `auto`; under `abort` it would refuse every ask on
3666
+ // arrival, so it floors to the shortest real wait.
3667
+ const effectiveMode = mode ?? this.baselineMode;
3668
+ this.deadlineOverride = {
3669
+ ...(window === undefined ? {} : { permissionTimeoutMs: normalizeApprovalWindow(effectiveMode, window) }),
3670
+ ...(mode === undefined ? {} : { mode }),
3671
+ };
3672
+ }
3673
+ catch {
3674
+ this.deadlineOverride = {};
3675
+ }
3216
3676
  this.loadPersistedChoices(parsed.conversations);
3217
3677
  }
3218
3678
  }
@@ -3267,6 +3727,11 @@ export class BridgeManager {
3267
3727
  ...(this.defaultAgentId === undefined ? {} : { defaultAgentId: this.defaultAgentId }),
3268
3728
  tagHalo: this.tagHaloSetting,
3269
3729
  agents: [...this.defsMap.values()],
3730
+ // The settings page's approval deadlines. Absent while none was ever
3731
+ // written, so a fresh deployment's state file stays exactly as it was.
3732
+ ...(Object.keys(this.deadlineOverride).length === 0
3733
+ ? {}
3734
+ : { approvalDeadlines: { ...this.deadlineOverride } }),
3270
3735
  // The per-conversation picks: agent + model + thought level + mode +
3271
3736
  // extra config values chosen in the composer, including for
3272
3737
  // conversations with no live session yet.
@@ -4260,6 +4725,99 @@ export class BridgeManager {
4260
4725
  this.tagHaloSetting = validateTagHalo(halo);
4261
4726
  this.persistAgents();
4262
4727
  }
4728
+ /**
4729
+ * Declare the deployment baseline for the approval deadline — the values
4730
+ * `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
4731
+ *
4732
+ * Kept separate from the validated tuning because "the file says 600000" and
4733
+ * "the file says nothing, so 300000 applies" are different facts: the settings
4734
+ * card shows the first as the reset target and the second as a built-in
4735
+ * default. Called once by the host entry, before any bridge exists — and it
4736
+ * also moves the baseline, so a manager built without tuning (a direct
4737
+ * consumer, a test) still runs what the deployment asked for.
4738
+ */
4739
+ setConfiguredApprovalDeadlines(values, mode) {
4740
+ // The baseline must be legal for the mode it runs under, or the card would
4741
+ // advertise a number the deployment cannot actually run (不等待 under
4742
+ // `abort`) and the reset button would restore it.
4743
+ const effectiveMode = mode ?? this.configuredMode ?? this.baselineMode;
4744
+ const raw = values.permissionTimeoutMs;
4745
+ const window = raw === undefined ? undefined : normalizeApprovalWindow(effectiveMode, raw);
4746
+ this.configuredDeadlines = window === undefined ? {} : { permissionTimeoutMs: window };
4747
+ if (window !== undefined)
4748
+ this.baselineDeadline = window;
4749
+ if (mode !== undefined) {
4750
+ this.configuredMode = mode;
4751
+ this.baselineMode = mode;
4752
+ }
4753
+ this.applyDeadlineTuning();
4754
+ }
4755
+ /**
4756
+ * The approval deadline in force, plus the facts that explain a value the
4757
+ * user did not pick: the config-file baseline an override is outranking. The
4758
+ * mode rides along with its own baseline, for the same reason: the card has
4759
+ * to be able to say "the file asks for `abort`, the value set here wins".
4760
+ *
4761
+ * Everything is reported only when it is actually in play, so the card can
4762
+ * stay silent about a plain default.
4763
+ */
4764
+ approvalDeadlines() {
4765
+ const deadlines = {
4766
+ permissionTimeoutMs: this.tuning.permissionTimeoutMs,
4767
+ mode: this.tuning.permissionTimeoutMode,
4768
+ };
4769
+ if (Object.keys(this.configuredDeadlines).length > 0)
4770
+ deadlines.configured = { ...this.configuredDeadlines };
4771
+ if (this.configuredMode !== undefined)
4772
+ deadlines.configuredMode = this.configuredMode;
4773
+ return deadlines;
4774
+ }
4775
+ /**
4776
+ * Write the approval deadline, or the mode (omitted keys keep their value),
4777
+ * or drop the persisted override with `reset` so the config-file baseline —
4778
+ * or the built-in default — applies again.
4779
+ *
4780
+ * HOT: the rewrite reaches every live bridge, so a user changing the window
4781
+ * never has to kill a running session to see it take effect (the one thing an
4782
+ * `agent/save`-style restart would cost them). Only asks armed AFTER the
4783
+ * write see the new window; an ask already holding a timer keeps the one it
4784
+ * started with.
4785
+ */
4786
+ async setApprovalDeadlines(patch, reset = false) {
4787
+ // Merged BEFORE validating, so the whole resulting override passes the same
4788
+ // gate the config file does (an override written by an older build is
4789
+ // re-gated here rather than trusted). The validators also drop unknown
4790
+ // keys, which is what keeps a stale payload from poisoning state.
4791
+ const merged = reset ? {} : { ...this.deadlineOverride, ...patch };
4792
+ const window = validateApprovalDeadlines(merged).permissionTimeoutMs;
4793
+ const mode = merged.mode === undefined
4794
+ ? undefined
4795
+ : validatePermissionTimeoutMode(merged.mode, 'bad_payload');
4796
+ // The stored override must be legal for the mode it runs under, not just
4797
+ // legal on its own: 不等待 (0) belongs to `auto`, and a mode switch can
4798
+ // leave it behind. Flooring it HERE (not only in the running value) keeps
4799
+ // the card's number and the running number the same number.
4800
+ const effectiveMode = mode ?? this.baselineMode;
4801
+ this.deadlineOverride = {
4802
+ ...(window === undefined ? {} : { permissionTimeoutMs: normalizeApprovalWindow(effectiveMode, window) }),
4803
+ ...(mode === undefined ? {} : { mode }),
4804
+ };
4805
+ this.applyDeadlineTuning();
4806
+ this.persistAgents();
4807
+ }
4808
+ /**
4809
+ * Recompute the deadline and the mode from override → deployment baseline,
4810
+ * and push the result to every live bridge.
4811
+ */
4812
+ applyDeadlineTuning() {
4813
+ const permissionTimeoutMode = this.deadlineOverride.mode ?? this.baselineMode;
4814
+ const permissionTimeoutMs = normalizeApprovalWindow(permissionTimeoutMode, this.deadlineOverride.permissionTimeoutMs ?? this.baselineDeadline);
4815
+ this.tuning.permissionTimeoutMs = permissionTimeoutMs;
4816
+ this.tuning.permissionTimeoutMode = permissionTimeoutMode;
4817
+ const patch = { permissionTimeoutMs, mode: permissionTimeoutMode };
4818
+ for (const bridge of this.bridges.values())
4819
+ bridge.applyApprovalDeadlines(patch);
4820
+ }
4263
4821
  dshSessionMap = new Map();
4264
4822
  /**
4265
4823
  * What each DSH chat session has chosen for its ACP agent — the agent itself