dsh-ntr 0.1.6 → 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, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } 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`. */
@@ -89,7 +89,6 @@ export class AgentBridge {
89
89
  onConnectionLost;
90
90
  onCatalog;
91
91
  onCommands;
92
- approvalRequester;
93
92
  sessionWorkspace;
94
93
  state = 'stopped';
95
94
  initResult;
@@ -105,15 +104,6 @@ export class AgentBridge {
105
104
  /** Inflight `session/load` resumes, shared so concurrent prompts don't double-resume. */
106
105
  resuming = new Map();
107
106
  permissions = new PermissionLedger();
108
- /**
109
- * Why each outstanding ledger ask is on the ledger, keyed by request id and
110
- * recorded before the `permission/request` broadcast (the hub is
111
- * synchronous, so a subscriber can read it the moment the frame goes out).
112
- * Entries live exactly as long as the ask: the ledger's resolve callback
113
- * deletes them, and every terminal path (answer, timeout, session close,
114
- * process death) resolves through it.
115
- */
116
- ledgerFallbacks = new Map;
117
107
  startupTimeoutMs;
118
108
  /**
119
109
  * The ONE approval deadline, and the mode it settles with. Neither is
@@ -173,13 +163,8 @@ export class AgentBridge {
173
163
  /** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
174
164
  onCommands, tuning,
175
165
  /**
176
- * Resolved per ask (not captured) so a requester wired after this bridge
177
- * was built still applies. Absent → every ask rides the ledger.
178
- */
179
- approvalRequester,
180
- /**
181
- * Resolved per turn, like {@link approvalRequester}: the host entry may
182
- * 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.
183
168
  */
184
169
  sessionWorkspace) {
185
170
  this.def = def;
@@ -188,7 +173,6 @@ export class AgentBridge {
188
173
  this.onConnectionLost = onConnectionLost;
189
174
  this.onCatalog = onCatalog;
190
175
  this.onCommands = onCommands;
191
- this.approvalRequester = approvalRequester;
192
176
  this.sessionWorkspace = sessionWorkspace;
193
177
  const merged = { ...DEFAULT_TUNING, ...tuning };
194
178
  this.startupTimeoutMs = merged.startupTimeoutMs;
@@ -970,12 +954,15 @@ export class AgentBridge {
970
954
  // Closing a session is activity of its own: restart the idle window here
971
955
  // rather than at the prompt that opened it.
972
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);
973
961
  const conn = this.conn;
974
962
  if (conn !== undefined && this.state === 'ready') {
975
963
  // `session/close` is an optional capability; the local teardown stands regardless.
976
964
  await conn.agent.request(methods.agent.session.close, { sessionId: session.acpSessionId }).catch(() => { });
977
965
  }
978
- this.settlePermissions(session);
979
966
  this.sessions.delete(session.sessionId);
980
967
  this.acpIndex.delete(session.acpSessionId);
981
968
  this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason });
@@ -1040,30 +1027,27 @@ export class AgentBridge {
1040
1027
  }, 3000).unref();
1041
1028
  }
1042
1029
  /**
1043
- * Answer one ACP permission ask. Every ask reaches a human: the native
1044
- * approval chain first (answered in the chat composer), the settings-panel
1045
- * 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.
1046
1044
  */
1047
1045
  async handlePermissionRequest(acpSessionId, toolCall, options, signal) {
1048
1046
  const session = this.acpIndex.get(acpSessionId);
1049
1047
  if (session === undefined || session.closed)
1050
1048
  return { outcome: { outcome: 'cancelled' } };
1051
- // Native approval first (answered in the chat composer); a `reason` means
1052
- // no route is available and the settings-panel ledger takes over.
1053
- const native = await this.askNativeApproval(session, toolCall, options, signal);
1054
- if ('response' in native) {
1055
- this.touch();
1056
- return native.response;
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.
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.
1067
1051
  const mode = this.permissionTimeoutMode;
1068
1052
  const timeoutMs = this.permissionTimeoutMs;
1069
1053
  // 不等待(0)只属自动推进:请求到达即按那一档收场,不建 ledger 条目、不发卡
@@ -1072,11 +1056,28 @@ export class AgentBridge {
1072
1056
  if (mode === 'auto' && timeoutMs === 0) {
1073
1057
  return this.settleExpiredAsk(session, mode, toolCall, options, 0);
1074
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
+ };
1075
1075
  return new Promise(resolve => {
1076
1076
  let timer;
1077
+ let detachSignal;
1077
1078
  const requestId = this.permissions.create(session.sessionId, (outcome) => {
1078
1079
  clearTimeout(timer);
1079
- this.ledgerFallbacks.delete(requestId);
1080
+ detachSignal?.();
1080
1081
  this.touch();
1081
1082
  this.hub.broadcast({
1082
1083
  type: 'permission/resolved',
@@ -1087,194 +1088,52 @@ export class AgentBridge {
1087
1088
  resolve(outcome.outcome === 'selected'
1088
1089
  ? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
1089
1090
  : { 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 })),
1095
- });
1096
- // Recorded before the broadcast: the hub is synchronous, and the chat
1097
- // line `streamPrompt` folds from this frame words itself by the path
1098
- // this ask actually took.
1099
- this.ledgerFallbacks.set(requestId, { reason: native.reason, timeoutMs, mode });
1100
- if (timeoutMs > 0 && mode !== 'wait') {
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) {
1101
1096
  timer = setTimeout(() => {
1102
1097
  if (!this.permissions.has(requestId))
1103
1098
  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);
1108
- this.hub.broadcast({
1109
- type: 'agent/notice',
1110
- agentId: this.def.id,
1111
- level: 'info',
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}"`,
1118
- });
1119
- this.permissions.respond(requestId, picked === undefined
1120
- ? { outcome: 'cancelled' }
1121
- : { outcome: 'selected', optionId: picked.optionId });
1122
- }, 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()));
1123
1104
  timer.unref?.();
