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/src/bridge.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  parseCatalogOptions,
46
46
  usableConfigOptions,
47
47
  permissionToolKind,
48
+ permissionRequestFrame,
48
49
  pickAutoOption,
49
50
  quoteCommandToken,
50
51
  randomUUID,
@@ -68,7 +69,7 @@ import type {
68
69
  RosterPresetRow,
69
70
  } from './impl.ts'
70
71
  import type {
71
- AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpMcpServerDef, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
72
+ AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
72
73
  LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmReasoningEffortInfo, LlmResolvedModelInfo,
73
74
  ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PreparedAdapterCall, SessionView, StreamChunk,
74
75
  } from './protocol.ts'
@@ -116,9 +117,9 @@ interface LiveSession {
116
117
  released: boolean
117
118
  /**
118
119
  * Owning DSH chat session id, when the session was opened through the LLM
119
- * adapter (`AcpLlmAdapter`). `ctx.agents.get()` needs it to name the agent
120
- * whose open turn carries the native approval question; sessions started
121
- * over the `/acp` RPC have no chat session and stay on the ledger path.
120
+ * adapter (`AcpLlmAdapter`). It is what the conversation's permission card
121
+ * filters on (`permission/request.dshSessionId`); sessions started over the
122
+ * `/acp` RPC have no chat session, so their asks stay on the settings panel.
122
123
  */
123
124
  dshSessionId?: string
124
125
  /**
@@ -176,21 +177,24 @@ export interface BridgeTuning {
176
177
  /**
177
178
  * How long a permission ask may sit unanswered before the deadline fires.
178
179
  *
179
- * ONE window for both routes. An ask can be asked in the conversation (the
180
- * composer takeover) or in the settings panel's ledger (no approval service,
181
- * or no chat identity to route into) — but both ask the same question, "is
182
- * anyone there?", so both get the same answer. Two windows made the settings
183
- * card look like two unrelated knobs.
180
+ * ONE window for every ask. Every ask is broadcast as `permission/request`
181
+ * and answered from the conversation's dock card; an `/acp`-RPC ask has no
182
+ * conversation, so the settings page mirrors the same ask as its answer
183
+ * entry. One question, one window: "is anyone there?" gets the same answer
184
+ * wherever the card is rendered, and two numbers made the settings card look
185
+ * like two unrelated knobs.
184
186
  *
185
- * `0` means 一直等 — no deadline at all. What happens when it fires is
186
- * {@link BridgeTuning.permissionTimeoutMode}'s business, never this knob's.
187
+ * `0` means 不等待, not "wait forever": `auto` settles the ask on arrival,
188
+ * `abort` floors the window to the shortest real wait (refusing every ask on
189
+ * arrival is a brick wall, not a timeout), and `wait` ignores the window
190
+ * entirely.
187
191
  *
188
- * The deadline is what keeps a turn from hanging on a question nobody can
189
- * see: a routed ask shows no card and broadcasts no frame, so a host that
190
- * takes the question and never renders it would otherwise leave the turn
191
- * hanging forever. Past the deadline the ask is settled IN PLACE — it is not
192
- * handed anywhere else. A human who was still deciding finds the ask REFUSED
193
- * (or auto-answered, in `auto` mode), not relocated.
192
+ * The deadline is what keeps a turn from hanging on a question nobody
193
+ * answers: the card stays visible, but an unattended session would otherwise
194
+ * wait indefinitely. Past the deadline the ask is settled IN PLACE — never
195
+ * relocated: `abort` refuses it, `auto` answers it with the option the
196
+ * agent's own kinds imply (broadest grant first), and `wait` never expires
197
+ * it at all.
194
198
  *
195
199
  * Default 300000 ms. OmniTerm's equivalent is its reaper's permission-timeout
196
200
  * threshold (30 min default, 30 s steps, hot-updatable).
@@ -246,52 +250,6 @@ export interface BridgeTuning {
246
250
  probeIdleTimeoutMs?: number
247
251
  }
248
252
 
249
- /** One native approval question, as the host approval service defines it. */
250
- export interface ApprovalRequestContext {
251
- /** DSH chat session that owns the open turn the question is asked in. */
252
- dshSessionId?: string
253
- agentId: string
254
- /** Human-facing identity of the operation awaiting a decision. */
255
- toolName: string
256
- reason?: string
257
- /** The ACP tool call this ask belongs to; the host card attaches its detail to it. */
258
- callId?: string
259
- signal?: AbortSignal
260
- }
261
-
262
- /** Outcome vocabulary of the host approval chain (`'unavailable'` = no answerer). */
263
- export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
264
-
265
- /**
266
- * Why one permission ask rides the settings-panel ledger instead of the native
267
- * approval chain, recorded when the ask is broadcast.
268
- *
269
- * `no-requester` and `no-chat-session` are the DESIGNED ledger paths: a host
270
- * without the approval service, and an `/acp`-RPC session that has no chat
271
- * turn to route into. `unavailable` is a DEGRADATION — the service is wired
272
- * and this ask still failed, so the conversation the user is looking at will
273
- * never show the question. The reason decides the chat line's wording (see
274
- * `BridgeManager.streamPrompt`); the deadline and the mode decide what
275
- * happens if nobody answers ({@link AgentBridge.handlePermissionRequest}).
276
- */
277
- export type LedgerFallbackReason = 'no-requester' | 'no-chat-session' | 'unavailable'
278
-
279
- /** One recorded ledger fallback: why the ask is here, and the deadline armed for it. */
280
- export interface LedgerFallback {
281
- reason: LedgerFallbackReason
282
- /** Effective deadline in ms; `0` = no timer (wait for the human). */
283
- timeoutMs: number
284
- /** What the deadline does when it fires. */
285
- mode: PermissionTimeoutMode
286
- }
287
-
288
- /**
289
- * Asks the host approval service (`ctx.approval`) for one decision. Never
290
- * throws: any failure is reported as `'unavailable'` so the caller can fall
291
- * back to the settings-panel ledger.
292
- */
293
- export type ApprovalRequester = (request: ApprovalRequestContext) => Promise<ApprovalOutcome>
294
-
295
253
  /**
296
254
  * Resolves the workspace root of one chat session (`dshSessionId` = DSH session
297
255
  * id) — the sidebar's selected workspace, which the host's sandbox policy keeps
@@ -392,15 +350,6 @@ export class AgentBridge {
392
350
  /** Inflight `session/load` resumes, shared so concurrent prompts don't double-resume. */
393
351
  private readonly resuming = new Map<string, Promise<LiveSession>>()
394
352
  readonly permissions = new PermissionLedger()
395
- /**
396
- * Why each outstanding ledger ask is on the ledger, keyed by request id and
397
- * recorded before the `permission/request` broadcast (the hub is
398
- * synchronous, so a subscriber can read it the moment the frame goes out).
399
- * Entries live exactly as long as the ask: the ledger's resolve callback
400
- * deletes them, and every terminal path (answer, timeout, session close,
401
- * process death) resolves through it.
402
- */
403
- private readonly ledgerFallbacks = new Map<string, LedgerFallback>
404
353
  private readonly startupTimeoutMs: number
405
354
  /**
406
355
  * The ONE approval deadline, and the mode it settles with. Neither is
@@ -465,13 +414,8 @@ export class AgentBridge {
465
414
  private readonly onCommands?: (agentId: string, commands: NonNullable<SessionView['availableCommands']>) => void,
466
415
  tuning?: BridgeTuning,
467
416
  /**
468
- * Resolved per ask (not captured) so a requester wired after this bridge
469
- * was built still applies. Absent → every ask rides the ledger.
470
- */
471
- private readonly approvalRequester?: () => ApprovalRequester | undefined,
472
- /**
473
- * Resolved per turn, like {@link approvalRequester}: the host entry may
474
- * wire the access-mode mapping after this bridge was built.
417
+ * Resolved per turn: the host entry may wire the workspace reader after
418
+ * this bridge was built.
475
419
  */
476
420
  private readonly sessionWorkspace?: () => SessionWorkspaceResolver | undefined,
477
421
  ) {
@@ -1263,12 +1207,15 @@ export class AgentBridge {
1263
1207
  // Closing a session is activity of its own: restart the idle window here
1264
1208
  // rather than at the prompt that opened it.
1265
1209
  this.touch()
1210
+ // Answer every outstanding ask BEFORE `session/close` is awaited: an agent
1211
+ // blocked on an unanswered `session/request_permission` would otherwise be
1212
+ // able to hold the close open indefinitely.
1213
+ this.settlePermissions(session)
1266
1214
  const conn = this.conn
1267
1215
  if (conn !== undefined && this.state === 'ready') {
1268
1216
  // `session/close` is an optional capability; the local teardown stands regardless.
1269
1217
  await conn.agent.request(methods.agent.session.close, { sessionId: session.acpSessionId }).catch(() => { /* unsupported */ })
1270
1218
  }
1271
- this.settlePermissions(session)
1272
1219
  this.sessions.delete(session.sessionId)
1273
1220
  this.acpIndex.delete(session.acpSessionId)
1274
1221
  this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason })
@@ -1333,29 +1280,26 @@ export class AgentBridge {
1333
1280
  }
1334
1281
 
1335
1282
  /**
1336
- * Answer one ACP permission ask. Every ask reaches a human: the native
1337
- * approval chain first (answered in the chat composer), the settings-panel
1338
- * ledger otherwise.
1283
+ * Answer one ACP permission ask.
1284
+ *
1285
+ * EVERY ask is answered by a human through the plugin's own ledger. The
1286
+ * host's approval chain is deliberately not involved: it could accept an ask
1287
+ * and render nothing, leaving the turn hanging behind something invisible
1288
+ * with no deadline (issue #2). The ledger broadcasts `permission/request`,
1289
+ * the browser renders the answerable card, and `permission/respond` settles
1290
+ * it.
1291
+ *
1292
+ * The host answers on its own only when a deadline expires: `abort` refuses,
1293
+ * `auto` picks the option the agent's own kinds imply, `wait` never expires.
1294
+ * The ACP request's own `signal` (the agent withdrawing the ask, or the
1295
+ * connection dying) settles the same entry cancelled, so a withdrawn ask can
1296
+ * never leave a stale card behind.
1339
1297
  */
1340
1298
  private async handlePermissionRequest(acpSessionId: string, toolCall: unknown, options: PermissionOption[], signal?: AbortSignal): Promise<RequestPermissionResponse> {
1341
1299
  const session = this.acpIndex.get(acpSessionId)
1342
1300
  if (session === undefined || session.closed) return { outcome: { outcome: 'cancelled' } }
1343
- // Native approval first (answered in the chat composer); a `reason` means
1344
- // no route is available and the settings-panel ledger takes over.
1345
- const native = await this.askNativeApproval(session, toolCall, options, signal)
1346
- if ('response' in native) {
1347
- this.touch()
1348
- return native.response
1349
- }
1350
- // Deadline: how long this ask may sit unanswered. ONE window for every
1351
- // route — an ask that never reached the conversation included. They have
1352
- // no other answer entry, so exempting them would be the very invisible
1353
- // hang this mechanism exists to bound.
1354
- //
1355
- // What happens when it fires is the MODE's business: refused, answered
1356
- // with the option the agent's own kinds imply, or (in `wait`) never.
1357
- // The mode is read once, here, for the same reason the window is: an ask
1358
- // that started under one mode keeps it.
1301
+ // The deadline and the mode are read ONCE, here: an ask that started under
1302
+ // one pair keeps it, even if the settings page rewrites them mid-flight.
1359
1303
  const mode = this.permissionTimeoutMode
1360
1304
  const timeoutMs = this.permissionTimeoutMs
1361
1305
  // 不等待(0)只属自动推进:请求到达即按那一档收场,不建 ledger 条目、不发卡
@@ -1364,11 +1308,28 @@ export class AgentBridge {
1364
1308
  if (mode === 'auto' && timeoutMs === 0) {
1365
1309
  return this.settleExpiredAsk(session, mode, toolCall, options, 0)
1366
1310
  }
1311
+ const optionViews = options.map((o): PermissionOptionView => ({ optionId: o.optionId, name: o.name, kind: o.kind }))
1312
+ const reason = permissionAskReason(toolCall)
1313
+ // Computed once, at ask creation: the frame, the chat line and the timer
1314
+ // all describe the same instant, and a slow reader cannot stretch it.
1315
+ const deadlineAt = timeoutMs > 0 && mode !== 'wait' ? Date.now() + timeoutMs : undefined
1316
+ const view = {
1317
+ ...(session.dshSessionId === undefined ? {} : { dshSessionId: session.dshSessionId }),
1318
+ // Kept whole for the reconnect replay: a client that connects after the
1319
+ // ask was broadcast must be re-shown the very card it has to answer.
1320
+ toolCall,
1321
+ toolName: permissionToolName(toolCall),
1322
+ ...(reason === '' ? {} : { reason }),
1323
+ options: optionViews,
1324
+ timeoutMode: mode,
1325
+ ...(deadlineAt === undefined ? {} : { deadlineAt }),
1326
+ }
1367
1327
  return new Promise<RequestPermissionResponse>(resolve => {
1368
1328
  let timer: ReturnType<typeof setTimeout> | undefined
1329
+ let detachSignal: (() => void) | undefined
1369
1330
  const requestId = this.permissions.create(session.sessionId, (outcome: PermissionOutcome) => {
1370
1331
  clearTimeout(timer)
1371
- this.ledgerFallbacks.delete(requestId)
1332
+ detachSignal?.()
1372
1333
  this.touch()
1373
1334
  this.hub.broadcast({
1374
1335
  type: 'permission/resolved',
@@ -1379,202 +1340,57 @@ export class AgentBridge {
1379
1340
  resolve(outcome.outcome === 'selected'
1380
1341
  ? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
1381
1342
  : { outcome: { outcome: 'cancelled' } })
1382
- }, {
1383
- // Kept for the reconnect replay: a client that connects after the ask
1384
- // was broadcast must be re-shown the very card it has to answer.
1385
- toolCall,
1386
- options: options.map((o): PermissionOptionView => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
1387
- })
1388
- // Recorded before the broadcast: the hub is synchronous, and the chat
1389
- // line `streamPrompt` folds from this frame words itself by the path
1390
- // this ask actually took.
1391
- this.ledgerFallbacks.set(requestId, { reason: native.reason, timeoutMs, mode })
1392
- if (timeoutMs > 0 && mode !== 'wait') {
1343
+ }, view)
1344
+ // The live frame and the replay frame come from ONE builder, so a
1345
+ // reconnecting client receives exactly what a live one did.
1346
+ this.hub.broadcast(permissionRequestFrame({ requestId, sessionId: session.sessionId, ...view }))
1347
+ if (deadlineAt !== undefined) {
1393
1348
  timer = setTimeout(() => {
1394
1349
  if (!this.permissions.has(requestId)) return
1395
- const picked = mode === 'auto'
1396
- ? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
1397
- : undefined
1398
- const tool = permissionToolName(toolCall)
1399
- this.hub.broadcast({
1400
- type: 'agent/notice',
1401
- agentId: this.def.id,
1402
- level: 'info',
1403
- // Name what was missed and what was done about it, the way a human
1404
- // needs it: which operation sat unanswered, and what the plugin
1405
- // answered instead (OmniTerm's reaper writes the same notice).
1406
- text: picked === undefined
1407
- ? `acp: permission request for "${tool}" timed out after ${String(Math.round(timeoutMs / 1000))}s; refused`
1408
- : `acp: permission request for "${tool}" timed out after ${String(Math.round(timeoutMs / 1000))}s; answered "${picked.name}"`,
1409
- })
1410
- this.permissions.respond(requestId, picked === undefined
1411
- ? { outcome: 'cancelled' }
1412
- : { outcome: 'selected', optionId: picked.optionId })
1413
- }, timeoutMs)
1350
+ // Settle IN PLACE: exactly one notice, one `permission/expired` (the
1351
+ // conversation's trace of the expiry) and the ledger answer.
1352
+ const outcome = this.announceExpiredAsk(session, mode, toolCall, options, timeoutMs)
1353
+ this.permissions.respond(requestId, outcome)
1354
+ }, Math.max(0, deadlineAt - Date.now()))
1414
1355
  timer.unref?.()
1415
1356
  }
1416
- this.hub.broadcast({
1417
- type: 'permission/request',
1418
- requestId,
1419
- sessionId: session.sessionId,
1420
- toolCall,
1421
- options: options.map((o): PermissionOptionView => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
1422
- })
1423
- })
1424
- }
1425
-
1426
- /**
1427
- * Why one ledger ask is on the ledger, as recorded by
1428
- * {@link handlePermissionRequest}. The manager's `streamPrompt` reads it to
1429
- * word the chat line by the path the ask took; `undefined` once the ask is
1430
- * answered, timed out, or settled.
1431
- */
1432
- ledgerFallbackOf(requestId: string): LedgerFallback | undefined {
1433
- return this.ledgerFallbacks.get(requestId)
1434
- }
1435
-
1436
- /**
1437
- * Ask the host approval service for this permission (`ctx.approval` → the
1438
- * browser's composer takeover). Only sessions bound to a DSH chat session
1439
- * qualify: the service appends its audit pair to that session inside an
1440
- * open turn, so a session started over `/acp` (no chat turn) cannot be
1441
- * routed and must stay on the ledger path.
1442
- *
1443
- * The ask is raced against {@link BridgeTuning.permissionTimeoutMs}. A
1444
- * routed ask has exactly one answer entry — the composer takeover — and no
1445
- * broadcast at all, so a host that takes the question and never renders it
1446
- * would leave the turn hanging behind something invisible. Past the deadline
1447
- * the ask is settled RIGHT THERE, by
1448
- * {@link BridgeTuning.permissionTimeoutMode}: refused (`abort`), answered
1449
- * with the option the agent's own kinds imply (`auto`), or never expiring
1450
- * at all (`wait`).
1451
- *
1452
- * It is NOT handed somewhere else. An earlier revision withdrew the ask and
1453
- * re-asked it on the settings ledger, which made the question travel to a
1454
- * page the user was not looking at — and then cancelled it there a few
1455
- * minutes later anyway. A deadline now ends the ask where it was asked.
1456
- *
1457
- * @returns the agent-facing response, or the reason the ask must fall back
1458
- * to the ledger (see {@link LedgerFallbackReason}).
1459
- */
1460
- private async askNativeApproval(
1461
- session: LiveSession,
1462
- toolCall: unknown,
1463
- options: PermissionOption[],
1464
- signal?: AbortSignal,
1465
- ): Promise<{ response: RequestPermissionResponse } | { reason: LedgerFallbackReason }> {
1466
- const requester = this.approvalRequester?.()
1467
- if (requester === undefined) return { reason: 'no-requester' }
1468
- if (session.dshSessionId === undefined) return { reason: 'no-chat-session' }
1469
- // The mode is read ONCE, here: it decides both whether a deadline exists
1470
- // and what happens when it fires. An ask that started under one mode keeps
1471
- // it — the same rule the window follows (an ask already holding a timer
1472
- // keeps the one it started with).
1473
- const mode = this.permissionTimeoutMode
1474
- // 不等待(0)只属自动推进:没有问人的必要。直接按那一档收场,composer 接管
1475
- // 根本不必出现——它只会闪一下就没了,问了也来不及答。超时中止没有这一档。
1476
- if (mode === 'auto' && this.permissionTimeoutMs === 0) {
1477
- return { response: this.settleExpiredAsk(session, mode, toolCall, options, 0) }
1478
- }
1479
- // One signal carries both withdrawals: the agent's own cancel (whatever
1480
- // aborted the ACP request) and the deadline. The host's waterfall races its
1481
- // answerers against this signal, so aborting is a clean withdrawal rather
1482
- // than a leak.
1483
- const withdraw = new AbortController()
1484
- const askSignal = signal === undefined ? withdraw.signal : AbortSignal.any([signal, withdraw.signal])
1485
- let timer: ReturnType<typeof setTimeout> | undefined
1486
- let timedOut = false
1487
- // `wait` never expires, so it never arms a timer at all. A window of `0`
1488
- // never reaches here: 不等待 was already settled above.
1489
- const windowMs = this.permissionTimeoutMs
1490
- const deadline = mode !== 'wait' && windowMs > 0
1491
- ? new Promise<{ timedOut: true }>(resolve => {
1492
- timer = setTimeout(() => {
1493
- timedOut = true
1494
- withdraw.abort()
1495
- resolve({ timedOut: true })
1496
- }, windowMs)
1497
- timer.unref?.()
1498
- })
1499
- : undefined
1500
- let outcome: ApprovalOutcome
1501
- try {
1502
- const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {}) as Record<string, unknown>
1503
- const asked = requester({
1504
- dshSessionId: session.dshSessionId,
1505
- agentId: this.def.id,
1506
- toolName: permissionToolName(toolCall),
1507
- reason: permissionAskReason(toolCall),
1508
- // The ACP call id is the id the adapter streamed on the host tool card
1509
- // (`TurnStreamController` fills `tool-call` blocks from
1510
- // `snapshot.callId`), so the host's approval detail slot can attach
1511
- // the operation's own card to this ask.
1512
- ...(typeof row.toolCallId === 'string' && row.toolCallId !== '' ? { callId: row.toolCallId } : {}),
1513
- signal: askSignal,
1514
- })
1515
- // A withdrawn ask settles on the host's own schedule; dropping it here
1516
- // must not surface as an unhandled rejection later.
1517
- asked.catch(() => { /* withdrawn: the deadline settles this ask now */ })
1518
- const settled = deadline === undefined
1519
- ? await asked.then(o => ({ outcome: o }))
1520
- : await Promise.race([asked.then(o => ({ outcome: o })), deadline])
1521
- // The flag, not the race winner, decides: the host may settle `cancelled`
1522
- // from the very abort the deadline caused, and that is still a timeout.
1523
- if (timedOut || 'timedOut' in settled) {
1524
- return { response: this.settleExpiredAsk(session, mode, toolCall, options, windowMs) }
1357
+ // The agent's own withdrawal settles the entry cancelled; the ledger's
1358
+ // resolve callback broadcasts `permission/resolved`, and drops this
1359
+ // listener, so nothing outlives the ask.
1360
+ if (signal !== undefined) {
1361
+ const onAbort = (): void => {
1362
+ if (!this.permissions.has(requestId)) return
1363
+ this.permissions.respond(requestId, { outcome: 'cancelled' })
1364
+ }
1365
+ if (signal.aborted) {
1366
+ onAbort()
1367
+ return
1368
+ }
1369
+ signal.addEventListener('abort', onAbort, { once: true })
1370
+ detachSignal = () => { signal.removeEventListener('abort', onAbort) }
1525
1371
  }
1526
- outcome = settled.outcome
1527
- } catch {
1528
- // Synchronous throw or rejection, either way: the ask falls back to the
1529
- // ledger. An approval failure must never break the agent's turn.
1530
- outcome = 'unavailable'
1531
- } finally {
1532
- clearTimeout(timer)
1533
- }
1534
- if (outcome === 'unavailable') return { reason: 'unavailable' }
1535
- const response = nativeOutcomeResponse(outcome, options)
1536
- // Keep the event stream symmetric with the ledger path: a routed decision
1537
- // is still a resolution, and the browser uses it to drop any card it may
1538
- // hold for this session. No `permission/request` is broadcast — the
1539
- // settings panel must never show a card for a composer-routed ask.
1540
- this.hub.broadcast({
1541
- type: 'permission/resolved',
1542
- requestId: randomUUID(),
1543
- sessionId: session.sessionId,
1544
- outcome: response.outcome.outcome === 'selected'
1545
- ? { outcome: 'selected', optionId: response.outcome.optionId }
1546
- : { outcome: 'cancelled' },
1547
1372
  })
1548
- return { response }
1549
1373
  }
1550
1374
 
1551
1375
  /**
1552
- * Settle an ask whose deadline fired, WHERE IT WAS ASKED.
1553
- *
1554
- * `abort` refuses it — the operation does not happen and the agent carries
1555
- * on with that answer. `auto` answers it with the option the agent's own
1556
- * kinds imply ({@link pickAutoOption}) so the agent CONTINUES; it says which
1557
- * option it picked, because an unattended approval nobody looked at has to
1558
- * be visible afterwards. A mode that cannot pick an option (no options, or
1559
- * none with a usable id) degrades to `abort`: refusing is safer than
1560
- * guessing, and hanging is worse than either.
1561
- *
1562
- * The `permission/resolved` frame keeps the event stream symmetric with the
1563
- * ledger path, so the browser drops any card it may hold for this session.
1376
+ * Announce and compute one ask's expiry. Shared by the 不等待 arrival path
1377
+ * and the ledger deadline, so an expiry can never broadcast two notices or
1378
+ * skip the `permission/expired` line: once the card is gone that frame is
1379
+ * the conversation's only trace of what happened to the question.
1564
1380
  */
1565
- private settleExpiredAsk(
1381
+ private announceExpiredAsk(
1566
1382
  session: LiveSession,
1567
1383
  mode: PermissionTimeoutMode,
1568
1384
  toolCall: unknown,
1569
1385
  options: PermissionOption[],
1570
1386
  timeoutMs: number,
1571
- ): RequestPermissionResponse {
1387
+ ): PermissionOutcome {
1572
1388
  const picked = mode === 'auto'
1573
1389
  ? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
1574
1390
  : undefined
1575
- const response: RequestPermissionResponse = picked === undefined
1576
- ? { outcome: { outcome: 'cancelled' } }
1577
- : { outcome: { outcome: 'selected', optionId: picked.optionId } }
1391
+ const outcome: PermissionOutcome = picked === undefined
1392
+ ? { outcome: 'cancelled' }
1393
+ : { outcome: 'selected', optionId: picked.optionId }
1578
1394
  const tool = permissionToolName(toolCall)
1579
1395
  // 不等待(0)不是「等了 0 秒」,是没等:两种说法的区别就在这里。
1580
1396
  const waited = timeoutMs > 0
@@ -1591,19 +1407,36 @@ export class AgentBridge {
1591
1407
  ? `acp: permission request for "${tool}" ${waited}; refused`
1592
1408
  : `acp: permission request for "${tool}" ${waited}; answered "${picked.name}"`,
1593
1409
  })
1594
- // The conversation's only trace of the expiry: the composer card is going
1595
- // away and nothing else says why.
1596
1410
  this.hub.broadcast({
1597
1411
  type: 'permission/expired',
1598
1412
  sessionId: session.sessionId,
1599
1413
  toolCall,
1600
1414
  timeoutMs,
1601
- outcome: response.outcome.outcome === 'selected'
1602
- ? { outcome: 'selected', optionId: response.outcome.optionId }
1603
- : { outcome: 'cancelled' },
1415
+ outcome,
1604
1416
  ...(picked === undefined ? {} : { optionName: picked.name }),
1605
1417
  })
1606
- return response
1418
+ return outcome
1419
+ }
1420
+
1421
+ /**
1422
+ * Settle the 不等待 (`auto` + zero window) ask on arrival: no ledger entry
1423
+ * and no card — nothing asks a human, so there is nothing to show. `auto`
1424
+ * answers with the option the agent's own kinds imply ({@link pickAutoOption})
1425
+ * and the agent CONTINUES. A mode that cannot pick an option (no options, or
1426
+ * none with a usable id) degrades to refusing: safer than guessing, and
1427
+ * hanging is worse than either.
1428
+ */
1429
+ private settleExpiredAsk(
1430
+ session: LiveSession,
1431
+ mode: PermissionTimeoutMode,
1432
+ toolCall: unknown,
1433
+ options: PermissionOption[],
1434
+ timeoutMs: number,
1435
+ ): RequestPermissionResponse {
1436
+ const outcome = this.announceExpiredAsk(session, mode, toolCall, options, timeoutMs)
1437
+ return outcome.outcome === 'selected'
1438
+ ? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
1439
+ : { outcome: { outcome: 'cancelled' } }
1607
1440
  }
1608
1441
 
1609
1442
  private settlePermissions(session: LiveSession): void {
@@ -1643,7 +1476,7 @@ export class AgentBridge {
1643
1476
  * cannot reach into the agent's own process, so a second gate here would only
1644
1477
  * be a fiction in front of a tool that can write without asking us at all.
1645
1478
  * What the agent chooses to ask about goes to the human unchanged
1646
- * (`askNativeApproval`). See docs/acp-convergence.md §2.3.
1479
+ * (`handlePermissionRequest`). See docs/acp-convergence.md §2.3.
1647
1480
  */
1648
1481
  private async handleFsWrite(params: { sessionId: string; path: string; content: string }): Promise<{ }> {
1649
1482
  const session = this.acpIndex.get(params.sessionId)
@@ -1760,12 +1593,11 @@ const APPROVAL_REASON_INPUT_KEYS = [
1760
1593
  ] as const
1761
1594
 
1762
1595
  /**
1763
- * One line saying WHAT the native approval card is about.
1596
+ * One line saying WHAT the permission ask is about.
1764
1597
  *
1765
- * WHY: the host renders `reason ?? "工具 <name> 请求越权执行"`. Without a
1766
- * reason the human only sees the tool's NAME ("Bash") and cannot judge the
1767
- * request; the ACP tool call already declares the missing half — its title and
1768
- * (for execute/edit tools) a short scalar in `rawInput`. Only ONE declared
1598
+ * WHY: without it the human only sees the tool's NAME ("Bash") and cannot
1599
+ * judge the request; the ACP tool call already declares the missing half — its
1600
+ * title and (for execute/edit tools) a short scalar in `rawInput`. Only ONE declared
1769
1601
  * string is used, never a serialized object or a whole file, and the line is
1770
1602
  * capped so a pathological input cannot fill the card or the audit log.
1771
1603
  */
@@ -1798,22 +1630,6 @@ export function permissionAskReason(toolCall: unknown): string {
1798
1630
  return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line
1799
1631
  }
1800
1632
 
1801
- /**
1802
- * Translate a native approval outcome into the ACP option the agent must see.
1803
- * `'allowed-once'` is the chain's only grant; a rejection prefers the once-variant.
1804
- */
1805
- function nativeOutcomeResponse(outcome: ApprovalOutcome, options: PermissionOption[]): RequestPermissionResponse {
1806
- if (outcome === 'allowed-once') {
1807
- const allow = options.find(o => o.kind === 'allow_once') ?? options.find(o => o.kind === 'allow_always')
1808
- return allow === undefined ? { outcome: { outcome: 'cancelled' } } : { outcome: { outcome: 'selected', optionId: allow.optionId } }
1809
- }
1810
- if (outcome === 'rejected') {
1811
- const reject = options.find(o => o.kind === 'reject_once') ?? options.find(o => o.kind === 'reject_always')
1812
- return reject === undefined ? { outcome: { outcome: 'cancelled' } } : { outcome: { outcome: 'selected', optionId: reject.optionId } }
1813
- }
1814
- return { outcome: { outcome: 'cancelled' } }
1815
- }
1816
-
1817
1633
  function snapshot(session: LiveSession): SessionView {
1818
1634
  return {
1819
1635
  sessionId: session.sessionId,
@@ -2628,7 +2444,6 @@ class TurnStreamController {
2628
2444
  constructor(
2629
2445
  private readonly manager: BridgeManager,
2630
2446
  private readonly hub: EventHub,
2631
- private readonly bridge: AgentBridge,
2632
2447
  readonly session: LiveSession,
2633
2448
  readonly turnId: string,
2634
2449
  /** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
@@ -2840,7 +2655,7 @@ class TurnStreamController {
2840
2655
  // segment; the event that CUTS the segment belongs to the next one.
2841
2656
  if (this.foldInto(seg, update)) this.cursorSeq = typed.seq
2842
2657
  } else if (row.type === 'permission/request') {
2843
- const typed = event as { toolCall: unknown; requestId: string }
2658
+ const typed = event as Extract<AcpEvent, { type: 'permission/request' }>
2844
2659
  this.pushProse(this.permissionLine(typed))
2845
2660
  } else if (row.type === 'permission/expired') {
2846
2661
  const typed = event as { toolCall: unknown; timeoutMs: number; outcome: PermissionOutcome; optionName?: string }
@@ -2999,29 +2814,28 @@ class TurnStreamController {
2999
2814
  })
3000
2815
  }
3001
2816
 
3002
- private permissionLine(event: { toolCall: unknown; requestId: string }): string {
3003
- const title = typeof (event.toolCall as { title?: unknown } | undefined)?.title === 'string'
3004
- ? String((event.toolCall as { title: string }).title)
3005
- : 'permission'
3006
- // The line must not point at a place with no answer entry, and the bridge
3007
- // knows which path this ask took (`ledgerFallbackOf`, recorded before the
3008
- // frame was broadcast). On the ledger paths the settings panel's card is
3009
- // the answer entry. A DEGRADED native route has to say why the
3010
- // conversation is not showing the question — as an OBSERVED FACT, not a
3011
- // guessed cause.
3012
- //
2817
+ private permissionLine(event: Extract<AcpEvent, { type: 'permission/request' }>): string {
2818
+ // `toolName` is host-derived (`permissionToolName`) so agents that omit
2819
+ // `title` and declare it in `_meta` still name the operation here.
2820
+ const title = event.toolName
2821
+ // The line must point at the place the answer entry actually is: a
2822
+ // chat-bound ask renders its card in the conversation; an `/acp`-RPC ask
2823
+ // has no conversation, so its card lives on the settings page.
2824
+ const lead = event.dshSessionId !== undefined
2825
+ ? 'please answer the permission card above the composer'
2826
+ : 'please approve in Settings'
3013
2827
  // The last clause says what happens if nobody answers, and it follows the
3014
2828
  // MODE: refused, answered automatically, or (in `wait`) nothing at all.
3015
- const fallback = this.bridge.ledgerFallbackOf(event.requestId)
3016
- const why = fallback?.reason === 'unavailable' ? 'the approval service is unavailable' : undefined
3017
- const lead = why === undefined ? 'please approve in Settings' : `${why}; approve in Settings`
3018
- if (fallback === undefined || fallback.timeoutMs <= 0 || fallback.mode === 'wait') {
2829
+ if (event.timeoutMode === 'wait' || event.deadlineAt === undefined) {
3019
2830
  return `\n> 🔐 [Permission Required: ${title}] - ${lead}\n`
3020
2831
  }
3021
- const otherwise = fallback.mode === 'auto'
2832
+ const remainingMs = Math.max(0, event.deadlineAt - Date.now())
2833
+ // Floor (+1 ms for the Date.now tick), min 1: rounding up would promise time already spent.
2834
+ const remainingS = Math.max(1, Math.floor((remainingMs + 1) / 1000))
2835
+ const otherwise = event.timeoutMode === 'auto'
3022
2836
  ? 'this request is answered automatically'
3023
2837
  : 'this request is cancelled'
3024
- return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(Math.round(fallback.timeoutMs / 1000))}s or ${otherwise}\n`
2838
+ return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(remainingS)}s or ${otherwise}\n`
3025
2839
  }
3026
2840
 
3027
2841
  /**
@@ -3033,9 +2847,7 @@ class TurnStreamController {
3033
2847
  * answered with the option the agent's own kinds implied.
3034
2848
  */
3035
2849
  private expiredLine(event: { toolCall: unknown; timeoutMs: number; outcome: PermissionOutcome; optionName?: string }): string {
3036
- const title = typeof (event.toolCall as { title?: unknown } | undefined)?.title === 'string'
3037
- ? String((event.toolCall as { title: string }).title)
3038
- : 'permission'
2850
+ const title = permissionToolName(event.toolCall)
3039
2851
  const waited = event.timeoutMs > 0
3040
2852
  ? `no answer within ${String(Math.round(event.timeoutMs / 1000))}s`
3041
2853
  : 'not waited for'
@@ -3212,30 +3024,14 @@ export class BridgeManager {
3212
3024
  onCatalog?: (agentId: string, configOptions: unknown, shapeOnly?: boolean) => void,
3213
3025
  onCommands?: (agentId: string, commands: NonNullable<SessionView['availableCommands']>) => void,
3214
3026
  ): AgentBridge {
3215
- return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.approvalRequester, () => this.sessionWorkspaceResolver)
3027
+ return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver)
3216
3028
  }
3217
3029
 
3218
3030
  /**
3219
- * Route ACP permission asks through the host approval service (answered in
3220
- * the chat composer). Absent → every ask stays on the settings-panel ledger.
3221
- * Setting it also flips {@link approvalRouting}, which the browser reads to
3222
- * stop rendering the settings-panel permission card.
3223
- */
3224
- setApprovalRequester(requester?: ApprovalRequester): void {
3225
- this.approvalRequester = requester
3226
- }
3227
-
3228
- /** True when permission asks go to native approval instead of the settings panel. */
3229
- approvalRouting(): boolean {
3230
- return this.approvalRequester !== undefined
3231
- }
3232
-
3233
- /**
3234
- * Every outstanding ledger ask, across all agents, with the view the browser
3235
- * was shown it — the source of the reconnect replay (see
3236
- * `permissionReplayFrames`). Asks on the native route are deliberately
3237
- * absent: they broadcast nothing and their answer entry is the composer
3238
- * takeover the client already holds.
3031
+ * Every outstanding ask, across all agents, with the view the browser was
3032
+ * shown it — the source of the reconnect replay (see
3033
+ * `permissionReplayFrames`). Every ACP ask is a ledger entry, so this is the
3034
+ * whole live set.
3239
3035
  */
3240
3036
  pendingPermissionViews(): PendingPermissionView[] {
3241
3037
  const views: PendingPermissionView[] = []
@@ -5193,11 +4989,6 @@ export class BridgeManager {
5193
4989
  if (session.sessionId === sessionId) this.dshSessionMap.delete(key)
5194
4990
  }
5195
4991
  }
5196
- /**
5197
- * Latest native-approval requester. Held as a field (read through a thunk by
5198
- * each bridge) so a requester wired after bridges were built still applies.
5199
- */
5200
- private approvalRequester?: ApprovalRequester
5201
4992
  private sessionWorkspaceResolver?: SessionWorkspaceResolver
5202
4993
  private acpModeResolver?: AcpModeResolver
5203
4994
  /** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
@@ -5330,7 +5121,7 @@ export class BridgeManager {
5330
5121
  if (existing !== undefined && existing.released) {
5331
5122
  const resumed = await this.bridge(agentId).resumeSession(existing)
5332
5123
  // Rebind explicitly: a resume keeps the record's identity, but the chat
5333
- // session id is what native approval routing keys off.
5124
+ // session id is what the conversation's permission card addresses.
5334
5125
  resumed.dshSessionId = dshSessionId
5335
5126
  // A resume without an explicit pick must keep serving the agent the
5336
5127
  // conversation was ON (its own model string when one was recorded). The
@@ -5369,7 +5160,7 @@ export class BridgeManager {
5369
5160
  workspaceRoot,
5370
5161
  )
5371
5162
  // Absent for `/acp`-RPC sessions and for adapter calls without a chat
5372
- // session; those deliberately keep the ledger path (see askNativeApproval).
5163
+ // session; their permission cards are addressed by the ACP session only.
5373
5164
  session.dshSessionId = dshSessionId
5374
5165
  this.dshSessionMap.set(key, session)
5375
5166
  // Turn the displayed defaults into this conversation's pick, so the first
@@ -5651,7 +5442,6 @@ export class BridgeManager {
5651
5442
  const controller = new TurnStreamController(
5652
5443
  this,
5653
5444
  this.hub,
5654
- bridge,
5655
5445
  session,
5656
5446
  turnId,
5657
5447
  endTurn,