dsh-ntr 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bridge.js CHANGED
@@ -12,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, 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,
@@ -116,19 +115,17 @@ export class AgentBridge {
116
115
  */
117
116
  ledgerFallbacks = new Map;
118
117
  startupTimeoutMs;
119
- permissionTimeoutMs;
120
118
  /**
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.
119
+ * The ONE approval deadline, and the mode it settles with. Neither is
120
+ * readonly, unlike their neighbors: the settings page rewrites them on a
121
+ * running bridge ({@link AgentBridge.applyApprovalDeadlines}), so a user
122
+ * editing the card does not have to kill every live session to see it take
123
+ * effect. Both are read per ask, so a reassignment reaches every ask from
124
+ * that moment on. An ask already holding an armed timer keeps the window it
125
+ * was started with.
129
126
  */
130
- permissionFallbackTimeoutMs;
131
- nativeApprovalTimeoutMs;
127
+ permissionTimeoutMs;
128
+ permissionTimeoutMode;
132
129
  cancelFallbackMs;
133
130
  staleCancelRetryDelayMs;
134
131
  staleCancelMaxRetries;
@@ -195,9 +192,10 @@ export class AgentBridge {
195
192
  this.sessionWorkspace = sessionWorkspace;
196
193
  const merged = { ...DEFAULT_TUNING, ...tuning };
197
194
  this.startupTimeoutMs = merged.startupTimeoutMs;
198
- this.permissionTimeoutMs = merged.permissionTimeoutMs;
199
- this.permissionFallbackTimeoutMs = merged.permissionFallbackTimeoutMs;
200
- this.nativeApprovalTimeoutMs = merged.nativeApprovalTimeoutMs;
195
+ this.permissionTimeoutMode = merged.permissionTimeoutMode;
196
+ // 不等待 (0) is the `auto` mode's business; under `abort` it would refuse
197
+ // every ask on arrival, so the window is floored to the shortest real wait.
198
+ this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, merged.permissionTimeoutMs);
201
199
  this.cancelFallbackMs = merged.cancelFallbackMs;
202
200
  this.staleCancelRetryDelayMs = merged.staleCancelRetryDelayMs;
203
201
  this.staleCancelMaxRetries = merged.staleCancelMaxRetries;
@@ -206,20 +204,24 @@ export class AgentBridge {
206
204
  this.probeIdleTimeoutMs = merged.probeIdleTimeoutMs;
207
205
  }
208
206
  /**
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.
207
+ * Adopt a rewritten approval deadline or mode (the settings page's write).
208
+ * Only the window and the mode live here; every other tuning value is
209
+ * fixed at construction, because a live bridge cannot meaningfully adopt a
210
+ * new startup or cancel window mid-flight.
213
211
  *
214
212
  * Deliberately does NOT re-arm anything: an ask that already holds a timer
215
213
  * keeps the window it was started with, and the next ask reads the new value.
216
214
  * Re-arming would move the goalposts under a human who is already deciding.
217
215
  */
218
216
  applyApprovalDeadlines(patch) {
219
- if (patch.nativeApprovalTimeoutMs !== undefined)
220
- this.nativeApprovalTimeoutMs = patch.nativeApprovalTimeoutMs;
221
- if (patch.permissionFallbackTimeoutMs !== undefined)
222
- this.permissionFallbackTimeoutMs = patch.permissionFallbackTimeoutMs;
217
+ if (patch.permissionTimeoutMs !== undefined)
218
+ this.permissionTimeoutMs = patch.permissionTimeoutMs;
219
+ if (patch.mode !== undefined)
220
+ this.permissionTimeoutMode = patch.mode;
221
+ // A mode switch can leave a window behind that the new mode cannot run
222
+ // (不等待 under `abort`): floor it, so the ask waits instead of refusing
223
+ // on arrival.
224
+ this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, this.permissionTimeoutMs);
223
225
  }
