dsh-ntr 0.1.5 → 0.2.0

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,7 +12,7 @@ import { delimiter, dirname, join } from 'node:path';
12
12
  import { Writable, Readable } from 'node:stream';
13
13
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
15
- import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, } from "./impl.js";
15
+ import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, 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`. */
@@ -73,9 +73,8 @@ function toAcpMcpServers(defs) {
73
73
  }
74
74
  const DEFAULT_TUNING = {
75
75
  startupTimeoutMs: 60_000,
76
- permissionTimeoutMs: 0,
77
- permissionFallbackTimeoutMs: 300_000,
78
- nativeApprovalTimeoutMs: 300_000,
76
+ permissionTimeoutMs: 300_000,
77
+ permissionTimeoutMode: 'wait',
79
78
  cancelFallbackMs: 15_000,
80
79
  staleCancelRetryDelayMs: 600,
81
80
  staleCancelMaxRetries: 2,
@@ -90,7 +89,6 @@ export class AgentBridge {
90
89
  onConnectionLost;
91
90
  onCatalog;
92
91
  onCommands;
93
- approvalRequester;
94
92
  sessionWorkspace;
95
93
  state = 'stopped';
96
94
  initResult;
@@ -106,29 +104,18 @@ export class AgentBridge {
106
104
  /** Inflight `session/load` resumes, shared so concurrent prompts don't double-resume. */
107
105
  resuming = new Map();
108
106
  permissions = new PermissionLedger();
109
- /**
110
- * Why each outstanding ledger ask is on the ledger, keyed by request id and
111
- * recorded before the `permission/request` broadcast (the hub is
112
- * synchronous, so a subscriber can read it the moment the frame goes out).
113
- * Entries live exactly as long as the ask: the ledger's resolve callback
114
- * deletes them, and every terminal path (answer, timeout, session close,
115
- * process death) resolves through it.
116
- */
117
- ledgerFallbacks = new Map;
118
107
  startupTimeoutMs;
119
- permissionTimeoutMs;
120
108
  /**
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.
109
+ * The ONE approval deadline, and the mode it settles with. Neither is
110
+ * readonly, unlike their neighbors: the settings page rewrites them on a
111
+ * running bridge ({@link AgentBridge.applyApprovalDeadlines}), so a user
112
+ * editing the card does not have to kill every live session to see it take
113
+ * effect. Both are read per ask, so a reassignment reaches every ask from
114
+ * that moment on. An ask already holding an armed timer keeps the window it
115
+ * was started with.
129
116
  */
130
- permissionFallbackTimeoutMs;
131
- nativeApprovalTimeoutMs;
117
+ permissionTimeoutMs;
118
+ permissionTimeoutMode;
132
119
  cancelFallbackMs;
133
120
  staleCancelRetryDelayMs;
134
121
  staleCancelMaxRetries;
@@ -176,13 +163,8 @@ export class AgentBridge {
176
163
  /** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
177
164
  onCommands, tuning,
178
165
  /**
179
- * Resolved per ask (not captured) so a requester wired after this bridge
180
- * was built still applies. Absent → every ask rides the ledger.
181
- */
182
- approvalRequester,
183
- /**
184
- * Resolved per turn, like {@link approvalRequester}: the host entry may
185
- * wire the access-mode mapping after this bridge was built.
166
+ * Resolved per turn: the host entry may wire the workspace reader after
167
+ * this bridge was built.
186
168
  */
187
169
  sessionWorkspace) {
188
170
  this.def = def;
@@ -191,13 +173,13 @@ export class AgentBridge {
191
173
  this.onConnectionLost = onConnectionLost;
192
174
  this.onCatalog = onCatalog;
193
175
  this.onCommands = onCommands;
194
- this.approvalRequester = approvalRequester;
195
176
  this.sessionWorkspace = sessionWorkspace;
196
177
  const merged = { ...DEFAULT_TUNING, ...tuning };
197
178
  this.startupTimeoutMs = merged.startupTimeoutMs;
198
- this.permissionTimeoutMs = merged.permissionTimeoutMs;
199
- this.permissionFallbackTimeoutMs = merged.permissionFallbackTimeoutMs;
200
- this.nativeApprovalTimeoutMs = merged.nativeApprovalTimeoutMs;
179
+ this.permissionTimeoutMode = merged.permissionTimeoutMode;
180
+ // 不等待 (0) is the `auto` mode's business; under `abort` it would refuse
181
+ // every ask on arrival, so the window is floored to the shortest real wait.
182
+ this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, merged.permissionTimeoutMs);
201
183
  this.cancelFallbackMs = merged.cancelFallbackMs;
202
184
  this.staleCancelRetryDelayMs = merged.staleCancelRetryDelayMs;
203
185
  this.staleCancelMaxRetries = merged.staleCancelMaxRetries;
@@ -206,20 +188,24 @@ export class AgentBridge {
206
188
  this.probeIdleTimeoutMs = merged.probeIdleTimeoutMs;
207
189
  }
208
190
  /**
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.
191
+ * Adopt a rewritten approval deadline or mode (the settings page's write).
192
+ * Only the window and the mode live here; every other tuning value is
193
+ * fixed at construction, because a live bridge cannot meaningfully adopt a
194
+ * new startup or cancel window mid-flight.
213
195
  *
214
196
  * Deliberately does NOT re-arm anything: an ask that already holds a timer
215
197
  * keeps the window it was started with, and the next ask reads the new value.
216
198
  * Re-arming would move the goalposts under a human who is already deciding.
217
199
  */
218
200
  applyApprovalDeadlines(patch) {
219
- if (patch.nativeApprovalTimeoutMs !== undefined)
220
- this.nativeApprovalTimeoutMs = patch.nativeApprovalTimeoutMs;
221
- if (patch.permissionFallbackTimeoutMs !== undefined)
222
- this.permissionFallbackTimeoutMs = patch.permissionFallbackTimeoutMs;
201
+ if (patch.permissionTimeoutMs !== undefined)
202
+ this.permissionTimeoutMs = patch.permissionTimeoutMs;
203
+ if (patch.mode !== undefined)
204
+ this.permissionTimeoutMode = patch.mode;
205
+ // A mode switch can leave a window behind that the new mode cannot run
206
+ // (不等待 under `abort`): floor it, so the ask waits instead of refusing
207
+ // on arrival.
208
+ this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, this.permissionTimeoutMs);
223
209
  }
224
210
  /**
225
211
  * The idle-release window this agent honors for its CHAT process: its own
@@ -968,12 +954,15 @@ export class AgentBridge {
968
954
  // Closing a session is activity of its own: restart the idle window here
969
955
  // rather than at the prompt that opened it.
970
956
  this.touch();
957
+ // Answer every outstanding ask BEFORE `session/close` is awaited: an agent
958
+ // blocked on an unanswered `session/request_permission` would otherwise be
959
+ // able to hold the close open indefinitely.
960
+ this.settlePermissions(session);
971
961
  const conn = this.conn;
972
962
  if (conn !== undefined && this.state === 'ready') {
973
963
  // `session/close` is an optional capability; the local teardown stands regardless.
974
964
  await conn.agent.request(methods.agent.session.close, { sessionId: session.acpSessionId }).catch(() => { });
975
965
  }
976
- this.settlePermissions(session);
977
966
  this.sessions.delete(session.sessionId);
978
967
  this.acpIndex.delete(session.acpSessionId);
979
968
  this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason });
@@ -1038,26 +1027,57 @@ export class AgentBridge {
1038
1027
  }, 3000).unref();
1039
1028
  }
1040
1029
  /**
1041
- * Answer one ACP permission ask. Every ask reaches a human: the native
1042
- * approval chain first (answered in the chat composer), the settings-panel
1043
- * ledger otherwise.
1030
+ * Answer one ACP permission ask.
1031
+ *
1032
+ * EVERY ask is answered by a human through the plugin's own ledger. The
1033
+ * host's approval chain is deliberately not involved: it could accept an ask
1034
+ * and render nothing, leaving the turn hanging behind something invisible
1035
+ * with no deadline (issue #2). The ledger broadcasts `permission/request`,
1036
+ * the browser renders the answerable card, and `permission/respond` settles
1037
+ * it.
1038
+ *
1039
+ * The host answers on its own only when a deadline expires: `abort` refuses,
1040
+ * `auto` picks the option the agent's own kinds imply, `wait` never expires.
1041
+ * The ACP request's own `signal` (the agent withdrawing the ask, or the
1042
+ * connection dying) settles the same entry cancelled, so a withdrawn ask can
1043
+ * never leave a stale card behind.
1044
1044
  */
1045
1045
  async handlePermissionRequest(acpSessionId, toolCall, options, signal) {
1046
1046
  const session = this.acpIndex.get(acpSessionId);
1047
1047
  if (session === undefined || session.closed)
1048
1048
  return { outcome: { outcome: 'cancelled' } };
1049
- // Native approval first (answered in the chat composer); a `reason` means
1050
- // no route is available and the settings-panel ledger takes over.
1051
- const native = await this.askNativeApproval(session, toolCall, options, signal);
1052
- if ('response' in native) {
1053
- this.touch();
1054
- return native.response;
1055
- }
1049
+ // The deadline and the mode are read ONCE, here: an ask that started under
1050
+ // one pair keeps it, even if the settings page rewrites them mid-flight.
1051
+ const mode = this.permissionTimeoutMode;
1052
+ const timeoutMs = this.permissionTimeoutMs;
1053
+ // 不等待(0)只属自动推进:请求到达即按那一档收场,不建 ledger 条目、不发卡
1054
+ // ——问都来不及问。超时中止没有这一档(它会立刻拒绝每一问,agent 在每个门口
1055
+ // 都停住),它的窗口最短是真实等待。
1056
+ if (mode === 'auto' && timeoutMs === 0) {
1057
+ return this.settleExpiredAsk(session, mode, toolCall, options, 0);
1058
+ }
1059
+ const optionViews = options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind }));
1060
+ const reason = permissionAskReason(toolCall);
1061
+ // Computed once, at ask creation: the frame, the chat line and the timer
1062
+ // all describe the same instant, and a slow reader cannot stretch it.
1063
+ const deadlineAt = timeoutMs > 0 && mode !== 'wait' ? Date.now() + timeoutMs : undefined;
1064
+ const view = {
1065
+ ...(session.dshSessionId === undefined ? {} : { dshSessionId: session.dshSessionId }),
1066
+ // Kept whole for the reconnect replay: a client that connects after the
1067
+ // ask was broadcast must be re-shown the very card it has to answer.
1068
+ toolCall,
1069
+ toolName: permissionToolName(toolCall),
1070
+ ...(reason === '' ? {} : { reason }),
1071
+ options: optionViews,
1072
+ timeoutMode: mode,
1073
+ ...(deadlineAt === undefined ? {} : { deadlineAt }),
1074
+ };
1056
1075
  return new Promise(resolve => {
1057
1076
  let timer;
1077
+ let detachSignal;
1058
1078
  const requestId = this.permissions.create(session.sessionId, (outcome) => {
1059
1079
  clearTimeout(timer);
1060
- this.ledgerFallbacks.delete(requestId);
1080
+ detachSignal?.();
1061
1081
  this.touch();
1062
1082
  this.hub.broadcast({
1063
1083
  type: 'permission/resolved',
@@ -1068,161 +1088,91 @@ export class AgentBridge {
1068
1088
  resolve(outcome.outcome === 'selected'
1069
1089
  ? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
1070
1090
  : { 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 })),
1076
- });
1077
- // Deadline (`permissionTimeoutMs`): an approval left unanswered is
1078
- // answered `cancelled` on the human's behalf instead of hanging the turn
1079
- // forever. Off by default — approvals belong to a person. The one
1080
- // exception is a DEGRADED native route: the conversation just failed to
1081
- // show the question, so waiting forever would hang the turn behind an
1082
- // ask nobody can see there, and the fallback deadline applies instead
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.
1086
- const timeoutMs = this.permissionTimeoutMs > 0
1087
- ? this.permissionTimeoutMs
1088
- : native.reason === 'unavailable' || native.reason === 'native-timeout'
1089
- ? this.permissionFallbackTimeoutMs
1090
- : 0;
1091
- // Recorded before the broadcast: the hub is synchronous, and the chat
1092
- // line `streamPrompt` folds from this frame words itself by the path
1093
- // this ask actually took.
1094
- this.ledgerFallbacks.set(requestId, {
1095
- reason: native.reason,
1096
- timeoutMs,
1097
- ...(native.reason === 'native-timeout' ? { waitedMs: this.nativeApprovalTimeoutMs } : {}),
1098
- });
1099
- if (timeoutMs > 0) {
1091
+ }, view);
1092
+ // The live frame and the replay frame come from ONE builder, so a
1093
+ // reconnecting client receives exactly what a live one did.
1094
+ this.hub.broadcast(permissionRequestFrame({ requestId, sessionId: session.sessionId, ...view }));
1095
+ if (deadlineAt !== undefined) {
1100
1096
  timer = setTimeout(() => {
1101
1097
  if (!this.permissions.has(requestId))
1102
1098
  return;
1103
- this.hub.broadcast({
1104
- type: 'agent/notice',
1105
- agentId: this.def.id,
1106
- level: 'info',
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"`,
1111
- });
1112
- this.permissions.respond(requestId, { outcome: 'cancelled' });
1113
- }, timeoutMs);
1099
+ // Settle IN PLACE: exactly one notice, one `permission/expired` (the
1100
+ // conversation's trace of the expiry) and the ledger answer.
1101
+ const outcome = this.announceExpiredAsk(session, mode, toolCall, options, timeoutMs);
1102
+ this.permissions.respond(requestId, outcome);
1103
+ }, Math.max(0, deadlineAt - Date.now()));
1114
1104
  timer.unref?.();