1124
1105
  }
1125
- this.hub.broadcast({
1126
- type: 'permission/request',
1127
- requestId,
1128
- sessionId: session.sessionId,
1129
- toolCall,
1130
- options: options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
1131
- });
1132
- });
1133
- }
1134
- /**
1135
- * Why one ledger ask is on the ledger, as recorded by
1136
- * {@link handlePermissionRequest}. The manager's `streamPrompt` reads it to
1137
- * word the chat line by the path the ask took; `undefined` once the ask is
1138
- * answered, timed out, or settled.
1139
- */
1140
- ledgerFallbackOf(requestId) {
1141
- return this.ledgerFallbacks.get(requestId);
1142
- }
1143
- /**
1144
- * Ask the host approval service for this permission (`ctx.approval` → the
1145
- * browser's composer takeover). Only sessions bound to a DSH chat session
1146
- * qualify: the service appends its audit pair to that session inside an
1147
- * open turn, so a session started over `/acp` (no chat turn) cannot be
1148
- * routed and must stay on the ledger path.
1149
- *
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.
1163
- *
1164
- * @returns the agent-facing response, or the reason the ask must fall back
1165
- * to the ledger (see {@link LedgerFallbackReason}).
1166
- */
1167
- async askNativeApproval(session, toolCall, options, signal) {
1168
- const requester = this.approvalRequester?.();
1169
- if (requester === undefined)
1170
- return { reason: 'no-requester' };
1171
- if (session.dshSessionId === undefined)
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;
1204
- let outcome;
1205
- try {
1206
- const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
1207
- const asked = requester({
1208
- dshSessionId: session.dshSessionId,
1209
- agentId: this.def.id,
1210
- toolName: permissionToolName(toolCall),
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,
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) };
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); };
1229
1121
  }