224
226
  /**
225
227
  * The idle-release window this agent honors for its CHAT process: its own
@@ -1053,6 +1055,23 @@ export class AgentBridge {
1053
1055
  this.touch();
1054
1056
  return native.response;
1055
1057
  }
1058
+ // Deadline: how long this ask may sit unanswered. ONE window for every
1059
+ // route — an ask that never reached the conversation included. They have
1060
+ // no other answer entry, so exempting them would be the very invisible
1061
+ // hang this mechanism exists to bound.
1062
+ //
1063
+ // What happens when it fires is the MODE's business: refused, answered
1064
+ // with the option the agent's own kinds imply, or (in `wait`) never.
1065
+ // The mode is read once, here, for the same reason the window is: an ask
1066
+ // that started under one mode keeps it.
1067
+ const mode = this.permissionTimeoutMode;
1068
+ const timeoutMs = this.permissionTimeoutMs;
1069
+ // 不等待(0)只属自动推进:请求到达即按那一档收场,不建 ledger 条目、不发卡
1070
+ // ——问都来不及问。超时中止没有这一档(它会立刻拒绝每一问,agent 在每个门口
1071
+ // 都停住),它的窗口最短是真实等待。
1072
+ if (mode === 'auto' && timeoutMs === 0) {
1073
+ return this.settleExpiredAsk(session, mode, toolCall, options, 0);
1074
+ }
1056
1075
  return new Promise(resolve => {
1057
1076
  let timer;
1058
1077
  const requestId = this.permissions.create(session.sessionId, (outcome) => {
@@ -1074,42 +1093,32 @@ export class AgentBridge {
1074
1093
  toolCall,
1075
1094
  options: options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
1076
1095
  });
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
1096
  // Recorded before the broadcast: the hub is synchronous, and the chat
1092
1097
  // line `streamPrompt` folds from this frame words itself by the path
1093
1098
  // 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) {
1099
+ this.ledgerFallbacks.set(requestId, { reason: native.reason, timeoutMs, mode });
1100
+ if (timeoutMs > 0 && mode !== 'wait') {
1100
1101
  timer = setTimeout(() => {
1101
1102
  if (!this.permissions.has(requestId))
1102
1103
  return;
1104
+ const picked = mode === 'auto'
1105
+ ? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
1106
+ : undefined;
1107
+ const tool = permissionToolName(toolCall);
1103
1108
  this.hub.broadcast({
1104
1109
  type: 'agent/notice',
1105
1110
  agentId: this.def.id,
1106
1111
  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"`,
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}"`,
1111
1118
  });
1112
- this.permissions.respond(requestId, { outcome: 'cancelled' });
1119
+ this.permissions.respond(requestId, picked === undefined
1120
+ ? { outcome: 'cancelled' }
1121
+ : { outcome: 'selected', optionId: picked.optionId });
1113
1122
  }, timeoutMs);
1114
1123
  timer.unref?.();
1115
1124
  }
@@ -1138,19 +1147,19 @@ export class AgentBridge {
1138
1147
  * open turn, so a session started over `/acp` (no chat turn) cannot be
1139
1148
  * routed and must stay on the ledger path.
1140
1149
  *
1141
- * The ask is raced against {@link BridgeTuning.nativeApprovalTimeoutMs}. A
1150
+ * The ask is raced against {@link BridgeTuning.permissionTimeoutMs}. A
1142
1151
  * routed ask has exactly one answer entry — the composer takeover — and no
1143
1152
  * broadcast at all, so a host that takes the question and never renders it
1144
1153
  * 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.
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`).
1150
1158
  *
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.
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.
1154
1163
  *
1155
1164
  * @returns the agent-facing response, or the reason the ask must fall back
1156
1165
  * to the ledger (see {@link LedgerFallbackReason}).
@@ -1161,6 +1170,16 @@ export class AgentBridge {
1161
1170
  return { reason: 'no-requester' };
1162
1171
  if (session.dshSessionId === undefined)
1163
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
+ }
1164
1183
  // One signal carries both withdrawals: the agent's own cancel (whatever
1165
1184
  // aborted the ACP request) and the deadline. The host's waterfall races its
1166
1185
  // answerers against this signal, so aborting is a clean withdrawal rather
@@ -1169,22 +1188,32 @@ export class AgentBridge {
1169
1188
  const askSignal = signal === undefined ? withdraw.signal : AbortSignal.any([signal, withdraw.signal]);
1170
1189
  let timer;
1171
1190
  let timedOut = false;
1172
- const deadline = this.nativeApprovalTimeoutMs > 0
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
1173
1195
  ? new Promise(resolve => {
1174
1196
  timer = setTimeout(() => {
1175
1197
  timedOut = true;
1176
1198
  withdraw.abort();
1177
1199
  resolve({ timedOut: true });
1178
- }, this.nativeApprovalTimeoutMs);
1200
+ }, windowMs);
1179
1201
  timer.unref?.();
1180
1202
  })
1181
1203
  : undefined;
1182
1204
  let outcome;
1183
1205
  try {
1206
+ const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
1184
1207
  const asked = requester({
1185
1208
  dshSessionId: session.dshSessionId,
1186
1209
  agentId: this.def.id,
1187
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 } : {}),
1188
1217
  signal: askSignal,
1189
1218
  });
1190
1219
  // A withdrawn ask settles on the host's own schedule; dropping it here
@@ -1195,8 +1224,9 @@ export class AgentBridge {
1195
1224
  : await Promise.race([asked.then(o => ({ outcome: o })), deadline]);
1196
1225
  // The flag, not the race winner, decides: the host may settle `cancelled`
1197
1226
  // from the very abort the deadline caused, and that is still a timeout.
1198
- if (timedOut || 'timedOut' in settled)
1199
- return { reason: 'native-timeout' };
1227
+ if (timedOut || 'timedOut' in settled) {
1228
+ return { response: this.settleExpiredAsk(session, mode, toolCall, options, windowMs) };
1229
+ }
1200
1230
  outcome = settled.outcome;
1201
1231
  }
1202
1232
  catch {
@@ -1224,6 +1254,57 @@ export class AgentBridge {
1224
1254
  });
1225
1255
  return { response };
1226
1256
  }
1257
+ /**
1258
+ * Settle an ask whose deadline fired, WHERE IT WAS ASKED.
1259
+ *
1260
+ * `abort` refuses it — the operation does not happen and the agent carries
1261
+ * on with that answer. `auto` answers it with the option the agent's own
1262
+ * kinds imply ({@link pickAutoOption}) so the agent CONTINUES; it says which
1263
+ * option it picked, because an unattended approval nobody looked at has to
1264
+ * be visible afterwards. A mode that cannot pick an option (no options, or
1265
+ * none with a usable id) degrades to `abort`: refusing is safer than
1266
+ * guessing, and hanging is worse than either.
1267
+ *
1268
+ * The `permission/resolved` frame keeps the event stream symmetric with the
1269
+ * ledger path, so the browser drops any card it may hold for this session.
1270
+ */
1271
+ settleExpiredAsk(session, mode, toolCall, options, timeoutMs) {
1272
+ const picked = mode === 'auto'
1273
+ ? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
1274
+ : undefined;
1275
+ const response = picked === undefined
1276
+ ? { outcome: { outcome: 'cancelled' } }
1277
+ : { outcome: { outcome: 'selected', optionId: picked.optionId } };
1278
+ const tool = permissionToolName(toolCall);
1279
+ // 不等待(0)不是「等了 0 秒」,是没等:两种说法的区别就在这里。
1280
+ const waited = timeoutMs > 0
1281
+ ? `timed out after ${String(Math.round(timeoutMs / 1000))}s`
1282
+ : 'was not waited for';
1283
+ this.hub.broadcast({
1284
+ type: 'agent/notice',
1285
+ agentId: this.def.id,
1286
+ level: 'info',
1287
+ // Name what was missed and what was done about it — the way a human
1288
+ // needs it: which operation sat unanswered, and what the plugin answered
1289
+ // instead. OmniTerm's reaper writes the same "you missed this" notice.
1290
+ text: picked === undefined
1291
+ ? `acp: permission request for "${tool}" ${waited}; refused`
1292
+ : `acp: permission request for "${tool}" ${waited}; answered "${picked.name}"`,
1293
+ });
1294
+ // The conversation's only trace of the expiry: the composer card is going
1295
+ // away and nothing else says why.
1296
+ this.hub.broadcast({
1297
+ type: 'permission/expired',
1298
+ sessionId: session.sessionId,
1299
+ toolCall,
1300
+ timeoutMs,
1301
+ outcome: response.outcome.outcome === 'selected'
1302
+ ? { outcome: 'selected', optionId: response.outcome.optionId }
1303
+ : { outcome: 'cancelled' },
1304
+ ...(picked === undefined ? {} : { optionName: picked.name }),
1305
+ });
1306
+ return response;
1307
+ }
1227
1308
  settlePermissions(session) {
1228
1309
  this.permissions.settleFor(p => p.sessionId === session.sessionId);
1229
1310
  }