1115
1105
  }
1116
- this.hub.broadcast({
1117
- type: 'permission/request',
1118
- requestId,
1119
- sessionId: session.sessionId,
1120
- toolCall,
1121
- options: options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
1122
- });
1106
+ // The agent's own withdrawal settles the entry cancelled; the ledger's
1107
+ // resolve callback broadcasts `permission/resolved`, and drops this
1108
+ // listener, so nothing outlives the ask.
1109
+ if (signal !== undefined) {
1110
+ const onAbort = () => {
1111
+ if (!this.permissions.has(requestId))
1112
+ return;
1113
+ this.permissions.respond(requestId, { outcome: 'cancelled' });
1114
+ };
1115
+ if (signal.aborted) {
1116
+ onAbort();
1117
+ return;
1118
+ }
1119
+ signal.addEventListener('abort', onAbort, { once: true });
1120
+ detachSignal = () => { signal.removeEventListener('abort', onAbort); };
1121
+ }
1123
1122
  });
1124
1123
  }
1125
1124
  /**
1126
- * Why one ledger ask is on the ledger, as recorded by
1127
- * {@link handlePermissionRequest}. The manager's `streamPrompt` reads it to
1128
- * word the chat line by the path the ask took; `undefined` once the ask is
1129
- * answered, timed out, or settled.
1130
- */
1131
- ledgerFallbackOf(requestId) {
1132
- return this.ledgerFallbacks.get(requestId);
1133
- }
1134
- /**
1135
- * Ask the host approval service for this permission (`ctx.approval` → the
1136
- * browser's composer takeover). Only sessions bound to a DSH chat session
1137
- * qualify: the service appends its audit pair to that session inside an
1138
- * open turn, so a session started over `/acp` (no chat turn) cannot be
1139
- * routed and must stay on the ledger path.
1140
- *
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.
1154
- *
1155
- * @returns the agent-facing response, or the reason the ask must fall back
1156
- * to the ledger (see {@link LedgerFallbackReason}).
1125
+ * Announce and compute one ask's expiry. Shared by the 不等待 arrival path
1126
+ * and the ledger deadline, so an expiry can never broadcast two notices or
1127
+ * skip the `permission/expired` line: once the card is gone that frame is
1128
+ * the conversation's only trace of what happened to the question.
1157
1129
  */
