dsh-ntr 0.1.6 → 0.2.1

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,
@@ -2198,6 +2051,8 @@ export function releaseAllToolOutputWaiters() {
2198
2051
  for (const waiter of [...set])
2199
2052
  waiter(undefined);
2200
2053
  }
2054
+ /** ACP `usage_update` is context occupancy, not request tokens; host usage is a 4-chars/token OUTPUT estimate (tool args excluded). */
2055
+ const OUTPUT_CHARS_PER_TOKEN = 4;
2201
2056
  /**
2202
2057
  * The state of ONE ACP prompt, shared across every driver step of the turn.
2203
2058
  *
@@ -2206,12 +2061,11 @@ export function releaseAllToolOutputWaiters() {
2206
2061
  * `transcript.since(cursor)` — events that arrive while no segment is open are
2207
2062
  * recorded, never lost), the seen-call registry (a call id gets exactly one
2208
2063
  * card per turn, wherever its restatements land), and the turn-level facts
2209
- * (`sawVisibleBlock`, usage) that outlive any single segment.
2064
+ * (`sawVisibleBlock`) that outlive any single segment.
2210
2065
  */
2211
2066
  class TurnStreamController {
2212
2067
  manager;
2213
2068
  hub;
2214
- bridge;
2215
2069
  session;
2216
2070
  turnId;
2217
2071
  endTurn;
@@ -2221,17 +2075,15 @@ class TurnStreamController {
2221
2075
  disposed = false;
2222
2076
  seenCalls = new Set();
2223
2077
  announcedKinds = new Map();
2224
- latestUsage = null;
2225
2078
  /** Narrative lines (permission asks) that arrived while no segment was open. */
2226
2079
  pendingProse = [];
2227
2080
  sawVisibleBlock = false;
2228
2081
  cursorSeq;
2229
- constructor(manager, hub, bridge, session, turnId,
2082
+ constructor(manager, hub, session, turnId,
2230
2083
  /** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
2231
2084
  endTurn, rollbackFirstTurn) {
2232
2085
  this.manager = manager;
2233
2086
  this.hub = hub;
2234
- this.bridge = bridge;
2235
2087
  this.session = session;
2236
2088
  this.turnId = turnId;
2237
2089
  this.endTurn = endTurn;
@@ -2268,6 +2120,7 @@ class TurnStreamController {
2268
2120
  index: -1,
2269
2121
  activeType: null,
2270
2122
  activeText: '',
2123
+ outputChars: 0,
2271
2124
  activeToolCall: undefined,
2272
2125
  hasCard: false,
2273
2126
  finished: false,
@@ -2394,8 +2247,10 @@ class TurnStreamController {
2394
2247
  seg.activeType = null;
2395
2248
  seg.activeToolCall = undefined;
2396
2249
  }
2397
- if (this.latestUsage !== null)
2398
- seg.queue.push({ type: 'usage', usage: this.latestUsage });
2250
+ const outputTokens = Math.round(seg.outputChars / OUTPUT_CHARS_PER_TOKEN);
2251
+ // Zero input keeps the host's cache-hit pill hidden instead of faking 0%.
2252
+ if (outputTokens > 0)
2253
+ seg.queue.push({ type: 'usage', usage: { inputTokens: 0, outputTokens } });
2399
2254
  seg.queue.push({ type: 'finish', reason });
2400
2255
  seg.queue.close();
2401
2256
  if (this.segment === seg)
@@ -2438,11 +2293,10 @@ class TurnStreamController {
2438
2293
  const seg = this.segment;
2439
2294
  if (seg === undefined || seg.finished) {
2440
2295
  // Between segments: tool outputs still reach the shared registry (the
2441
- // guard's virtual tool may be parked on this very record) and usage
2442
- // keeps the turn's tally fresh, but the cursor does NOT advance — the
2443
- // next segment replays everything since it, which re-records those
2444
- // idempotently and folds the prose the agent streamed while its cards
2445
- // executed on the host.
2296
+ // guard's virtual tool may be parked on this very record), but the
2297
+ // cursor does NOT advance — the next segment replays everything since
2298
+ // it, which re-records those idempotently and folds the prose the agent
2299
+ // streamed while its cards executed on the host.
2446
2300
  this.recordWithoutSegment(update);
2447
2301
  return;
2448
2302
  }
@@ -2486,17 +2340,8 @@ class TurnStreamController {
2486
2340
  /** Events that arrive between segments: recorded, replayed into the next one. */
2487
2341
  recordWithoutSegment(update) {
2488
2342
  const kind = String(update.sessionUpdate ?? '');
2489
- if (kind === 'usage_update') {
2490
- this.noteUsage(update);
2491
- }
2492
- else if (kind === 'tool_call' || kind === 'tool_call_update') {
2343
+ if (kind === 'tool_call' || kind === 'tool_call_update')
2493
2344
  this.noteToolRecord(update);
2494
- }
2495
- }
2496
- noteUsage(update) {
2497
- const used = typeof update.used === 'number' ? update.used : 0;
2498
- const size = typeof update.size === 'number' ? update.size : 0;
2499
- this.latestUsage = { inputTokens: size, outputTokens: used };
2500
2345
  }
2501
2346
  /** Record a tool call's output (idempotent) and wake any waiter. */
2502
2347
  noteToolRecord(update) {
@@ -2522,6 +2367,7 @@ class TurnStreamController {
2522
2367
  this.finishSegment(seg, { kind: 'stop' });
2523
2368
  return false;
2524
2369
  }
2370
+ seg.outputChars += text.length;
2525
2371
  this.emitProse(seg, kind === 'agent_thought_chunk' ? 'reasoning' : 'text', text);
2526
2372
  return true;
2527
2373
  }
@@ -2544,10 +2390,6 @@ class TurnStreamController {
2544
2390
  this.openToolCallBlock(seg, snapshot);
2545
2391
  return true;
2546
2392
  }
2547
- else if (kind === 'usage_update') {
2548
- this.noteUsage(update);
2549
- return true;
2550
- }
2551
2393
  else if (!BOOKKEEPING_UPDATES.has(kind)) {
2552
2394
  const text = describeUnhandledUpdate(kind, update);
2553
2395
  if (this.announcedKinds.get(kind) === text)
@@ -2617,28 +2459,27 @@ class TurnStreamController {
2617
2459
  });
2618
2460
  }
2619
2461
  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
- //
2462
+ // `toolName` is host-derived (`permissionToolName`) so agents that omit
2463
+ // `title` and declare it in `_meta` still name the operation here.
2464
+ const title = event.toolName;
2465
+ // The line must point at the place the answer entry actually is: a
2466
+ // chat-bound ask renders its card in the conversation; an `/acp`-RPC ask
2467
+ // has no conversation, so its card lives on the settings page.
2468
+ const lead = event.dshSessionId !== undefined
2469
+ ? 'please answer the permission card above the composer'
2470
+ : 'please approve in Settings';
2630
2471
  // The last clause says what happens if nobody answers, and it follows the
2631
2472
  // 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') {
2473
+ if (event.timeoutMode === 'wait' || event.deadlineAt === undefined) {
2636
2474
  return `\n> 🔐 [Permission Required: ${title}] - ${lead}\n`;
2637
2475
  }
2638
- const otherwise = fallback.mode === 'auto'
2476
+ const remainingMs = Math.max(0, event.deadlineAt - Date.now());
2477
+ // Floor (+1 ms for the Date.now tick), min 1: rounding up would promise time already spent.
2478
+ const remainingS = Math.max(1, Math.floor((remainingMs + 1) / 1000));
2479
+ const otherwise = event.timeoutMode === 'auto'
2639
2480
  ? 'this request is answered automatically'
2640
2481
  : 'this request is cancelled';
2641
- return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(Math.round(fallback.timeoutMs / 1000))}s or ${otherwise}\n`;
2482
+ return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(remainingS)}s or ${otherwise}\n`;
2642
2483
  }
2643
2484
  /**
2644
2485
  * The line for an ask the plugin settled itself when its deadline fired.
@@ -2649,9 +2490,7 @@ class TurnStreamController {
2649
2490
  * answered with the option the agent's own kinds implied.
2650
2491
  */
2651
2492
  expiredLine(event) {
2652
- const title = typeof event.toolCall?.title === 'string'
2653
- ? String(event.toolCall.title)
2654
- : 'permission';
2493
+ const title = permissionToolName(event.toolCall);
2655
2494
  const waited = event.timeoutMs > 0
2656
2495
  ? `no answer within ${String(Math.round(event.timeoutMs / 1000))}s`
2657
2496
  : 'not waited for';
@@ -2826,27 +2665,13 @@ export class BridgeManager {
2826
2665
  routeBindingRegistry().defaultModel = this.defaultAgent();
2827
2666
  }
2828
2667
  makeBridge(def, onConnectionLost, onCatalog, onCommands) {
2829
- return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.approvalRequester, () => this.sessionWorkspaceResolver);
2668
+ return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver);
2830
2669
  }