1230
- outcome = settled.outcome;
1231
- }
1232
- catch {
1233
- // Synchronous throw or rejection, either way: the ask falls back to the
1234
- // ledger. An approval failure must never break the agent's turn.
1235
- outcome = 'unavailable';
1236
- }
1237
- finally {
1238
- clearTimeout(timer);
1239
- }
1240
- if (outcome === 'unavailable')
1241
- return { reason: 'unavailable' };
1242
- const response = nativeOutcomeResponse(outcome, options);
1243
- // Keep the event stream symmetric with the ledger path: a routed decision
1244
- // is still a resolution, and the browser uses it to drop any card it may
1245
- // hold for this session. No `permission/request` is broadcast — the
1246
- // settings panel must never show a card for a composer-routed ask.
1247
- this.hub.broadcast({
1248
- type: 'permission/resolved',
1249
- requestId: randomUUID(),
1250
- sessionId: session.sessionId,
1251
- outcome: response.outcome.outcome === 'selected'
1252
- ? { outcome: 'selected', optionId: response.outcome.optionId }
1253
- : { outcome: 'cancelled' },
1254
1122
  });
1255
- return { response };
1256
1123
  }
1257
1124
  /**
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.
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.
1270
1129
  */
1271
- settleExpiredAsk(session, mode, toolCall, options, timeoutMs) {
1130
+ announceExpiredAsk(session, mode, toolCall, options, timeoutMs) {
1272
1131
  const picked = mode === 'auto'
1273
1132
  ? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
1274
1133
  : undefined;
1275
- const response = picked === undefined
1276
- ? { outcome: { outcome: 'cancelled' } }
1277
- : { outcome: { outcome: 'selected', optionId: picked.optionId } };
1134
+ const outcome = picked === undefined
1135
+ ? { outcome: 'cancelled' }
1136
+ : { outcome: 'selected', optionId: picked.optionId };
1278
1137
  const tool = permissionToolName(toolCall);
1279
1138
  // 不等待(0)不是「等了 0 秒」,是没等:两种说法的区别就在这里。
1280
1139
  const waited = timeoutMs > 0
@@ -1291,19 +1150,29 @@ export class AgentBridge {
1291
1150
  ? `acp: permission request for "${tool}" ${waited}; refused`
1292
1151
  : `acp: permission request for "${tool}" ${waited}; answered "${picked.name}"`,
1293
1152
  });
1294
- // The conversation's only trace of the expiry: the composer card is going
1295
- // away and nothing else says why.
1296
1153
  this.hub.broadcast({
1297
1154
  type: 'permission/expired',
1298
1155
  sessionId: session.sessionId,
1299
1156
  toolCall,
1300
1157
  timeoutMs,
1301
- outcome: response.outcome.outcome === 'selected'
1302
- ? { outcome: 'selected', optionId: response.outcome.optionId }
1303
- : { outcome: 'cancelled' },
1158
+ outcome,
1304
1159
  ...(picked === undefined ? {} : { optionName: picked.name }),
1305
1160
  });
1306
- 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' } };
1307
1176
  }