@@ -1361,6 +1442,59 @@ export function permissionToolName(toolCall) {
1361
1442
  return row.kind;
1362
1443
  return 'tool call';
1363
1444
  }
1445
+ /** Longest reason line the approval card headline carries. */
1446
+ const APPROVAL_REASON_MAX = 240;
1447
+ /** Input keys that name the operation in one string, most specific first. */
1448
+ const APPROVAL_REASON_INPUT_KEYS = [
1449
+ 'command',
1450
+ 'cmd',
1451
+ 'path',
1452
+ 'file_path',
1453
+ 'filePath',
1454
+ 'pattern',
1455
+ 'query',
1456
+ 'url',
1457
+ 'description',
1458
+ ];
1459
+ /**
1460
+ * One line saying WHAT the native approval card is about.
1461
+ *
1462
+ * WHY: the host renders `reason ?? "工具 <name> 请求越权执行"`. Without a
1463
+ * reason the human only sees the tool's NAME ("Bash") and cannot judge the
1464
+ * request; the ACP tool call already declares the missing half — its title and
1465
+ * (for execute/edit tools) a short scalar in `rawInput`. Only ONE declared
1466
+ * string is used, never a serialized object or a whole file, and the line is
1467
+ * capped so a pathological input cannot fill the card or the audit log.
1468
+ */
1469
+ export function permissionAskReason(toolCall) {
1470
+ const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
1471
+ const title = permissionToolName(toolCall);
1472
+ const raw = row.rawInput;
1473
+ const input = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
1474
+ ? raw
1475
+ : typeof raw === 'string' && raw.length <= APPROVAL_REASON_MAX * 4
1476
+ // Some agents send the arguments as a JSON string; read one scalar out
1477
+ // of it instead of echoing the blob.
1478
+ ? (() => {
1479
+ try {
1480
+ const parsed = JSON.parse(raw);
1481
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
1482
+ ? parsed
1483
+ : undefined;
1484
+ }
1485
+ catch {
1486
+ return undefined;
1487
+ }
1488
+ })()
1489
+ : undefined;
1490
+ const detail = input === undefined
1491
+ ? undefined
1492
+ : APPROVAL_REASON_INPUT_KEYS
1493
+ .map(key => input[key])
1494
+ .find((value) => typeof value === 'string' && value !== '');
1495
+ const line = detail === undefined || detail === title ? title : `${title} · ${detail}`;
1496
+ return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line;
1497
+ }
1364
1498
  /**
1365
1499
  * Translate a native approval outcome into the ACP option the agent must see.
1366
1500
  * `'allowed-once'` is the chain's only grant; a rejection prefers the once-variant.
@@ -2319,24 +2453,36 @@ class TurnStreamController {
2319
2453
  }
2320
2454
  else if (row.type === 'permission/request') {
2321
2455
  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);
2456
+ this.pushProse(this.permissionLine(typed));
2457
+ }
2458
+ else if (row.type === 'permission/expired') {
2459
+ const typed = event;
2460
+ this.pushProse(this.expiredLine(typed));
2338
2461
  }
2339
2462
  }
2463
+ /**
2464
+ * Put one narrative line into the turn's prose stream.
2465
+ *
2466
+ * A card-bearing segment must stay the segment's LAST block, so a line that
2467
+ * arrives while a card is open is parked for the next segment instead of
2468
+ * streaming below the card. A line that arrives between segments waits for
2469
+ * the next one to open.
2470
+ */
2471
+ pushProse(line) {
2472
+ if (line === '')
2473
+ return;
2474
+ const seg = this.segment;
2475
+ if (seg === undefined || seg.finished) {
2476
+ this.pendingProse.push(line);
2477
+ return;
2478
+ }
2479
+ if (seg.hasCard) {
2480
+ this.pendingProse.push(line);
2481
+ this.finishSegment(seg, { kind: 'stop' });
2482
+ return;
2483
+ }
2484
+ this.emitProse(seg, 'reasoning', line);
2485
+ }
2340
2486
  /** Events that arrive between segments: recorded, replayed into the next one. */