2831
2670
  /**
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;
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.
2671
+ * Every outstanding ask, across all agents, with the view the browser was
2672
+ * shown it — the source of the reconnect replay (see
2673
+ * `permissionReplayFrames`). Every ACP ask is a ledger entry, so this is the
2674
+ * whole live set.
2850
2675
  */
2851
2676
  pendingPermissionViews() {
2852
2677
  const views = [];
@@ -4834,11 +4659,6 @@ export class BridgeManager {
4834
4659
  this.dshSessionMap.delete(key);
4835
4660
  }
4836
4661
  }
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
4662
  sessionWorkspaceResolver;
4843
4663
  acpModeResolver;
4844
4664
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
@@ -4973,7 +4793,7 @@ export class BridgeManager {
4973
4793
  if (existing !== undefined && existing.released) {
4974
4794
  const resumed = await this.bridge(agentId).resumeSession(existing);
4975
4795
  // Rebind explicitly: a resume keeps the record's identity, but the chat
4976
- // session id is what native approval routing keys off.
4796
+ // session id is what the conversation's permission card addresses.
4977
4797
  resumed.dshSessionId = dshSessionId;
4978
4798
  // A resume without an explicit pick must keep serving the agent the
4979
4799
  // conversation was ON (its own model string when one was recorded). The
@@ -5011,7 +4831,7 @@ export class BridgeManager {
5011
4831
  // `ensureReady` for why `session/new`'s cwd alone is not enough.
5012
4832
  workspaceRoot);
5013
4833
  // Absent for `/acp`-RPC sessions and for adapter calls without a chat
5014
- // session; those deliberately keep the ledger path (see askNativeApproval).
4834
+ // session; their permission cards are addressed by the ACP session only.
5015
4835
  session.dshSessionId = dshSessionId;
5016
4836
  this.dshSessionMap.set(key, session);
5017
4837
  // Turn the displayed defaults into this conversation's pick, so the first
@@ -5291,7 +5111,7 @@ export class BridgeManager {
5291
5111
  // loop then executes the card and proposes the continuation step, which
5292
5112
  // resumes the SAME prompt through the branch above.
5293
5113
  const endTurn = bridge.beginTurn(session);
5294
- const controller = new TurnStreamController(this, this.hub, bridge, session, turnId, endTurn, victim => this.rollbackFirstTurn(victim));
5114
+ const controller = new TurnStreamController(this, this.hub, session, turnId, endTurn, victim => this.rollbackFirstTurn(victim));
5295
5115
  if (session.dshSessionId !== undefined)
5296
5116
  this.rememberTurnStream(session.dshSessionId, controller);
5297
5117
  const segment = controller.startSegment();