1308
1177
  settlePermissions(session) {
1309
1178
  this.permissions.settleFor(p => p.sessionId === session.sessionId);
@@ -1340,7 +1209,7 @@ export class AgentBridge {
1340
1209
  * cannot reach into the agent's own process, so a second gate here would only
1341
1210
  * be a fiction in front of a tool that can write without asking us at all.
1342
1211
  * What the agent chooses to ask about goes to the human unchanged
1343
- * (`askNativeApproval`). See docs/acp-convergence.md §2.3.
1212
+ * (`handlePermissionRequest`). See docs/acp-convergence.md §2.3.
1344
1213
  */
1345
1214
  async handleFsWrite(params) {
1346
1215
  const session = this.acpIndex.get(params.sessionId);
@@ -1457,12 +1326,11 @@ const APPROVAL_REASON_INPUT_KEYS = [
1457
1326
  'description',
1458
1327
  ];
1459
1328
  /**
1460
- * One line saying WHAT the native approval card is about.
1329
+ * One line saying WHAT the permission ask is about.
1461
1330
  *
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
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
1466
1334
  * string is used, never a serialized object or a whole file, and the line is
1467
1335
  * capped so a pathological input cannot fill the card or the audit log.
1468
1336
  */
@@ -1495,21 +1363,6 @@ export function permissionAskReason(toolCall) {
1495
1363
  const line = detail === undefined || detail === title ? title : `${title} · ${detail}`;
1496
1364
  return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line;
1497
1365
  }
1498
- /**
1499
- * Translate a native approval outcome into the ACP option the agent must see.
1500
- * `'allowed-once'` is the chain's only grant; a rejection prefers the once-variant.
1501
- */
1502
- function nativeOutcomeResponse(outcome, options) {
1503
- if (outcome === 'allowed-once') {
1504
- const allow = options.find(o => o.kind === 'allow_once') ?? options.find(o => o.kind === 'allow_always');
1505
- return allow === undefined ? { outcome: { outcome: 'cancelled' } } : { outcome: { outcome: 'selected', optionId: allow.optionId } };
1506
- }
1507
- if (outcome === 'rejected') {
1508
- const reject = options.find(o => o.kind === 'reject_once') ?? options.find(o => o.kind === 'reject_always');
1509
- return reject === undefined ? { outcome: { outcome: 'cancelled' } } : { outcome: { outcome: 'selected', optionId: reject.optionId } };
1510
- }
1511
- return { outcome: { outcome: 'cancelled' } };
1512
- }
1513
1366
  function snapshot(session) {
1514
1367
  return {
1515
1368
  sessionId: session.sessionId,
@@ -2211,7 +2064,6 @@ export function releaseAllToolOutputWaiters() {
2211
2064
  class TurnStreamController {
2212
2065
  manager;
2213
2066
  hub;
2214
- bridge;
2215
2067
  session;
2216
2068
  turnId;
2217
2069
  endTurn;
@@ -2226,12 +2078,11 @@ class TurnStreamController {
2226
2078
  pendingProse = [];
2227
2079
  sawVisibleBlock = false;
2228
2080
  cursorSeq;
2229
- constructor(manager, hub, bridge, session, turnId,
2081
+ constructor(manager, hub, session, turnId,
2230
2082
  /** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
2231
2083
  endTurn, rollbackFirstTurn) {
2232
2084
  this.manager = manager;
2233
2085
  this.hub = hub;
2234
- this.bridge = bridge;
2235
2086
  this.session = session;
2236
2087
  this.turnId = turnId;
2237
2088
  this.endTurn = endTurn;
@@ -2617,28 +2468,27 @@ class TurnStreamController {
2617
2468
  });
2618
2469
  }
2619
2470
  permissionLine(event) {
2620
- const title = typeof event.toolCall?.title === 'string'
2621
- ? String(event.toolCall.title)
2622
- : 'permission';
2623
- // The line must not point at a place with no answer entry, and the bridge
2624
- // knows which path this ask took (`ledgerFallbackOf`, recorded before the
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
- //
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';
2630
2480
  // The last clause says what happens if nobody answers, and it follows the
2631
2481
  // MODE: refused, answered automatically, or (in `wait`) nothing at all.
2632
- const fallback = this.bridge.ledgerFallbackOf(event.requestId);
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') {
2482
+ if (event.timeoutMode === 'wait' || event.deadlineAt === undefined) {
2636
2483
  return `\n> 🔐 [Permission Required: ${title}] - ${lead}\n`;
2637
2484
  }
2638
- const otherwise = fallback.mode === 'auto'
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'
2639
2489
  ? 'this request is answered automatically'
2640
2490
  : 'this request is cancelled';
2641
- return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(Math.round(fallback.timeoutMs / 1000))}s or ${otherwise}\n`;
2491
+ return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(remainingS)}s or ${otherwise}\n`;
2642
2492
  }
2643
2493
  /**
2644
2494
  * The line for an ask the plugin settled itself when its deadline fired.
@@ -2649,9 +2499,7 @@ class TurnStreamController {
2649
2499
  * answered with the option the agent's own kinds implied.
2650
2500
  */
2651
2501
  expiredLine(event) {
2652
- const title = typeof event.toolCall?.title === 'string'
2653
- ? String(event.toolCall.title)
2654
- : 'permission';
2502
+ const title = permissionToolName(event.toolCall);
2655
2503
  const waited = event.timeoutMs > 0
2656
2504
  ? `no answer within ${String(Math.round(event.timeoutMs / 1000))}s`
2657
2505
  : 'not waited for';
@@ -2826,27 +2674,13 @@ export class BridgeManager {
2826
2674
  routeBindingRegistry().defaultModel = this.defaultAgent();
2827
2675
  }
2828
2676
  makeBridge(def, onConnectionLost, onCatalog, onCommands) {
2829
- return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.approvalRequester, () => this.sessionWorkspaceResolver);
2830
- }
2831
- /**
2832
- * Route ACP permission asks through the host approval service (answered in
2833
- * the chat composer). Absent → every ask stays on the settings-panel ledger.
2834
- * Setting it also flips {@link approvalRouting}, which the browser reads to
2835
- * stop rendering the settings-panel permission card.
2836
- */
2837
- setApprovalRequester(requester) {
2838
- this.approvalRequester = requester;
2839
- }
2840
- /** True when permission asks go to native approval instead of the settings panel. */
2841
- approvalRouting() {
2842
- return this.approvalRequester !== undefined;
2677
+ return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver);
2843
2678
  }
2844
2679
  /**
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.
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.
2850
2684
  */
2851
2685
  pendingPermissionViews() {
2852
2686
  const views = [];
@@ -4834,11 +4668,6 @@ export class BridgeManager {
4834
4668
  this.dshSessionMap.delete(key);
4835
4669
  }
4836
4670
  }
4837
- /**
4838
- * Latest native-approval requester. Held as a field (read through a thunk by
4839
- * each bridge) so a requester wired after bridges were built still applies.
4840
- */
4841
- approvalRequester;
4842
4671
  sessionWorkspaceResolver;
4843
4672
  acpModeResolver;
4844
4673
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
@@ -4973,7 +4802,7 @@ export class BridgeManager {
4973
4802
  if (existing !== undefined && existing.released) {
4974
4803
  const resumed = await this.bridge(agentId).resumeSession(existing);
4975
4804
  // Rebind explicitly: a resume keeps the record's identity, but the chat
4976
- // session id is what native approval routing keys off.
4805
+ // session id is what the conversation's permission card addresses.
4977
4806
  resumed.dshSessionId = dshSessionId;
4978
4807
  // A resume without an explicit pick must keep serving the agent the
4979
4808
  // conversation was ON (its own model string when one was recorded). The
@@ -5011,7 +4840,7 @@ export class BridgeManager {
5011
4840
  // `ensureReady` for why `session/new`'s cwd alone is not enough.
5012
4841
  workspaceRoot);
5013
4842
  // Absent for `/acp`-RPC sessions and for adapter calls without a chat
5014
- // session; those deliberately keep the ledger path (see askNativeApproval).
4843
+ // session; their permission cards are addressed by the ACP session only.
5015
4844
  session.dshSessionId = dshSessionId;
5016
4845
  this.dshSessionMap.set(key, session);
5017
4846
  // Turn the displayed defaults into this conversation's pick, so the first
@@ -5291,7 +5120,7 @@ export class BridgeManager {
5291
5120
  // loop then executes the card and proposes the continuation step, which
5292
5121
  // resumes the SAME prompt through the branch above.
5293
5122
  const endTurn = bridge.beginTurn(session);
5294
- 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));
5295
5124
  if (session.dshSessionId !== undefined)
5296
5125
  this.rememberTurnStream(session.dshSessionId, controller);
5297
5126
  const segment = controller.startSegment();