2341
2487
  recordWithoutSegment(update) {
2342
2488
  const kind = String(update.sessionUpdate ?? '');
@@ -2476,25 +2622,44 @@ class TurnStreamController {
2476
2622
  : 'permission';
2477
2623
  // The line must not point at a place with no answer entry, and the bridge
2478
2624
  // 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.
2625
+ // frame was broadcast). On the ledger paths the settings panel's card is
2626
+ // the answer entry. A DEGRADED native route has to say why the
2627
+ // conversation is not showing the question — as an OBSERVED FACT, not a
2628
+ // guessed cause.
2629
+ //
2630
+ // The last clause says what happens if nobody answers, and it follows the
2631
+ // MODE: refused, answered automatically, or (in `wait`) nothing at all.
2484
2632
  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`;
2633
+ const why = fallback?.reason === 'unavailable' ? 'the approval service is unavailable' : undefined;
2634
+ const lead = why === undefined ? 'please approve in Settings' : `${why}; approve in Settings`;
2635
+ if (fallback === undefined || fallback.timeoutMs <= 0 || fallback.mode === 'wait') {
2636
+ return `\n> 🔐 [Permission Required: ${title}] - ${lead}\n`;
2637
+ }
2638
+ const otherwise = fallback.mode === 'auto'
2639
+ ? 'this request is answered automatically'
2640
+ : 'this request is cancelled';
2641
+ return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(Math.round(fallback.timeoutMs / 1000))}s or ${otherwise}\n`;
2642
+ }
2643
+ /**
2644
+ * The line for an ask the plugin settled itself when its deadline fired.
2645
+ *
2646
+ * The composer card is disappearing, so the conversation has to say what
2647
+ * happened to the question — otherwise the user is left with a vanished
2648
+ * card and no explanation. The wording follows the outcome: refused, or
2649
+ * answered with the option the agent's own kinds implied.
2650
+ */
2651
+ expiredLine(event) {
2652
+ const title = typeof event.toolCall?.title === 'string'
2653
+ ? String(event.toolCall.title)
2654
+ : 'permission';
2655
+ const waited = event.timeoutMs > 0
2656
+ ? `no answer within ${String(Math.round(event.timeoutMs / 1000))}s`
2657
+ : 'not waited for';
2658
+ if (event.outcome.outcome === 'selected') {
2659
+ const picked = event.optionName === undefined ? event.outcome.optionId : event.optionName;
2660
+ return `\n> 🔐 [Permission Required: ${title}] - ${waited}; answered "${picked}" automatically, continuing\n`;
2661
+ }
2662
+ return `\n> 🔐 [Permission Required: ${title}] - ${waited}; refused\n`;
2498
2663
  }
2499
2664
  }