1158
- async askNativeApproval(session, toolCall, options, signal) {
1159
- const requester = this.approvalRequester?.();
1160
- if (requester === undefined)
1161
- return { reason: 'no-requester' };
1162
- if (session.dshSessionId === undefined)
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
- })
1130
+ announceExpiredAsk(session, mode, toolCall, options, timeoutMs) {
1131
+ const picked = mode === 'auto'
1132
+ ? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
1181
1133
  : undefined;
1182
- let outcome;
1183
- try {
1184
- const asked = requester({
1185
- dshSessionId: session.dshSessionId,
1186
- agentId: this.def.id,
1187
- toolName: permissionToolName(toolCall),
1188
- signal: askSignal,
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;
1201
- }
1202
- catch {
1203
- // Synchronous throw or rejection, either way: the ask falls back to the
1204
- // ledger. An approval failure must never break the agent's turn.
1205
- outcome = 'unavailable';
1206
- }
1207
- finally {
1208
- clearTimeout(timer);
1209
- }
1210
- if (outcome === 'unavailable')
1211
- return { reason: 'unavailable' };
1212
- const response = nativeOutcomeResponse(outcome, options);
1213
- // Keep the event stream symmetric with the ledger path: a routed decision
1214
- // is still a resolution, and the browser uses it to drop any card it may
1215
- // hold for this session. No `permission/request` is broadcast — the
1216
- // settings panel must never show a card for a composer-routed ask.
1134
+ const outcome = picked === undefined
1135
+ ? { outcome: 'cancelled' }
1136
+ : { outcome: 'selected', optionId: picked.optionId };
1137
+ const tool = permissionToolName(toolCall);
1138
+ // 不等待(0)不是「等了 0 秒」,是没等:两种说法的区别就在这里。
1139
+ const waited = timeoutMs > 0
1140
+ ? `timed out after ${String(Math.round(timeoutMs / 1000))}s`
1141
+ : 'was not waited for';
1217
1142
  this.hub.broadcast({
1218
- type: 'permission/resolved',
1219
- requestId: randomUUID(),
1143
+ type: 'agent/notice',
1144
+ agentId: this.def.id,
1145
+ level: 'info',
1146
+ // Name what was missed and what was done about it — the way a human
1147
+ // needs it: which operation sat unanswered, and what the plugin answered
1148
+ // instead. OmniTerm's reaper writes the same "you missed this" notice.
1149
+ text: picked === undefined
1150
+ ? `acp: permission request for "${tool}" ${waited}; refused`
1151
+ : `acp: permission request for "${tool}" ${waited}; answered "${picked.name}"`,
1152
+ });
1153
+ this.hub.broadcast({
1154
+ type: 'permission/expired',
1220
1155
  sessionId: session.sessionId,
1221
- outcome: response.outcome.outcome === 'selected'
1222
- ? { outcome: 'selected', optionId: response.outcome.optionId }
1223
- : { outcome: 'cancelled' },
1156
+ toolCall,
1157
+ timeoutMs,
1158
+ outcome,
1159
+ ...(picked === undefined ? {} : { optionName: picked.name }),
1224
1160
  });
1225
- return { response };
1161
+ return outcome;
1162
+ }
1163
+ /**
1164
+ * Settle the 不等待 (`auto` + zero window) ask on arrival: no ledger entry
1165
+ * and no card — nothing asks a human, so there is nothing to show. `auto`
1166
+ * answers with the option the agent's own kinds imply ({@link pickAutoOption})
1167
+ * and the agent CONTINUES. A mode that cannot pick an option (no options, or
1168
+ * none with a usable id) degrades to refusing: safer than guessing, and
1169
+ * hanging is worse than either.
1170
+ */
1171
+ settleExpiredAsk(session, mode, toolCall, options, timeoutMs) {
1172
+ const outcome = this.announceExpiredAsk(session, mode, toolCall, options, timeoutMs);
1173
+ return outcome.outcome === 'selected'
1174
+ ? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
1175
+ : { outcome: { outcome: 'cancelled' } };
1226
1176
  }
1227
1177
  settlePermissions(session) {
1228
1178
  this.permissions.settleFor(p => p.sessionId === session.sessionId);
@@ -1259,7 +1209,7 @@ export class AgentBridge {
1259
1209
  * cannot reach into the agent's own process, so a second gate here would only
1260
1210
  * be a fiction in front of a tool that can write without asking us at all.
1261
1211
  * What the agent chooses to ask about goes to the human unchanged
1262
- * (`askNativeApproval`). See docs/acp-convergence.md §2.3.
1212
+ * (`handlePermissionRequest`). See docs/acp-convergence.md §2.3.
1263
1213
  */
1264
1214
  async handleFsWrite(params) {
1265
1215
  const session = this.acpIndex.get(params.sessionId);
@@ -1361,20 +1311,57 @@ export function permissionToolName(toolCall) {
1361
1311
  return row.kind;
1362
1312
  return 'tool call';
1363
1313
  }
1314
+ /** Longest reason line the approval card headline carries. */
1315
+ const APPROVAL_REASON_MAX = 240;
1316
+ /** Input keys that name the operation in one string, most specific first. */
1317
+ const APPROVAL_REASON_INPUT_KEYS = [
1318
+ 'command',
1319
+ 'cmd',
1320
+ 'path',
1321
+ 'file_path',
1322
+ 'filePath',
1323
+ 'pattern',
1324
+ 'query',
1325
+ 'url',
1326
+ 'description',
1327
+ ];
1364
1328
  /**
1365
- * Translate a native approval outcome into the ACP option the agent must see.
1366
- * `'allowed-once'` is the chain's only grant; a rejection prefers the once-variant.
1329
+ * One line saying WHAT the permission ask is about.
1330
+ *
1331
+ * WHY: without it the human only sees the tool's NAME ("Bash") and cannot
1332
+ * judge the request; the ACP tool call already declares the missing half — its
1333
+ * title and (for execute/edit tools) a short scalar in `rawInput`. Only ONE declared
1334
+ * string is used, never a serialized object or a whole file, and the line is
1335
+ * capped so a pathological input cannot fill the card or the audit log.
1367
1336
  */
1368
- function nativeOutcomeResponse(outcome, options) {
1369
- if (outcome === 'allowed-once') {
1370
- const allow = options.find(o => o.kind === 'allow_once') ?? options.find(o => o.kind === 'allow_always');
1371
- return allow === undefined ? { outcome: { outcome: 'cancelled' } } : { outcome: { outcome: 'selected', optionId: allow.optionId } };
1372
- }
1373
- if (outcome === 'rejected') {
1374
- const reject = options.find(o => o.kind === 'reject_once') ?? options.find(o => o.kind === 'reject_always');
1375
- return reject === undefined ? { outcome: { outcome: 'cancelled' } } : { outcome: { outcome: 'selected', optionId: reject.optionId } };
1376
- }
1377
- return { outcome: { outcome: 'cancelled' } };
1337
+ export function permissionAskReason(toolCall) {
1338
+ const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
1339
+ const title = permissionToolName(toolCall);
1340
+ const raw = row.rawInput;
1341
+ const input = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
1342
+ ? raw
1343
+ : typeof raw === 'string' && raw.length <= APPROVAL_REASON_MAX * 4
1344
+ // Some agents send the arguments as a JSON string; read one scalar out
1345
+ // of it instead of echoing the blob.
1346
+ ? (() => {
1347
+ try {
1348
+ const parsed = JSON.parse(raw);
1349
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
1350
+ ? parsed
1351
+ : undefined;
1352
+ }
1353
+ catch {
1354
+ return undefined;
1355
+ }
1356
+ })()
1357
+ : undefined;
1358
+ const detail = input === undefined
1359
+ ? undefined
1360
+ : APPROVAL_REASON_INPUT_KEYS
1361
+ .map(key => input[key])
1362
+ .find((value) => typeof value === 'string' && value !== '');
1363
+ const line = detail === undefined || detail === title ? title : `${title} · ${detail}`;
1364
+ return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line;
1378
1365
  }
1379
1366
  function snapshot(session) {
1380
1367
  return {
@@ -2077,7 +2064,6 @@ export function releaseAllToolOutputWaiters() {
2077
2064
  class TurnStreamController {
2078
2065
  manager;
2079
2066
  hub;
2080
- bridge;
2081
2067
  session;
2082
2068
  turnId;
2083
2069
  endTurn;
@@ -2092,12 +2078,11 @@ class TurnStreamController {
2092
2078
  pendingProse = [];
2093
2079
  sawVisibleBlock = false;
2094
2080
  cursorSeq;
2095
- constructor(manager, hub, bridge, session, turnId,
2081
+ constructor(manager, hub, session, turnId,
2096
2082
  /** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
2097
2083
  endTurn, rollbackFirstTurn) {
2098
2084
  this.manager = manager;
2099
2085
  this.hub = hub;
2100
- this.bridge = bridge;
2101
2086
  this.session = session;
2102
2087
  this.turnId = turnId;
2103
2088
  this.endTurn = endTurn;
@@ -2319,24 +2304,36 @@ class TurnStreamController {
2319
2304
  }
2320
2305
  else if (row.type === 'permission/request') {
2321
2306
  const typed = event;
2322
- const seg = this.segment;
2323
- const line = this.permissionLine(typed);
2324
- if (seg === undefined || seg.finished) {
2325
- if (line !== '')
2326
- this.pendingProse.push(line);
2327
- return;
2328
- }
2329
- if (seg.hasCard) {
2330
- // The card must stay the segment's last block: park the line for the
2331
- // next segment instead of streaming it below the card.
2332
- this.pendingProse.push(line);
2333
- this.finishSegment(seg, { kind: 'stop' });
2334
- return;
2335
- }
2336
- if (line !== '')
2337
- this.emitProse(seg, 'reasoning', line);
2307
+ this.pushProse(this.permissionLine(typed));
2308
+ }
2309
+ else if (row.type === 'permission/expired') {
2310
+ const typed = event;
2311
+ this.pushProse(this.expiredLine(typed));
2338
2312
  }
2339
2313
  }
2314
+ /**
2315
+ * Put one narrative line into the turn's prose stream.
2316
+ *
2317
+ * A card-bearing segment must stay the segment's LAST block, so a line that
2318
+ * arrives while a card is open is parked for the next segment instead of
2319
+ * streaming below the card. A line that arrives between segments waits for
2320
+ * the next one to open.
2321
+ */
2322
+ pushProse(line) {
2323
+ if (line === '')
2324
+ return;
2325
+ const seg = this.segment;
2326
+ if (seg === undefined || seg.finished) {
2327
+ this.pendingProse.push(line);
2328
+ return;
2329
+ }
2330
+ if (seg.hasCard) {
2331
+ this.pendingProse.push(line);
2332
+ this.finishSegment(seg, { kind: 'stop' });
2333
+ return;
2334
+ }
2335
+ this.emitProse(seg, 'reasoning', line);
2336
+ }
2340
2337
  /** Events that arrive between segments: recorded, replayed into the next one. */
2341
2338
  recordWithoutSegment(update) {
2342
2339
  const kind = String(update.sessionUpdate ?? '');
@@ -2471,30 +2468,46 @@ class TurnStreamController {
2471
2468
  });
2472
2469
  }
2473
2470
  permissionLine(event) {
2474
- const title = typeof event.toolCall?.title === 'string'
2475
- ? String(event.toolCall.title)
2476
- : 'permission';
2477
- // The line must not point at a place with no answer entry, and the bridge
2478
- // knows which path this ask took (`ledgerFallbackOf`, recorded before the
2479
- // frame was broadcast). On the DESIGNED ledger paths the settings panel's
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.
2484
- const fallback = this.bridge.ledgerFallbackOf(event.requestId);
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`;
2471
+ // `toolName` is host-derived (`permissionToolName`) so agents that omit
2472
+ // `title` and declare it in `_meta` still name the operation here.
2473
+ const title = event.toolName;
2474
+ // The line must point at the place the answer entry actually is: a
2475
+ // chat-bound ask renders its card in the conversation; an `/acp`-RPC ask
2476
+ // has no conversation, so its card lives on the settings page.
2477
+ const lead = event.dshSessionId !== undefined
2478
+ ? 'please answer the permission card above the composer'
2479
+ : 'please approve in Settings';
2480
+ // The last clause says what happens if nobody answers, and it follows the
2481
+ // MODE: refused, answered automatically, or (in `wait`) nothing at all.
2482
+ if (event.timeoutMode === 'wait' || event.deadlineAt === undefined) {
2483
+ return `\n> 🔐 [Permission Required: ${title}] - ${lead}\n`;
2484
+ }
2485
+ const remainingMs = Math.max(0, event.deadlineAt - Date.now());
2486
+ // Floor (+1 ms for the Date.now tick), min 1: rounding up would promise time already spent.
2487
+ const remainingS = Math.max(1, Math.floor((remainingMs + 1) / 1000));
2488
+ const otherwise = event.timeoutMode === 'auto'
2489
+ ? 'this request is answered automatically'
2490
+ : 'this request is cancelled';
2491
+ return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(remainingS)}s or ${otherwise}\n`;
2492
+ }
2493
+ /**
2494
+ * The line for an ask the plugin settled itself when its deadline fired.
2495
+ *
2496
+ * The composer card is disappearing, so the conversation has to say what
2497
+ * happened to the question — otherwise the user is left with a vanished
2498
+ * card and no explanation. The wording follows the outcome: refused, or
2499
+ * answered with the option the agent's own kinds implied.
2500
+ */
2501
+ expiredLine(event) {
2502
+ const title = permissionToolName(event.toolCall);
2503
+ const waited = event.timeoutMs > 0
2504
+ ? `no answer within ${String(Math.round(event.timeoutMs / 1000))}s`
2505
+ : 'not waited for';
2506
+ if (event.outcome.outcome === 'selected') {
2507
+ const picked = event.optionName === undefined ? event.outcome.optionId : event.optionName;
2508
+ return `\n> 🔐 [Permission Required: ${title}] - ${waited}; answered "${picked}" automatically, continuing\n`;
2509
+ }
2510
+ return `\n> 🔐 [Permission Required: ${title}] - ${waited}; refused\n`;
2498
2511
  }
2499
2512
  }
2500
2513
  /** Chunk text shared by the controller's fold (string / object / array forms). */
@@ -2539,17 +2552,23 @@ export class BridgeManager {
2539
2552
  /** The agent tag's halo preference (persisted; see `persistAgents`). */
2540
2553
  tagHaloSetting = { ...DEFAULT_TAG_HALO };
2541
2554
  /**
2542
- * What `cordis.patch.yml` asked for, by key — ONLY the keys it actually
2543
- * names. This is the deployment baseline, kept apart from
2555
+ * What `cordis.patch.yml` asked for — ONLY if it actually named the key.
2556
+ * This is the deployment baseline, kept apart from
2544
2557
  * {@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.
2558
+ * the two is winning. Empty when the file says nothing about the window, in
2559
+ * which case {@link baselineDeadline} (the built-in default) applies.
2547
2560
  */
2548
2561
  configuredDeadlines = {};
2549
2562
  /**
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.
2563
+ * The mode the config file named, if it named one. Kept apart from the
2564
+ * override for the same reason the window is: the card has to be able
2565
+ * to say "the file asks for `abort`, the value set here wins".
2566
+ */
2567
+ configuredMode;
2568
+ /**
2569
+ * The window before any override — the tuning this manager was constructed
2570
+ * with, which already carries the validated config file (the host entry
2571
+ * passes `validateConfig`'s output) or the built-in default.
2553
2572
  *
2554
2573
  * Held separately from {@link configuredDeadlines} because the two answer
2555
2574
  * different questions: this one is what RUNS when nothing is overridden, that
@@ -2557,17 +2576,23 @@ export class BridgeManager {
2557
2576
  * and a bare default does not deserve). A manager built without tuning at all
2558
2577
  * — a direct consumer, a test — gets `DEFAULT_TUNING` here and stays correct.
2559
2578
  */
2560
- baselineDeadlines;
2579
+ baselineDeadline;
2580
+ /**
2581
+ * The mode before any override — the tuning this manager was constructed
2582
+ * with, which already carries the validated config file (the host entry
2583
+ * passes `validateConfig`'s output) or the built-in default.
2584
+ */
2585
+ baselineMode;
2561
2586
  /**
2562
2587
  * 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
2588
+ * OVER {@link baselineDeadline}: once a user picks a window in the UI, that
2564
2589
  * pick is what runs, and the config file becomes the value the reset button
2565
2590
  * returns to. Without this split a hand-edited `cordis.patch.yml` and a UI
2566
2591
  * write would fight with no way to tell which one is live.
2567
2592
  */
2568
2593
  deadlineOverride = {};
2569
2594
  /**
2570
- * Tuning in force, defaults applied. The two approval deadlines are rewritten
2595
+ * Tuning in force, defaults applied. The approval deadline is rewritten
2571
2596
  * IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
2572
2597
  * rather than the constructor's parameter: every bridge built afterwards
2573
2598
  * inherits the rewrite instead of the boot-time value.
@@ -2624,13 +2649,11 @@ export class BridgeManager {
2624
2649
  // `applyDeadlineTuning`), and every bridge built afterwards must inherit
2625
2650
  // the rewrite instead of the boot-time value.
2626
2651
  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
- };
2652
+ // The pre-override deadline value, snapshotted before anything can rewrite
2653
+ // it. Everything else in `tuning` is fixed for the manager's lifetime, so
2654
+ // only this needs a snapshot.
2655
+ this.baselineDeadline = this.tuning.permissionTimeoutMs;
2656
+ this.baselineMode = this.tuning.permissionTimeoutMode;
2634
2657
  for (const def of defs) {
2635
2658
  this.defsMap.set(def.id, def);
2636
2659
  }
@@ -2651,27 +2674,13 @@ export class BridgeManager {
2651
2674
  routeBindingRegistry().defaultModel = this.defaultAgent();
2652
2675
  }
2653
2676
  makeBridge(def, onConnectionLost, onCatalog, onCommands) {
2654
- return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.approvalRequester, () => this.sessionWorkspaceResolver);
2655
- }
2656
- /**
2657
- * Route ACP permission asks through the host approval service (answered in
2658
- * the chat composer). Absent → every ask stays on the settings-panel ledger.
2659
- * Setting it also flips {@link approvalRouting}, which the browser reads to
2660
- * stop rendering the settings-panel permission card.
2661
- */
2662
- setApprovalRequester(requester) {
2663
- this.approvalRequester = requester;
2664
- }
2665
- /** True when permission asks go to native approval instead of the settings panel. */
2666
- approvalRouting() {
2667
- return this.approvalRequester !== undefined;
2677
+ return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver);
2668
2678
  }
2669
2679
  /**
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.
2680
+ * Every outstanding ask, across all agents, with the view the browser was
2681
+ * shown it — the source of the reconnect replay (see
2682
+ * `permissionReplayFrames`). Every ACP ask is a ledger entry, so this is the
2683
+ * whole live set.
2675
2684
  */
2676
2685
  pendingPermissionViews() {
2677
2686
  const views = [];
@@ -3469,7 +3478,31 @@ export class BridgeManager {
3469
3478
  // validator accepts must be the value that runs (a UI write that did
3470
3479
  // not survive a restart would read as a broken setting).
3471
3480
  try {
3472
- this.deadlineOverride = validateApprovalDeadlines(parsed.approvalDeadlines ?? {});
3481
+ const raw = parsed.approvalDeadlines ?? {};
3482
+ // The OLD card wrote the two legacy windows, and its 「关」 preset
3483
+ // wrote a zero — which then meant "this window never fires". The only
3484
+ // way to say that now is the `wait` MODE (a zero means 不等待 today),
3485
+ // so a legacy zero carries over as the mode instead of silently
3486
+ // turning "wait forever" into "answer immediately".
3487
+ const legacy = raw;
3488
+ const legacyWindow = legacy.nativeApprovalTimeoutMs ?? legacy.permissionFallbackTimeoutMs;
3489
+ const legacyNever = legacyWindow === 0;
3490
+ const window = legacyNever
3491
+ ? undefined
3492
+ : validateApprovalDeadlines(raw).permissionTimeoutMs;
3493
+ const mode = legacyNever
3494
+ ? 'wait'
3495
+ : (raw.mode === undefined
3496
+ ? undefined
3497
+ : validatePermissionTimeoutMode(raw.mode, 'bad_payload'));
3498
+ // A stored window must be legal for the mode it runs under: 不等待
3499
+ // (0) belongs to `auto`; under `abort` it would refuse every ask on
3500
+ // arrival, so it floors to the shortest real wait.
3501
+ const effectiveMode = mode ?? this.baselineMode;
3502
+ this.deadlineOverride = {
3503
+ ...(window === undefined ? {} : { permissionTimeoutMs: normalizeApprovalWindow(effectiveMode, window) }),
3504
+ ...(mode === undefined ? {} : { mode }),
3505
+ };
3473
3506
  }
3474
3507
  catch {
3475
3508
  this.deadlineOverride = {};
@@ -4527,7 +4560,7 @@ export class BridgeManager {
4527
4560
  this.persistAgents();
4528
4561
  }
4529
4562
  /**
4530
- * Declare the deployment baseline for the two approval deadlines — the values
4563
+ * Declare the deployment baseline for the approval deadline — the values
4531
4564
  * `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
4532
4565
  *
4533
4566
  * Kept separate from the validated tuning because "the file says 600000" and
@@ -4537,36 +4570,46 @@ export class BridgeManager {
4537
4570
  * also moves the baseline, so a manager built without tuning (a direct
4538
4571
  * consumer, a test) still runs what the deployment asked for.
4539
4572
  */
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;
4573
+ setConfiguredApprovalDeadlines(values, mode) {
4574
+ // The baseline must be legal for the mode it runs under, or the card would
4575
+ // advertise a number the deployment cannot actually run (不等待 under
4576
+ // `abort`) and the reset button would restore it.
4577
+ const effectiveMode = mode ?? this.configuredMode ?? this.baselineMode;
4578
+ const raw = values.permissionTimeoutMs;
4579
+ const window = raw === undefined ? undefined : normalizeApprovalWindow(effectiveMode, raw);
4580
+ this.configuredDeadlines = window === undefined ? {} : { permissionTimeoutMs: window };
4581
+ if (window !== undefined)
4582
+ this.baselineDeadline = window;
4583
+ if (mode !== undefined) {
4584
+ this.configuredMode = mode;
4585
+ this.baselineMode = mode;
4586
+ }
4546
4587
  this.applyDeadlineTuning();
4547
4588
  }
4548
4589
  /**
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.
4590
+ * The approval deadline in force, plus the facts that explain a value the
4591
+ * user did not pick: the config-file baseline an override is outranking. The
4592
+ * mode rides along with its own baseline, for the same reason: the card has
4593
+ * to be able to say "the file asks for `abort`, the value set here wins".
4594
+ *
4595
+ * Everything is reported only when it is actually in play, so the card can
4596
+ * stay silent about a plain default.
4554
4597
  */
4555
4598
  approvalDeadlines() {
4556
4599
  const deadlines = {
4557
- nativeApprovalTimeoutMs: this.tuning.nativeApprovalTimeoutMs,
4558
- permissionFallbackTimeoutMs: this.tuning.permissionFallbackTimeoutMs,
4600
+ permissionTimeoutMs: this.tuning.permissionTimeoutMs,
4601
+ mode: this.tuning.permissionTimeoutMode,
4559
4602
  };
4560
4603
  if (Object.keys(this.configuredDeadlines).length > 0)
4561
4604
  deadlines.configured = { ...this.configuredDeadlines };
4562
- if (this.tuning.permissionTimeoutMs > 0)
4563
- deadlines.permissionTimeoutMs = this.tuning.permissionTimeoutMs;
4605
+ if (this.configuredMode !== undefined)
4606
+ deadlines.configuredMode = this.configuredMode;
4564
4607
  return deadlines;
4565
4608
  }
4566
4609
  /**
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.
4610
+ * Write the approval deadline, or the mode (omitted keys keep their value),
4611
+ * or drop the persisted override with `reset` so the config-file baseline —
4612
+ * or the built-in default — applies again.
4570
4613
  *
4571
4614
  * HOT: the rewrite reaches every live bridge, so a user changing the window
4572
4615
  * never has to kill a running session to see it take effect (the one thing an
@@ -4577,25 +4620,35 @@ export class BridgeManager {
4577
4620
  async setApprovalDeadlines(patch, reset = false) {
4578
4621
  // Merged BEFORE validating, so the whole resulting override passes the same
4579
4622
  // 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.
4623
+ // re-gated here rather than trusted). The validators also drop unknown
4624
+ // keys, which is what keeps a stale payload from poisoning state.
4582
4625
  const merged = reset ? {} : { ...this.deadlineOverride, ...patch };
4583
- this.deadlineOverride = validateApprovalDeadlines(merged);
4626
+ const window = validateApprovalDeadlines(merged).permissionTimeoutMs;
4627
+ const mode = merged.mode === undefined
4628
+ ? undefined
4629
+ : validatePermissionTimeoutMode(merged.mode, 'bad_payload');
4630
+ // The stored override must be legal for the mode it runs under, not just
4631
+ // legal on its own: 不等待 (0) belongs to `auto`, and a mode switch can
4632
+ // leave it behind. Flooring it HERE (not only in the running value) keeps
4633
+ // the card's number and the running number the same number.
4634
+ const effectiveMode = mode ?? this.baselineMode;
4635
+ this.deadlineOverride = {
4636
+ ...(window === undefined ? {} : { permissionTimeoutMs: normalizeApprovalWindow(effectiveMode, window) }),
4637
+ ...(mode === undefined ? {} : { mode }),
4638
+ };
4584
4639
  this.applyDeadlineTuning();
4585
4640
  this.persistAgents();
4586
4641
  }
4587
4642
  /**
4588
- * Recompute the two deadlines from override → deployment baseline, and push
4589
- * the result to every live bridge.
4643
+ * Recompute the deadline and the mode from override → deployment baseline,
4644
+ * and push the result to every live bridge.
4590
4645
  */
4591
4646
  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 };
4647
+ const permissionTimeoutMode = this.deadlineOverride.mode ?? this.baselineMode;
4648
+ const permissionTimeoutMs = normalizeApprovalWindow(permissionTimeoutMode, this.deadlineOverride.permissionTimeoutMs ?? this.baselineDeadline);
4649
+ this.tuning.permissionTimeoutMs = permissionTimeoutMs;
4650
+ this.tuning.permissionTimeoutMode = permissionTimeoutMode;
4651
+ const patch = { permissionTimeoutMs, mode: permissionTimeoutMode };
4599
4652
  for (const bridge of this.bridges.values())
4600
4653
  bridge.applyApprovalDeadlines(patch);
4601
4654
  }
@@ -4615,11 +4668,6 @@ export class BridgeManager {
4615
4668
  this.dshSessionMap.delete(key);
4616
4669
  }
4617
4670
  }
4618
- /**
4619
- * Latest native-approval requester. Held as a field (read through a thunk by
4620
- * each bridge) so a requester wired after bridges were built still applies.
4621
- */
4622
- approvalRequester;
4623
4671
  sessionWorkspaceResolver;
4624
4672
  acpModeResolver;
4625
4673
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
@@ -4754,7 +4802,7 @@ export class BridgeManager {
4754
4802
  if (existing !== undefined && existing.released) {
4755
4803
  const resumed = await this.bridge(agentId).resumeSession(existing);
4756
4804
  // Rebind explicitly: a resume keeps the record's identity, but the chat
4757
- // session id is what native approval routing keys off.
4805
+ // session id is what the conversation's permission card addresses.
4758
4806
  resumed.dshSessionId = dshSessionId;
4759
4807
  // A resume without an explicit pick must keep serving the agent the
4760
4808
  // conversation was ON (its own model string when one was recorded). The
@@ -4792,7 +4840,7 @@ export class BridgeManager {
4792
4840
  // `ensureReady` for why `session/new`'s cwd alone is not enough.
4793
4841
  workspaceRoot);
4794
4842
  // Absent for `/acp`-RPC sessions and for adapter calls without a chat
4795
- // session; those deliberately keep the ledger path (see askNativeApproval).
4843
+ // session; their permission cards are addressed by the ACP session only.
4796
4844
  session.dshSessionId = dshSessionId;
4797
4845
  this.dshSessionMap.set(key, session);
4798
4846
  // Turn the displayed defaults into this conversation's pick, so the first
@@ -5072,7 +5120,7 @@ export class BridgeManager {
5072
5120
  // loop then executes the card and proposes the continuation step, which
5073
5121
  // resumes the SAME prompt through the branch above.
5074
5122
  const endTurn = bridge.beginTurn(session);
5075
- const controller = new TurnStreamController(this, this.hub, bridge, session, turnId, endTurn, victim => this.rollbackFirstTurn(victim));
5123
+ const controller = new TurnStreamController(this, this.hub, session, turnId, endTurn, victim => this.rollbackFirstTurn(victim));
5076
5124
  if (session.dshSessionId !== undefined)
5077
5125
  this.rememberTurnStream(session.dshSessionId, controller);
5078
5126
  const segment = controller.startSegment();