dsh-ntr 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/impl.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { randomUUID } from './ids.ts'
9
9
  import type {
10
10
  AcpAgentCatalog, AcpAgentDef, AcpCatalogOption, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPluginConfig, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult,
11
- AgentView, ApprovalDeadlineKey, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, PermissionOptionView, PermissionOutcome, RpcResult,
11
+ AgentView, ApprovalDeadlineKey, ApprovalDeadlinePatch, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, PermissionOptionView, PermissionOutcome, PermissionTimeoutMode, RpcResult,
12
12
  } from './protocol.ts'
13
13
  import { DEFAULT_TAG_HALO } from './protocol.ts'
14
14
 
@@ -103,6 +103,41 @@ export function validateTagHalo(value: unknown): AcpTagHalo {
103
103
  */
104
104
  export const APPROVAL_DEADLINE_MAX_MS = 86_400_000
105
105
 
106
+ /**
107
+ * The shortest REAL wait: the first rung of the card's ladder (30 seconds).
108
+ *
109
+ * The floor for a mode that must not wait zero — see
110
+ * {@link normalizeApprovalWindow}.
111
+ */
112
+ export const APPROVAL_DEADLINE_MIN_WAIT_MS = 30_000
113
+
114
+ /**
115
+ * Make a window legal for the mode it runs under.
116
+ *
117
+ * `0` means 不等待 — the ask is settled the moment it arrives instead of
118
+ * waiting. That is the **auto** mode's business: `auto` answers with the
119
+ * option the agent's own kinds imply, so the agent CONTINUES, and an
120
+ * unattended deployment gets a sane default instead of a hang.
121
+ *
122
+ * Under `abort` the same zero would refuse every ask the instant it arrived:
123
+ * the agent stops at every gate it meets, which is not a timeout, it is a
124
+ * brick wall. So `abort` never offers 不等待, and a window of `0` reaching it
125
+ * (a config file, a state file written by an older build, a mode switch that
126
+ * left the window behind) normalizes to the shortest real wait.
127
+ *
128
+ * `wait` ignores the window entirely — it never expires.
129
+ */
130
+ export function normalizeApprovalWindow(mode: PermissionTimeoutMode, ms: number): number {
131
+ return mode === 'abort' && ms === 0 ? APPROVAL_DEADLINE_MIN_WAIT_MS : ms
132
+ }
133
+
134
+ /**
135
+ * The order `auto` mode picks an option in, by the kind the agent declared:
136
+ * the broadest grant first, then the narrow one, then the refusals, then
137
+ * whatever the agent offered instead. See {@link pickAutoOption}.
138
+ */
139
+ export const AUTO_OPTION_KIND_PRIORITY = ['allow_always', 'allow_once', 'reject_once', 'reject_always'] as const
140
+
106
141
  /**
107
142
  * Validate ONE approval deadline, shared by every layer that accepts one.
108
143
  *
@@ -122,35 +157,62 @@ export function validateApprovalDeadlineMs(raw: unknown, label: string, code: 'b
122
157
 
123
158
  /**
124
159
  * Validate a settings-page deadline write. Omitted keys are simply absent from
125
- * the result — the manager merges, so a card that only touched one slider must
126
- * not reset the other. `reset` is not a deadline and is dropped here.
160
+ * the result — the manager merges, so a card that only moved the mode must not
161
+ * reset the number. `reset` is not a deadline and is dropped here.
127
162
  */
128
163
  export function validateApprovalDeadlines(value: unknown): Partial<Record<ApprovalDeadlineKey, number>> {
129
164
  if (value === null || typeof value !== 'object') throw new AcpError('bad_payload', 'acp: approval deadlines must be an object')
130
- const { nativeApprovalTimeoutMs, permissionFallbackTimeoutMs } = value as {
131
- nativeApprovalTimeoutMs?: unknown
132
- permissionFallbackTimeoutMs?: unknown
133
- }
165
+ const { permissionTimeoutMs } = value as { permissionTimeoutMs?: unknown }
134
166
  const out: Partial<Record<ApprovalDeadlineKey, number>> = {}
135
- if (nativeApprovalTimeoutMs !== undefined) {
136
- out.nativeApprovalTimeoutMs = validateApprovalDeadlineMs(
137
- nativeApprovalTimeoutMs,
138
- 'nativeApprovalTimeoutMs',
167
+ if (permissionTimeoutMs !== undefined) {
168
+ out.permissionTimeoutMs = validateApprovalDeadlineMs(
169
+ permissionTimeoutMs,
170
+ 'permissionTimeoutMs',
139
171
  'bad_payload',
140
- '(0 = wait for the host approval chain forever)',
141
- )
142
- }
143
- if (permissionFallbackTimeoutMs !== undefined) {
144
- out.permissionFallbackTimeoutMs = validateApprovalDeadlineMs(
145
- permissionFallbackTimeoutMs,
146
- 'permissionFallbackTimeoutMs',
147
- 'bad_payload',
148
- '(0 = never answer a degraded ask on the human\'s behalf)',
172
+ '(0 = 不等待,仅自动推进档可用)',
149
173
  )
150
174
  }
151
175
  return out
152
176
  }
153
177
 
178
+ /**
179
+ * Validate the mode a write asks for. Three values, and the list is the whole
180
+ * vocabulary: an unanswered ask is either answered for you, kept waiting, or
181
+ * refused. Anything else is a typo, not a new mode.
182
+ */
183
+ export function validatePermissionTimeoutMode(raw: unknown, code: 'bad_config' | 'bad_payload'): PermissionTimeoutMode {
184
+ if (raw !== 'abort' && raw !== 'auto' && raw !== 'wait') {
185
+ throw new AcpError(code, 'acp: permissionTimeoutMode must be one of "abort", "auto", "wait"')
186
+ }
187
+ return raw
188
+ }
189
+
190
+ /**
191
+ * The option an expired deadline answers with in `auto` mode.
192
+ *
193
+ * The pick is by the option's KIND, because that is what the agent declared
194
+ * about the choice it is offering: `allow_always` first (one grant covers the
195
+ * rest of the run), then `allow_once` (the smaller grant), then the reject
196
+ * kinds (refuse but let the agent continue), and finally the first option
197
+ * whatever it is — an agent may offer only a custom kind, and refusing to
198
+ * answer at all would hang the turn.
199
+ *
200
+ * `undefined` means NO option could be read (no options, or none with a usable
201
+ * id). The caller then degrades to `abort`: guessing an option is worse than
202
+ * refusing, and hanging is worse than either. OmniTerm dev picks the same way
203
+ * (`pick_auto_option`, `src/acp/permission.rs`).
204
+ */
205
+ export function pickAutoOption(options: readonly PermissionOptionView[]): PermissionOptionView | undefined {
206
+ const usable = (option: PermissionOptionView): PermissionOptionView | undefined =>
207
+ option.optionId === '' ? undefined : option
208
+ for (const kind of AUTO_OPTION_KIND_PRIORITY) {
209
+ const hit = options.find(option => option.kind === kind)
210
+ const picked = hit === undefined ? undefined : usable(hit)
211
+ if (picked !== undefined) return picked
212
+ }
213
+ return options.length === 0 ? undefined : usable(options[0] as PermissionOptionView)
214
+ }
215
+
154
216
  /**
155
217
  * Bound on the number of MCP servers one agent may declare. An MCP server is
156
218
  * a process (stdio) or a remote endpoint the agent connects to at session
@@ -437,27 +499,30 @@ export function validateConfig(raw: unknown): AcpPluginConfig {
437
499
  if (typeof startupTimeoutMs !== 'number' || !Number.isFinite(startupTimeoutMs) || startupTimeoutMs < 1_000 || startupTimeoutMs > 600_000) {
438
500
  throw new AcpError('bad_config', 'acp: startupTimeoutMs must be a number in [1000, 600000]')
439
501
  }
440
- const permissionTimeoutMs = value.permissionTimeoutMs ?? 0
441
- if (typeof permissionTimeoutMs !== 'number' || !Number.isInteger(permissionTimeoutMs) || permissionTimeoutMs < 0 || permissionTimeoutMs > APPROVAL_DEADLINE_MAX_MS) {
442
- throw new AcpError('bad_config', `acp: permissionTimeoutMs must be an integer in [0, ${String(APPROVAL_DEADLINE_MAX_MS)}] (0 = wait forever)`)
443
- }
444
- // Both degraded-route deadlines share the `permissionTimeoutMs` range: `0` is
445
- // the meaningful value (never withdraw / never auto-cancel), and anything up
446
- // to a day is a legitimate "ask again later" window. Same validator as the
447
- // settings-page write, so a baseline the panel can display is a baseline the
448
- // panel can also produce.
449
- const nativeApprovalTimeoutMs = validateApprovalDeadlineMs(
450
- value.nativeApprovalTimeoutMs ?? 300_000,
451
- 'nativeApprovalTimeoutMs',
452
- 'bad_config',
453
- '(0 = wait for the host approval chain forever)',
454
- )
455
- const permissionFallbackTimeoutMs = validateApprovalDeadlineMs(
456
- value.permissionFallbackTimeoutMs ?? 300_000,
457
- 'permissionFallbackTimeoutMs',
502
+ // ONE wait window for both routes. The two legacy keys are folded in when
503
+ // the new one is absent, so an existing `cordis.patch.yml` keeps meaning what
504
+ // it meant instead of silently falling back to the default.
505
+ const legacyWindow = value.nativeApprovalTimeoutMs ?? value.permissionFallbackTimeoutMs
506
+ // A legacy window of 0 meant "this window never fires". The only way to say
507
+ // that now is the `wait` MODE (0 is 不等待 today), so a legacy zero carries
508
+ // over as the mode — otherwise an unattended deployment would silently turn
509
+ // "wait forever" into "answer immediately".
510
+ const legacyNever = legacyWindow === 0
511
+ const permissionTimeoutMs = validateApprovalDeadlineMs(
512
+ legacyNever ? 300_000 : (value.permissionTimeoutMs ?? legacyWindow ?? 300_000),
513
+ 'permissionTimeoutMs',
458
514
  'bad_config',
459
- '(0 = never answer a degraded ask on the human\'s behalf)',
515
+ '(0 = 不等待,仅自动推进档可用)',
460
516
  )
517
+ // What an expired deadline DOES. Same validator as the settings-page write,
518
+ // so a mode the panel can offer is a mode the next boot accepts.
519
+ const permissionTimeoutMode = legacyNever
520
+ ? 'wait'
521
+ : validatePermissionTimeoutMode(value.permissionTimeoutMode ?? 'wait', 'bad_config')
522
+ // 不等待 belongs to `auto` (it answers, so the agent continues). Under
523
+ // `abort` a zero window would refuse every ask on arrival — the agent stops
524
+ // at every gate — so it becomes the shortest real wait.
525
+ const effectivePermissionTimeoutMs = normalizeApprovalWindow(permissionTimeoutMode, permissionTimeoutMs)
461
526
  const cancelFallbackMs = value.cancelFallbackMs ?? 15_000
462
527
  if (typeof cancelFallbackMs !== 'number' || !Number.isInteger(cancelFallbackMs) || cancelFallbackMs < 0 || cancelFallbackMs > 600_000) {
463
528
  throw new AcpError('bad_config', 'acp: cancelFallbackMs must be an integer in [0, 600000] (0 = disable the fallback)')
@@ -475,9 +540,8 @@ export function validateConfig(raw: unknown): AcpPluginConfig {
475
540
  transcriptMaxEntries: max,
476
541
  probeModelsOnBoot,
477
542
  startupTimeoutMs,
478
- permissionTimeoutMs,
479
- nativeApprovalTimeoutMs,
480
- permissionFallbackTimeoutMs,
543
+ permissionTimeoutMs: effectivePermissionTimeoutMs,
544
+ permissionTimeoutMode,
481
545
  cancelFallbackMs,
482
546
  staleCancelRetryDelayMs,
483
547
  staleCancelMaxRetries,
@@ -1515,6 +1579,22 @@ export class EventHub {
1515
1579
 
1516
1580
  // ---- Permission ledger ----
1517
1581
 
1582
+ /** Everything a `permission/request` frame carries besides the ids the ledger owns. */
1583
+ export interface PendingPermissionFields {
1584
+ /** DSH chat session id; absent for `/acp` RPC-only sessions. */
1585
+ dshSessionId?: string
1586
+ toolCall: unknown
1587
+ /** Host-derived human name (`permissionToolName`). */
1588
+ toolName: string
1589
+ /** One-line detail from the agent's declared input, when it has any. */
1590
+ reason?: string
1591
+ options: PermissionOptionView[]
1592
+ /** What an unanswered ask does when its deadline fires. */
1593
+ timeoutMode: PermissionTimeoutMode
1594
+ /** Epoch ms of the deadline; absent in `wait` mode (no deadline). */
1595
+ deadlineAt?: number
1596
+ }
1597
+
1518
1598
  export interface PendingPermission {
1519
1599
  requestId: string
1520
1600
  sessionId: string
@@ -1524,15 +1604,13 @@ export interface PendingPermission {
1524
1604
  * client receives (see `permissionReplayFrames`). Absent on entries created
1525
1605
  * without a view — those still answer, they just cannot be re-shown.
1526
1606
  */
1527
- view?: { toolCall: unknown; options: PermissionOptionView[] }
1607
+ view?: PendingPermissionFields
1528
1608
  }
1529
1609
 
1530
1610
  /** One outstanding ask as a reconnecting client must be re-shown it. */
1531
- export interface PendingPermissionView {
1611
+ export interface PendingPermissionView extends PendingPermissionFields {
1532
1612
  requestId: string
1533
1613
  sessionId: string
1534
- toolCall: unknown
1535
- options: PermissionOptionView[]
1536
1614
  }
1537
1615
 
1538
1616
  export class PermissionLedger {
@@ -1584,14 +1662,37 @@ export class PermissionLedger {
1584
1662
  views.push({
1585
1663
  requestId: entry.requestId,
1586
1664
  sessionId: entry.sessionId,
1587
- toolCall: entry.view.toolCall,
1588
- options: entry.view.options,
1665
+ ...entry.view,
1589
1666
  })
1590
1667
  }
1591
1668
  return views
1592
1669
  }
1593
1670
  }
1594
1671
 
1672
+ /**
1673
+ * The `permission/request` frame for one ask.
1674
+ *
1675
+ * ONE builder for both the live broadcast and the reconnect replay, so the two
1676
+ * can never drift: a client that reconnects must receive exactly the frame the
1677
+ * live one carried (its conversation card filters on `dshSessionId` and shows
1678
+ * `toolName` / `deadlineAt`, so a replay missing them would silently drop the
1679
+ * ask).
1680
+ */
1681
+ export function permissionRequestFrame(view: PendingPermissionView): AcpEvent {
1682
+ return {
1683
+ type: 'permission/request',
1684
+ requestId: view.requestId,
1685
+ sessionId: view.sessionId,
1686
+ ...(view.dshSessionId === undefined ? {} : { dshSessionId: view.dshSessionId }),
1687
+ toolCall: view.toolCall,
1688
+ toolName: view.toolName,
1689
+ ...(view.reason === undefined ? {} : { reason: view.reason }),
1690
+ options: view.options,
1691
+ timeoutMode: view.timeoutMode,
1692
+ ...(view.deadlineAt === undefined ? {} : { deadlineAt: view.deadlineAt }),
1693
+ }
1694
+ }
1695
+
1595
1696
  /**
1596
1697
  * Frames that re-establish a freshly connected client's view of the pending
1597
1698
  * permission asks: every outstanding ask re-sent as its original
@@ -1599,10 +1700,10 @@ export class PermissionLedger {
1599
1700
  *
1600
1701
  * Why a replay at all: a `permission/request` is broadcast ONCE, when the ask is
1601
1702
  * created. A client that connects later — page reload, second tab, stream
1602
- * outage — never receives it, and for a ledger ask (the `/acp` RPC path, or a
1603
- * native route that degraded) that card is the ONLY answer entry, so the ask is
1604
- * invisible to exactly the client that needs it. OmniTerm closes the same hole
1605
- * with `PermissionManager::pending_events()` + a `PermissionsSynced` marker on
1703
+ * outage — never receives it, and that card (conversation or settings ledger)
1704
+ * is the ONLY answer entry for the ask, so the ask is invisible to exactly the
1705
+ * client that needs it. OmniTerm closes the same hole with
1706
+ * `PermissionManager::pending_events()` + a `PermissionsSynced` marker on
1606
1707
  * every WS connect.
1607
1708
  *
1608
1709
  * Why the terminator is mandatory: the replay is a snapshot, so an ask resolved
@@ -1611,13 +1712,7 @@ export class PermissionLedger {
1611
1712
  * `respond` would answer `unknown_request`.
1612
1713
  */
1613
1714
  export function permissionReplayFrames(pending: readonly PendingPermissionView[]): AcpEvent[] {
1614
- const requests: AcpEvent[] = pending.map(p => ({
1615
- type: 'permission/request' as const,
1616
- requestId: p.requestId,
1617
- sessionId: p.sessionId,
1618
- toolCall: p.toolCall,
1619
- options: p.options,
1620
- }))
1715
+ const requests: AcpEvent[] = pending.map(permissionRequestFrame)
1621
1716
  return [...requests, { type: 'permission/synced', requestIds: pending.map(p => p.requestId) }]
1622
1717
  }
1623
1718
 
@@ -1650,18 +1745,18 @@ export interface AcpManager {
1650
1745
  /** Write the halo preference (persisted with the agent registry). */
1651
1746
  setTagHalo?(halo: AcpTagHalo): Promise<void>
1652
1747
  /**
1653
- * The approval deadlines in force, with the facts that explain an override
1654
- * (the `cordis.patch.yml` baseline it outranks, and a `permissionTimeoutMs`
1655
- * that overrides both). `undefined` on a host half from before the card
1656
- * existed, which hides the settings card instead of guessing a value.
1748
+ * The approval deadline in force, with the facts that explain an override
1749
+ * (the `cordis.patch.yml` baseline it outranks). `undefined` on a host half
1750
+ * from before the card existed, which hides the settings card instead of
1751
+ * guessing a value.
1657
1752
  */
1658
1753
  approvalDeadlines?(): AcpApprovalDeadlines | undefined
1659
1754
  /**
1660
- * Write one or both approval deadlines (omitted keys keep their value), or
1661
- * drop the persisted override with `reset` so the config-file baseline
1755
+ * Write the approval deadline, or the mode (omitted keys keep their value),
1756
+ * or drop the persisted override with `reset` so the config-file baseline
1662
1757
  * applies again. Applies to the running bridges without a restart.
1663
1758
  */
1664
- setApprovalDeadlines?(patch: Partial<Record<ApprovalDeadlineKey, number>>, reset?: boolean): Promise<void>
1759
+ setApprovalDeadlines?(patch: ApprovalDeadlinePatch, reset?: boolean): Promise<void>
1665
1760
  detectAgents?(): Promise<DetectedAgentInfo[]>
1666
1761
  saveAgent?(def: AcpAgentDef): Promise<void>
1667
1762
  deleteAgent?(agentId: string): Promise<void>
@@ -1697,12 +1792,6 @@ export interface AcpManager {
1697
1792
  * cache (newly published models appear, withdrawn ones drop out).
1698
1793
  */
1699
1794
  cachedAgentModels?(agentId: string): AgentProbeModelsResult | undefined
1700
- /**
1701
- * True when ACP permission asks are routed to the host's native approval
1702
- * service (answered in the chat composer) instead of the settings-panel
1703
- * ledger. Read-only capability report for `agents/list`.
1704
- */
1705
- approvalRouting?(): boolean
1706
1795
  /**
1707
1796
  * ACP agent sessions backing one DSH chat session (looked up by chat session
1708
1797
  * id), so the composer can offer the agent's OWN mode list. Empty when no
@@ -1817,12 +1906,7 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
1817
1906
  try {
1818
1907
  switch (endpoint) {
1819
1908
  case 'agents/list':
1820
- // `approvalRouted` is a capability report, not a command: the browser
1821
- // uses it to decide whether the settings panel still owns approvals.
1822
- return ok({
1823
- agents: manager.agentViews(),
1824
- approvalRouted: manager.approvalRouting?.() ?? false,
1825
- })
1909
+ return ok({ agents: manager.agentViews() })
1826
1910
  case 'agents/detect':
1827
1911
  return ok({ agents: await (manager.detectAgents?.() ?? Promise.resolve([])) })
1828
1912
  case 'agent/save': {
@@ -1985,10 +2069,10 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
1985
2069
  // renders nothing rather than a number it cannot trust.
1986
2070
  return ok({ deadlines: manager.approvalDeadlines?.() })
1987
2071
  case 'ui/approval-deadlines-set': {
1988
- const patch = validateApprovalDeadlines({
1989
- nativeApprovalTimeoutMs: body.nativeApprovalTimeoutMs,
1990
- permissionFallbackTimeoutMs: body.permissionFallbackTimeoutMs,
1991
- })
2072
+ const patch: ApprovalDeadlinePatch = {
2073
+ ...validateApprovalDeadlines({ permissionTimeoutMs: body.permissionTimeoutMs }),
2074
+ ...(body.mode === undefined ? {} : { mode: validatePermissionTimeoutMode(body.mode, 'bad_payload') }),
2075
+ }
1992
2076
  await manager.setApprovalDeadlines?.(patch, body.reset === true)
1993
2077
  return ok({ deadlines: manager.approvalDeadlines?.() })
1994
2078
  }
package/src/index.ts CHANGED
@@ -11,32 +11,15 @@ import type { ConnectionRpcHandler, HostConnectionHandle } from '@deepseek-ai/ds
11
11
  import { ACP_PRESET_ID, AcpLlmAdapter, BridgeManager } from './bridge.ts'
12
12
  import { EventHub, dispatchRpc, permissionReplayFrames, sessionAgentPreset, sessionAgentPresetFromLog, validateConfig } from './impl.ts'
13
13
  import type { AcpEvent, ApprovalDeadlineKey } from './protocol.ts'
14
- import type { ApprovalOutcome } from './bridge.ts'
15
14
  import type { RosterPresetRow, SessionLogLike } from './impl.ts'
16
15
 
17
16
  export const name = 'dsh-ntr'
18
- // `approval` / `agents` are deliberately NOT listed here: they are optional
19
- // collaborators probed structurally below. A hard dependency would make the
20
- // whole plugin fail to load on a deployment without them (see the LLM service
21
- // probe, which follows the same shape).
17
+ // Deliberately minimal: every other host service is probed structurally
18
+ // (`ctx.get` + a late `ctx.inject`) so a deployment without one still loads
19
+ // this plugin. ACP permission asks are answered through this plugin's own
20
+ // ledger, so the host approval service is not a collaborator at all.
22
21
  export const inject = ['webServer', 'connection']
23
22
 
24
- /** Structural view of the host approval service (`@deepseek-ai/dsh-user-approval`). */
25
- interface ApprovalServiceLike {
26
- request(req: { agent: unknown; toolName: string; reason?: string; signal?: AbortSignal }): Promise<ApprovalOutcome>
27
- /**
28
- * Present on the host service, but NEVER read by this plugin: the only call
29
- * made here is `request` (see `wireApproval`). A deployment-wide default
30
- * policy is the host's own business.
31
- */
32
- config?: { policy?: string }
33
- }
34
-
35
- /** Structural view of the host agent registry (`ctx.agents`), keyed by chat session id. */
36
- interface AgentsServiceLike {
37
- get(sessionId: string): unknown | undefined
38
- }
39
-
40
23
  /** Structural view of the host sandbox policy service (`@deepseek-ai/dsh-sandbox-policy`). */
41
24
  interface SandboxPolicyLike {
42
25
  resolve(request: { session?: unknown }): { mode?: unknown; workspaceRoot?: unknown }
@@ -99,8 +82,7 @@ export function apply(ctx: Context, config?: unknown): void {
99
82
  {
100
83
  startupTimeoutMs: resolved.startupTimeoutMs,
101
84
  permissionTimeoutMs: resolved.permissionTimeoutMs,
102
- nativeApprovalTimeoutMs: resolved.nativeApprovalTimeoutMs,
103
- permissionFallbackTimeoutMs: resolved.permissionFallbackTimeoutMs,
85
+ permissionTimeoutMode: resolved.permissionTimeoutMode,
104
86
  cancelFallbackMs: resolved.cancelFallbackMs,
105
87
  staleCancelRetryDelayMs: resolved.staleCancelRetryDelayMs,
106
88
  staleCancelMaxRetries: resolved.staleCancelMaxRetries,
@@ -115,11 +97,20 @@ export function apply(ctx: Context, config?: unknown): void {
115
97
  // returns to). Only the keys the file actually names are declared.
116
98
  const rawConfig = (config ?? {}) as Record<string, unknown>
117
99
  const configuredDeadlines: Partial<Record<ApprovalDeadlineKey, number>> = {}
118
- for (const key of ['nativeApprovalTimeoutMs', 'permissionFallbackTimeoutMs'] as const) {
119
- const value = rawConfig[key]
120
- if (typeof value === 'number' && Number.isFinite(value)) configuredDeadlines[key] = value
100
+ // The window the file actually named — the new key, or the legacy one it
101
+ // folded into (same order `validateConfig` resolves them in, so the card's
102
+ // baseline and the running value never disagree).
103
+ const rawWindow = rawConfig.permissionTimeoutMs
104
+ ?? rawConfig.nativeApprovalTimeoutMs
105
+ ?? rawConfig.permissionFallbackTimeoutMs
106
+ if (typeof rawWindow === 'number' && Number.isFinite(rawWindow)) {
107
+ configuredDeadlines.permissionTimeoutMs = rawWindow
121
108
  }
122
- manager.setConfiguredApprovalDeadlines(configuredDeadlines)
109
+ // The mode is declared the same way: only when the file actually names it.
110
+ const configuredMode = rawConfig.permissionTimeoutMode === 'abort' || rawConfig.permissionTimeoutMode === 'auto' || rawConfig.permissionTimeoutMode === 'wait'
111
+ ? rawConfig.permissionTimeoutMode
112
+ : undefined
113
+ manager.setConfiguredApprovalDeadlines(configuredDeadlines, configuredMode)
123
114
  // Boot hook kept only so this entry point's shape does not change:
124
115
  // `bootstrapCatalogs` is intentionally empty — boot never spawns an agent,
125
116
  // in any environment and for any `probeModelsOnBoot` value. Catalogs fill
@@ -158,34 +149,6 @@ export function apply(ctx: Context, config?: unknown): void {
158
149
  ctx.inject(['agentPresets'], () => { wirePresetRoster() })
159
150
  }
160
151
 
161
- /**
162
- * Native approval routing: answer ACP `session/request_permission` through
163
- * the host approval chain so the question takes over the chat composer
164
- * (`ApprovalPanel`) instead of the settings panel.
165
- *
166
- * `approval.request` needs the live agent object (its session carries the
167
- * audit pair, and the ask must land inside an open turn) and the chat
168
- * session id our adapter already knows. Any missing piece — service absent,
169
- * unknown session, a rejecting service — degrades to `'unavailable'`, which
170
- * sends the ask back to the ledger/panel path rather than losing it.
171
- */
172
- const wireApproval = (): void => {
173
- const approval = (typeof ctx.get === 'function' ? ctx.get('approval') : (ctx as unknown as { approval?: ApprovalServiceLike }).approval) as ApprovalServiceLike | undefined
174
- const agents = (typeof ctx.get === 'function' ? ctx.get('agents') : (ctx as unknown as { agents?: AgentsServiceLike }).agents) as AgentsServiceLike | undefined
175
- if (approval === undefined || agents === undefined) return
176
- manager.setApprovalRequester(async ({ dshSessionId, toolName, signal }) => {
177
- if (dshSessionId === undefined) return 'unavailable'
178
- const agent = agents.get(dshSessionId)
179
- if (agent === undefined) return 'unavailable'
180
- try {
181
- return await approval.request({ agent, toolName, ...(signal === undefined ? {} : { signal }) })
182
- } catch {
183
- // Never propagate: a failed ask must fall back, not break the turn.
184
- return 'unavailable'
185
- }
186
- })
187
- }
188
-
189
152
  /**
190
153
  * Workspace routing: the sidebar's selected workspace IS the chat session's
191
154
  * workspace, so the agent's session and its process both run there (see
@@ -274,11 +237,10 @@ export function apply(ctx: Context, config?: unknown): void {
274
237
  }
275
238
  }
276
239
 
277
- wireApproval()
278
240
  wireWorkspace()
279
241
  if (typeof ctx.inject === 'function') {
280
- // Late arrival for the workspace collaborators; kept separate from the
281
- // approval probe so a deployment missing one service still wires the other.
242
+ // Late arrival for the workspace collaborators: a deployment missing one
243
+ // service must not fail this plugin's activation.
282
244
  ctx.inject(['sandboxPolicy', 'sessions'], () => {
283
245
  wireWorkspace()
284
246
  })
@@ -291,13 +253,6 @@ export function apply(ctx: Context, config?: unknown): void {
291
253
  wireAcpMode()
292
254
  })
293
255
  }
294
- if (typeof ctx.inject === 'function') {
295
- // Late arrival: both services may still be loading when this plugin
296
- // applies (profile order is not ours to control).
297
- ctx.inject(['approval', 'agents'], () => {
298
- wireApproval()
299
- })
300
- }
301
256
 
302
257
  /** Registration handle: callable disposer, plus `replace` to re-announce routes. */
303
258
  interface LlmAdapterHandle {