2500
2665
  /** Chunk text shared by the controller's fold (string / object / array forms). */
@@ -2539,17 +2704,23 @@ export class BridgeManager {
2539
2704
  /** The agent tag's halo preference (persisted; see `persistAgents`). */
2540
2705
  tagHaloSetting = { ...DEFAULT_TAG_HALO };
2541
2706
  /**
2542
- * What `cordis.patch.yml` asked for, by key — ONLY the keys it actually
2543
- * names. This is the deployment baseline, kept apart from
2707
+ * What `cordis.patch.yml` asked for — ONLY if it actually named the key.
2708
+ * This is the deployment baseline, kept apart from
2544
2709
  * {@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.
2710
+ * the two is winning. Empty when the file says nothing about the window, in
2711
+ * which case {@link baselineDeadline} (the built-in default) applies.
2547
2712
  */
2548
2713
  configuredDeadlines = {};
2549
2714
  /**
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.
2715
+ * The mode the config file named, if it named one. Kept apart from the
2716
+ * override for the same reason the window is: the card has to be able
2717
+ * to say "the file asks for `abort`, the value set here wins".
2718
+ */
2719
+ configuredMode;
2720
+ /**
2721
+ * The window before any override — the tuning this manager was constructed
2722
+ * with, which already carries the validated config file (the host entry
2723
+ * passes `validateConfig`'s output) or the built-in default.
2553
2724
  *
2554
2725
  * Held separately from {@link configuredDeadlines} because the two answer
2555
2726
  * different questions: this one is what RUNS when nothing is overridden, that
@@ -2557,17 +2728,23 @@ export class BridgeManager {
2557
2728
  * and a bare default does not deserve). A manager built without tuning at all
2558
2729
  * — a direct consumer, a test — gets `DEFAULT_TUNING` here and stays correct.
2559
2730
  */
2560
- baselineDeadlines;
2731
+ baselineDeadline;
2732
+ /**
2733
+ * The mode before any override — the tuning this manager was constructed
2734
+ * with, which already carries the validated config file (the host entry
2735
+ * passes `validateConfig`'s output) or the built-in default.
2736
+ */
2737
+ baselineMode;
2561
2738
  /**
2562
2739
  * 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
2740
+ * OVER {@link baselineDeadline}: once a user picks a window in the UI, that
2564
2741
  * pick is what runs, and the config file becomes the value the reset button
2565
2742
  * returns to. Without this split a hand-edited `cordis.patch.yml` and a UI
2566
2743
  * write would fight with no way to tell which one is live.
2567
2744
  */
2568
2745
  deadlineOverride = {};
2569
2746
  /**
2570
- * Tuning in force, defaults applied. The two approval deadlines are rewritten
2747
+ * Tuning in force, defaults applied. The approval deadline is rewritten
2571
2748
  * IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
2572
2749
  * rather than the constructor's parameter: every bridge built afterwards
2573
2750
  * inherits the rewrite instead of the boot-time value.
@@ -2624,13 +2801,11 @@ export class BridgeManager {
2624
2801
  // `applyDeadlineTuning`), and every bridge built afterwards must inherit
2625
2802
  // the rewrite instead of the boot-time value.
2626
2803
  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
- };
2804
+ // The pre-override deadline value, snapshotted before anything can rewrite
2805
+ // it. Everything else in `tuning` is fixed for the manager's lifetime, so
2806
+ // only this needs a snapshot.
2807
+ this.baselineDeadline = this.tuning.permissionTimeoutMs;
2808
+ this.baselineMode = this.tuning.permissionTimeoutMode;
2634
2809
  for (const def of defs) {
2635
2810
  this.defsMap.set(def.id, def);
2636
2811
  }
@@ -3469,7 +3644,31 @@ export class BridgeManager {
3469
3644
  // validator accepts must be the value that runs (a UI write that did
3470
3645
  // not survive a restart would read as a broken setting).
3471
3646
  try {
3472
- this.deadlineOverride = validateApprovalDeadlines(parsed.approvalDeadlines ?? {});
3647
+ const raw = parsed.approvalDeadlines ?? {};
3648
+ // The OLD card wrote the two legacy windows, and its 「关」 preset
3649
+ // wrote a zero — which then meant "this window never fires". The only
3650
+ // way to say that now is the `wait` MODE (a zero means 不等待 today),
3651
+ // so a legacy zero carries over as the mode instead of silently
3652
+ // turning "wait forever" into "answer immediately".
3653
+ const legacy = raw;
3654
+ const legacyWindow = legacy.nativeApprovalTimeoutMs ?? legacy.permissionFallbackTimeoutMs;
3655
+ const legacyNever = legacyWindow === 0;
3656
+ const window = legacyNever
3657
+ ? undefined
3658
+ : validateApprovalDeadlines(raw).permissionTimeoutMs;
3659
+ const mode = legacyNever
3660
+ ? 'wait'
3661
+ : (raw.mode === undefined
3662
+ ? undefined
3663
+ : validatePermissionTimeoutMode(raw.mode, 'bad_payload'));
3664
+ // A stored window must be legal for the mode it runs under: 不等待
3665
+ // (0) belongs to `auto`; under `abort` it would refuse every ask on
3666
+ // arrival, so it floors to the shortest real wait.
3667
+ const effectiveMode = mode ?? this.baselineMode;
3668
+ this.deadlineOverride = {
3669
+ ...(window === undefined ? {} : { permissionTimeoutMs: normalizeApprovalWindow(effectiveMode, window) }),
3670
+ ...(mode === undefined ? {} : { mode }),
3671
+ };
3473
3672
  }
3474
3673
  catch {
3475
3674
  this.deadlineOverride = {};
@@ -4527,7 +4726,7 @@ export class BridgeManager {
4527
4726
  this.persistAgents();
4528
4727
  }
4529
4728
  /**
4530
- * Declare the deployment baseline for the two approval deadlines — the values
4729
+ * Declare the deployment baseline for the approval deadline — the values
4531
4730
  * `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
4532
4731
  *
4533
4732
  * Kept separate from the validated tuning because "the file says 600000" and
@@ -4537,36 +4736,46 @@ export class BridgeManager {
4537
4736
  * also moves the baseline, so a manager built without tuning (a direct
4538
4737
  * consumer, a test) still runs what the deployment asked for.
4539
4738
  */
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;
4739
+ setConfiguredApprovalDeadlines(values, mode) {
4740
+ // The baseline must be legal for the mode it runs under, or the card would
4741
+ // advertise a number the deployment cannot actually run (不等待 under
4742
+ // `abort`) and the reset button would restore it.
4743
+ const effectiveMode = mode ?? this.configuredMode ?? this.baselineMode;
4744
+ const raw = values.permissionTimeoutMs;
4745
+ const window = raw === undefined ? undefined : normalizeApprovalWindow(effectiveMode, raw);
4746
+ this.configuredDeadlines = window === undefined ? {} : { permissionTimeoutMs: window };
4747
+ if (window !== undefined)
4748
+ this.baselineDeadline = window;
4749
+ if (mode !== undefined) {
4750
+ this.configuredMode = mode;
4751
+ this.baselineMode = mode;
4752
+ }
4546
4753
  this.applyDeadlineTuning();
4547
4754
  }
4548
4755
  /**
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.
4756
+ * The approval deadline in force, plus the facts that explain a value the
4757
+ * user did not pick: the config-file baseline an override is outranking. The
4758
+ * mode rides along with its own baseline, for the same reason: the card has
4759
+ * to be able to say "the file asks for `abort`, the value set here wins".
4760
+ *
4761
+ * Everything is reported only when it is actually in play, so the card can
4762
+ * stay silent about a plain default.
4554
4763
  */
4555
4764
  approvalDeadlines() {
4556
4765
  const deadlines = {
4557
- nativeApprovalTimeoutMs: this.tuning.nativeApprovalTimeoutMs,
4558
- permissionFallbackTimeoutMs: this.tuning.permissionFallbackTimeoutMs,
4766
+ permissionTimeoutMs: this.tuning.permissionTimeoutMs,
4767
+ mode: this.tuning.permissionTimeoutMode,
4559
4768
  };
4560
4769
  if (Object.keys(this.configuredDeadlines).length > 0)
4561
4770
  deadlines.configured = { ...this.configuredDeadlines };
4562
- if (this.tuning.permissionTimeoutMs > 0)
4563
- deadlines.permissionTimeoutMs = this.tuning.permissionTimeoutMs;
4771
+ if (this.configuredMode !== undefined)
4772
+ deadlines.configuredMode = this.configuredMode;
4564
4773
  return deadlines;
4565
4774
  }
4566
4775
  /**
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.
4776
+ * Write the approval deadline, or the mode (omitted keys keep their value),
4777
+ * or drop the persisted override with `reset` so the config-file baseline —
4778
+ * or the built-in default — applies again.
4570
4779
  *
4571
4780
  * HOT: the rewrite reaches every live bridge, so a user changing the window
4572
4781
  * never has to kill a running session to see it take effect (the one thing an
@@ -4577,25 +4786,35 @@ export class BridgeManager {
4577
4786
  async setApprovalDeadlines(patch, reset = false) {
4578
4787
  // Merged BEFORE validating, so the whole resulting override passes the same
4579
4788
  // 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.
4789
+ // re-gated here rather than trusted). The validators also drop unknown
4790
+ // keys, which is what keeps a stale payload from poisoning state.
4582
4791
  const merged = reset ? {} : { ...this.deadlineOverride, ...patch };
4583
- this.deadlineOverride = validateApprovalDeadlines(merged);
4792
+ const window = validateApprovalDeadlines(merged).permissionTimeoutMs;
4793
+ const mode = merged.mode === undefined
4794
+ ? undefined
4795
+ : validatePermissionTimeoutMode(merged.mode, 'bad_payload');
4796
+ // The stored override must be legal for the mode it runs under, not just
4797
+ // legal on its own: 不等待 (0) belongs to `auto`, and a mode switch can
4798
+ // leave it behind. Flooring it HERE (not only in the running value) keeps
4799
+ // the card's number and the running number the same number.
4800
+ const effectiveMode = mode ?? this.baselineMode;
4801
+ this.deadlineOverride = {
4802
+ ...(window === undefined ? {} : { permissionTimeoutMs: normalizeApprovalWindow(effectiveMode, window) }),
4803
+ ...(mode === undefined ? {} : { mode }),
4804
+ };
4584
4805
  this.applyDeadlineTuning();
4585
4806
  this.persistAgents();
4586
4807
  }
4587
4808
  /**
4588
- * Recompute the two deadlines from override → deployment baseline, and push
4589
- * the result to every live bridge.
4809
+ * Recompute the deadline and the mode from override → deployment baseline,
4810
+ * and push the result to every live bridge.
4590
4811
  */
4591
4812
  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 };
4813
+ const permissionTimeoutMode = this.deadlineOverride.mode ?? this.baselineMode;
4814
+ const permissionTimeoutMs = normalizeApprovalWindow(permissionTimeoutMode, this.deadlineOverride.permissionTimeoutMs ?? this.baselineDeadline);
4815
+ this.tuning.permissionTimeoutMs = permissionTimeoutMs;
4816
+ this.tuning.permissionTimeoutMode = permissionTimeoutMode;
4817
+ const patch = { permissionTimeoutMs, mode: permissionTimeoutMode };
4599
4818
  for (const bridge of this.bridges.values())
4600
4819
  bridge.applyApprovalDeadlines(patch);
4601
4820
  }