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/lib/impl.js CHANGED
@@ -95,6 +95,38 @@ export function validateTagHalo(value) {
95
95
  * longer would be a mis-typed number rather than a deliberate window.
96
96
  */
97
97
  export const APPROVAL_DEADLINE_MAX_MS = 86_400_000;
98
+ /**
99
+ * The shortest REAL wait: the first rung of the card's ladder (30 seconds).
100
+ *
101
+ * The floor for a mode that must not wait zero — see
102
+ * {@link normalizeApprovalWindow}.
103
+ */
104
+ export const APPROVAL_DEADLINE_MIN_WAIT_MS = 30_000;
105
+ /**
106
+ * Make a window legal for the mode it runs under.
107
+ *
108
+ * `0` means 不等待 — the ask is settled the moment it arrives instead of
109
+ * waiting. That is the **auto** mode's business: `auto` answers with the
110
+ * option the agent's own kinds imply, so the agent CONTINUES, and an
111
+ * unattended deployment gets a sane default instead of a hang.
112
+ *
113
+ * Under `abort` the same zero would refuse every ask the instant it arrived:
114
+ * the agent stops at every gate it meets, which is not a timeout, it is a
115
+ * brick wall. So `abort` never offers 不等待, and a window of `0` reaching it
116
+ * (a config file, a state file written by an older build, a mode switch that
117
+ * left the window behind) normalizes to the shortest real wait.
118
+ *
119
+ * `wait` ignores the window entirely — it never expires.
120
+ */
121
+ export function normalizeApprovalWindow(mode, ms) {
122
+ return mode === 'abort' && ms === 0 ? APPROVAL_DEADLINE_MIN_WAIT_MS : ms;
123
+ }
124
+ /**
125
+ * The order `auto` mode picks an option in, by the kind the agent declared:
126
+ * the broadest grant first, then the narrow one, then the refusals, then
127
+ * whatever the agent offered instead. See {@link pickAutoOption}.
128
+ */
129
+ export const AUTO_OPTION_KIND_PRIORITY = ['allow_always', 'allow_once', 'reject_once', 'reject_always'];
98
130
  /**
99
131
  * Validate ONE approval deadline, shared by every layer that accepts one.
100
132
  *
@@ -113,22 +145,55 @@ export function validateApprovalDeadlineMs(raw, label, code, hint) {
113
145
  }
114
146
  /**
115
147
  * Validate a settings-page deadline write. Omitted keys are simply absent from
116
- * the result — the manager merges, so a card that only touched one slider must
117
- * not reset the other. `reset` is not a deadline and is dropped here.
148
+ * the result — the manager merges, so a card that only moved the mode must not
149
+ * reset the number. `reset` is not a deadline and is dropped here.
118
150
  */
119
151
  export function validateApprovalDeadlines(value) {
120
152
  if (value === null || typeof value !== 'object')
121
153
  throw new AcpError('bad_payload', 'acp: approval deadlines must be an object');
122
- const { nativeApprovalTimeoutMs, permissionFallbackTimeoutMs } = value;
154
+ const { permissionTimeoutMs } = value;
123
155
  const out = {};
124
- if (nativeApprovalTimeoutMs !== undefined) {
125
- out.nativeApprovalTimeoutMs = validateApprovalDeadlineMs(nativeApprovalTimeoutMs, 'nativeApprovalTimeoutMs', 'bad_payload', '(0 = wait for the host approval chain forever)');
126
- }
127
- if (permissionFallbackTimeoutMs !== undefined) {
128
- out.permissionFallbackTimeoutMs = validateApprovalDeadlineMs(permissionFallbackTimeoutMs, 'permissionFallbackTimeoutMs', 'bad_payload', '(0 = never answer a degraded ask on the human\'s behalf)');
156
+ if (permissionTimeoutMs !== undefined) {
157
+ out.permissionTimeoutMs = validateApprovalDeadlineMs(permissionTimeoutMs, 'permissionTimeoutMs', 'bad_payload', '(0 = 不等待,仅自动推进档可用)');
129
158
  }
130
159
  return out;
131
160
  }
161
+ /**
162
+ * Validate the mode a write asks for. Three values, and the list is the whole
163
+ * vocabulary: an unanswered ask is either answered for you, kept waiting, or
164
+ * refused. Anything else is a typo, not a new mode.
165
+ */
166
+ export function validatePermissionTimeoutMode(raw, code) {
167
+ if (raw !== 'abort' && raw !== 'auto' && raw !== 'wait') {
168
+ throw new AcpError(code, 'acp: permissionTimeoutMode must be one of "abort", "auto", "wait"');
169
+ }
170
+ return raw;
171
+ }
172
+ /**
173
+ * The option an expired deadline answers with in `auto` mode.
174
+ *
175
+ * The pick is by the option's KIND, because that is what the agent declared
176
+ * about the choice it is offering: `allow_always` first (one grant covers the
177
+ * rest of the run), then `allow_once` (the smaller grant), then the reject
178
+ * kinds (refuse but let the agent continue), and finally the first option
179
+ * whatever it is — an agent may offer only a custom kind, and refusing to
180
+ * answer at all would hang the turn.
181
+ *
182
+ * `undefined` means NO option could be read (no options, or none with a usable
183
+ * id). The caller then degrades to `abort`: guessing an option is worse than
184
+ * refusing, and hanging is worse than either. OmniTerm dev picks the same way
185
+ * (`pick_auto_option`, `src/acp/permission.rs`).
186
+ */
187
+ export function pickAutoOption(options) {
188
+ const usable = (option) => option.optionId === '' ? undefined : option;
189
+ for (const kind of AUTO_OPTION_KIND_PRIORITY) {
190
+ const hit = options.find(option => option.kind === kind);
191
+ const picked = hit === undefined ? undefined : usable(hit);
192
+ if (picked !== undefined)
193
+ return picked;
194
+ }
195
+ return options.length === 0 ? undefined : usable(options[0]);
196
+ }
132
197
  /**
133
198
  * Bound on the number of MCP servers one agent may declare. An MCP server is
134
199
  * a process (stdio) or a remote endpoint the agent connects to at session
@@ -418,17 +483,25 @@ export function validateConfig(raw) {
418
483
  if (typeof startupTimeoutMs !== 'number' || !Number.isFinite(startupTimeoutMs) || startupTimeoutMs < 1_000 || startupTimeoutMs > 600_000) {
419
484
  throw new AcpError('bad_config', 'acp: startupTimeoutMs must be a number in [1000, 600000]');
420
485
  }
421
- const permissionTimeoutMs = value.permissionTimeoutMs ?? 0;
422
- if (typeof permissionTimeoutMs !== 'number' || !Number.isInteger(permissionTimeoutMs) || permissionTimeoutMs < 0 || permissionTimeoutMs > APPROVAL_DEADLINE_MAX_MS) {
423
- throw new AcpError('bad_config', `acp: permissionTimeoutMs must be an integer in [0, ${String(APPROVAL_DEADLINE_MAX_MS)}] (0 = wait forever)`);
424
- }
425
- // Both degraded-route deadlines share the `permissionTimeoutMs` range: `0` is
426
- // the meaningful value (never withdraw / never auto-cancel), and anything up
427
- // to a day is a legitimate "ask again later" window. Same validator as the
428
- // settings-page write, so a baseline the panel can display is a baseline the
429
- // panel can also produce.
430
- const nativeApprovalTimeoutMs = validateApprovalDeadlineMs(value.nativeApprovalTimeoutMs ?? 300_000, 'nativeApprovalTimeoutMs', 'bad_config', '(0 = wait for the host approval chain forever)');
431
- const permissionFallbackTimeoutMs = validateApprovalDeadlineMs(value.permissionFallbackTimeoutMs ?? 300_000, 'permissionFallbackTimeoutMs', 'bad_config', '(0 = never answer a degraded ask on the human\'s behalf)');
486
+ // ONE wait window for both routes. The two legacy keys are folded in when
487
+ // the new one is absent, so an existing `cordis.patch.yml` keeps meaning what
488
+ // it meant instead of silently falling back to the default.
489
+ const legacyWindow = value.nativeApprovalTimeoutMs ?? value.permissionFallbackTimeoutMs;
490
+ // A legacy window of 0 meant "this window never fires". The only way to say
491
+ // that now is the `wait` MODE (0 is 不等待 today), so a legacy zero carries
492
+ // over as the mode — otherwise an unattended deployment would silently turn
493
+ // "wait forever" into "answer immediately".
494
+ const legacyNever = legacyWindow === 0;
495
+ const permissionTimeoutMs = validateApprovalDeadlineMs(legacyNever ? 300_000 : (value.permissionTimeoutMs ?? legacyWindow ?? 300_000), 'permissionTimeoutMs', 'bad_config', '(0 = 不等待,仅自动推进档可用)');
496
+ // What an expired deadline DOES. Same validator as the settings-page write,
497
+ // so a mode the panel can offer is a mode the next boot accepts.
498
+ const permissionTimeoutMode = legacyNever
499
+ ? 'wait'
500
+ : validatePermissionTimeoutMode(value.permissionTimeoutMode ?? 'wait', 'bad_config');
501
+ // 不等待 belongs to `auto` (it answers, so the agent continues). Under
502
+ // `abort` a zero window would refuse every ask on arrival — the agent stops
503
+ // at every gate — so it becomes the shortest real wait.
504
+ const effectivePermissionTimeoutMs = normalizeApprovalWindow(permissionTimeoutMode, permissionTimeoutMs);
432
505
  const cancelFallbackMs = value.cancelFallbackMs ?? 15_000;
433
506
  if (typeof cancelFallbackMs !== 'number' || !Number.isInteger(cancelFallbackMs) || cancelFallbackMs < 0 || cancelFallbackMs > 600_000) {
434
507
  throw new AcpError('bad_config', 'acp: cancelFallbackMs must be an integer in [0, 600000] (0 = disable the fallback)');
@@ -446,9 +519,8 @@ export function validateConfig(raw) {
446
519
  transcriptMaxEntries: max,
447
520
  probeModelsOnBoot,
448
521
  startupTimeoutMs,
449
- permissionTimeoutMs,
450
- nativeApprovalTimeoutMs,
451
- permissionFallbackTimeoutMs,
522
+ permissionTimeoutMs: effectivePermissionTimeoutMs,
523
+ permissionTimeoutMode,
452
524
  cancelFallbackMs,
453
525
  staleCancelRetryDelayMs,
454
526
  staleCancelMaxRetries,
@@ -1441,13 +1513,35 @@ export class PermissionLedger {
1441
1513
  views.push({
1442
1514
  requestId: entry.requestId,
1443
1515
  sessionId: entry.sessionId,
1444
- toolCall: entry.view.toolCall,
1445
- options: entry.view.options,
1516
+ ...entry.view,
1446
1517
  });
1447
1518
  }
1448
1519
  return views;
1449
1520
  }
1450
1521
  }
1522
+ /**
1523
+ * The `permission/request` frame for one ask.
1524
+ *
1525
+ * ONE builder for both the live broadcast and the reconnect replay, so the two
1526
+ * can never drift: a client that reconnects must receive exactly the frame the
1527
+ * live one carried (its conversation card filters on `dshSessionId` and shows
1528
+ * `toolName` / `deadlineAt`, so a replay missing them would silently drop the
1529
+ * ask).
1530
+ */
1531
+ export function permissionRequestFrame(view) {
1532
+ return {
1533
+ type: 'permission/request',
1534
+ requestId: view.requestId,
1535
+ sessionId: view.sessionId,
1536
+ ...(view.dshSessionId === undefined ? {} : { dshSessionId: view.dshSessionId }),
1537
+ toolCall: view.toolCall,
1538
+ toolName: view.toolName,
1539
+ ...(view.reason === undefined ? {} : { reason: view.reason }),
1540
+ options: view.options,
1541
+ timeoutMode: view.timeoutMode,
1542
+ ...(view.deadlineAt === undefined ? {} : { deadlineAt: view.deadlineAt }),
1543
+ };
1544
+ }
1451
1545
  /**
1452
1546
  * Frames that re-establish a freshly connected client's view of the pending
1453
1547
  * permission asks: every outstanding ask re-sent as its original
@@ -1455,10 +1549,10 @@ export class PermissionLedger {
1455
1549
  *
1456
1550
  * Why a replay at all: a `permission/request` is broadcast ONCE, when the ask is
1457
1551
  * created. A client that connects later — page reload, second tab, stream
1458
- * outage — never receives it, and for a ledger ask (the `/acp` RPC path, or a
1459
- * native route that degraded) that card is the ONLY answer entry, so the ask is
1460
- * invisible to exactly the client that needs it. OmniTerm closes the same hole
1461
- * with `PermissionManager::pending_events()` + a `PermissionsSynced` marker on
1552
+ * outage — never receives it, and that card (conversation or settings ledger)
1553
+ * is the ONLY answer entry for the ask, so the ask is invisible to exactly the
1554
+ * client that needs it. OmniTerm closes the same hole with
1555
+ * `PermissionManager::pending_events()` + a `PermissionsSynced` marker on
1462
1556
  * every WS connect.
1463
1557
  *
1464
1558
  * Why the terminator is mandatory: the replay is a snapshot, so an ask resolved
@@ -1467,13 +1561,7 @@ export class PermissionLedger {
1467
1561
  * `respond` would answer `unknown_request`.
1468
1562
  */
1469
1563
  export function permissionReplayFrames(pending) {
1470
- const requests = pending.map(p => ({
1471
- type: 'permission/request',
1472
- requestId: p.requestId,
1473
- sessionId: p.sessionId,
1474
- toolCall: p.toolCall,
1475
- options: p.options,
1476
- }));
1564
+ const requests = pending.map(permissionRequestFrame);
1477
1565
  return [...requests, { type: 'permission/synced', requestIds: pending.map(p => p.requestId) }];
1478
1566
  }
1479
1567
  function ok(value) {
@@ -1507,12 +1595,7 @@ export async function dispatchRpc(manager, endpoint, payload) {
1507
1595
  try {
1508
1596
  switch (endpoint) {
1509
1597
  case 'agents/list':
1510
- // `approvalRouted` is a capability report, not a command: the browser
1511
- // uses it to decide whether the settings panel still owns approvals.
1512
- return ok({
1513
- agents: manager.agentViews(),
1514
- approvalRouted: manager.approvalRouting?.() ?? false,
1515
- });
1598
+ return ok({ agents: manager.agentViews() });
1516
1599
  case 'agents/detect':
1517
1600
  return ok({ agents: await (manager.detectAgents?.() ?? Promise.resolve([])) });
1518
1601
  case 'agent/save': {
@@ -1683,10 +1766,10 @@ export async function dispatchRpc(manager, endpoint, payload) {
1683
1766
  // renders nothing rather than a number it cannot trust.
1684
1767
  return ok({ deadlines: manager.approvalDeadlines?.() });
1685
1768
  case 'ui/approval-deadlines-set': {
1686
- const patch = validateApprovalDeadlines({
1687
- nativeApprovalTimeoutMs: body.nativeApprovalTimeoutMs,
1688
- permissionFallbackTimeoutMs: body.permissionFallbackTimeoutMs,
1689
- });
1769
+ const patch = {
1770
+ ...validateApprovalDeadlines({ permissionTimeoutMs: body.permissionTimeoutMs }),
1771
+ ...(body.mode === undefined ? {} : { mode: validatePermissionTimeoutMode(body.mode, 'bad_payload') }),
1772
+ };
1690
1773
  await manager.setApprovalDeadlines?.(patch, body.reset === true);
1691
1774
  return ok({ deadlines: manager.approvalDeadlines?.() });
1692
1775
  }
package/lib/index.js CHANGED
@@ -7,10 +7,10 @@
7
7
  import { ACP_PRESET_ID, AcpLlmAdapter, BridgeManager } from "./bridge.js";
8
8
  import { EventHub, dispatchRpc, permissionReplayFrames, sessionAgentPreset, sessionAgentPresetFromLog, validateConfig } from "./impl.js";
9
9
  export const name = 'dsh-ntr';
10
- // `approval` / `agents` are deliberately NOT listed here: they are optional
11
- // collaborators probed structurally below. A hard dependency would make the
12
- // whole plugin fail to load on a deployment without them (see the LLM service
13
- // probe, which follows the same shape).
10
+ // Deliberately minimal: every other host service is probed structurally
11
+ // (`ctx.get` + a late `ctx.inject`) so a deployment without one still loads
12
+ // this plugin. ACP permission asks are answered through this plugin's own
13
+ // ledger, so the host approval service is not a collaborator at all.
14
14
  export const inject = ['webServer', 'connection'];
15
15
  /** How many stored events the cold-read folds before giving up on scanning. */
16
16
  const COLD_READ_MAX_EVENTS = 4096;
@@ -24,8 +24,7 @@ export function apply(ctx, config) {
24
24
  const manager = new BridgeManager(resolved.agents, hub, resolved.transcriptMaxEntries ?? 600, undefined, undefined, resolved.probeModelsOnBoot ?? true, {
25
25
  startupTimeoutMs: resolved.startupTimeoutMs,
26
26
  permissionTimeoutMs: resolved.permissionTimeoutMs,
27
- nativeApprovalTimeoutMs: resolved.nativeApprovalTimeoutMs,
28
- permissionFallbackTimeoutMs: resolved.permissionFallbackTimeoutMs,
27
+ permissionTimeoutMode: resolved.permissionTimeoutMode,
29
28
  cancelFallbackMs: resolved.cancelFallbackMs,
30
29
  staleCancelRetryDelayMs: resolved.staleCancelRetryDelayMs,
31
30
  staleCancelMaxRetries: resolved.staleCancelMaxRetries,
@@ -39,12 +38,20 @@ export function apply(ctx, config) {
39
38
  // returns to). Only the keys the file actually names are declared.
40
39
  const rawConfig = (config ?? {});
41
40
  const configuredDeadlines = {};
42
- for (const key of ['nativeApprovalTimeoutMs', 'permissionFallbackTimeoutMs']) {
43
- const value = rawConfig[key];
44
- if (typeof value === 'number' && Number.isFinite(value))
45
- configuredDeadlines[key] = value;
41
+ // The window the file actually named — the new key, or the legacy one it
42
+ // folded into (same order `validateConfig` resolves them in, so the card's
43
+ // baseline and the running value never disagree).
44
+ const rawWindow = rawConfig.permissionTimeoutMs
45
+ ?? rawConfig.nativeApprovalTimeoutMs
46
+ ?? rawConfig.permissionFallbackTimeoutMs;
47
+ if (typeof rawWindow === 'number' && Number.isFinite(rawWindow)) {
48
+ configuredDeadlines.permissionTimeoutMs = rawWindow;
46
49
  }
47
- manager.setConfiguredApprovalDeadlines(configuredDeadlines);
50
+ // The mode is declared the same way: only when the file actually names it.
51
+ const configuredMode = rawConfig.permissionTimeoutMode === 'abort' || rawConfig.permissionTimeoutMode === 'auto' || rawConfig.permissionTimeoutMode === 'wait'
52
+ ? rawConfig.permissionTimeoutMode
53
+ : undefined;
54
+ manager.setConfiguredApprovalDeadlines(configuredDeadlines, configuredMode);
48
55
  // Boot hook kept only so this entry point's shape does not change:
49
56
  // `bootstrapCatalogs` is intentionally empty — boot never spawns an agent,
50
57
  // in any environment and for any `probeModelsOnBoot` value. Catalogs fill
@@ -79,37 +86,6 @@ export function apply(ctx, config) {
79
86
  // Late arrival: the registry may still be loading when this plugin applies.
80
87
  ctx.inject(['agentPresets'], () => { wirePresetRoster(); });
81
88
  }
82
- /**
83
- * Native approval routing: answer ACP `session/request_permission` through
84
- * the host approval chain so the question takes over the chat composer
85
- * (`ApprovalPanel`) instead of the settings panel.
86
- *
87
- * `approval.request` needs the live agent object (its session carries the
88
- * audit pair, and the ask must land inside an open turn) and the chat
89
- * session id our adapter already knows. Any missing piece — service absent,
90
- * unknown session, a rejecting service — degrades to `'unavailable'`, which
91
- * sends the ask back to the ledger/panel path rather than losing it.
92
- */
93
- const wireApproval = () => {
94
- const approval = (typeof ctx.get === 'function' ? ctx.get('approval') : ctx.approval);
95
- const agents = (typeof ctx.get === 'function' ? ctx.get('agents') : ctx.agents);
96
- if (approval === undefined || agents === undefined)
97
- return;
98
- manager.setApprovalRequester(async ({ dshSessionId, toolName, signal }) => {
99
- if (dshSessionId === undefined)
100
- return 'unavailable';
101
- const agent = agents.get(dshSessionId);
102
- if (agent === undefined)
103
- return 'unavailable';
104
- try {
105
- return await approval.request({ agent, toolName, ...(signal === undefined ? {} : { signal }) });
106
- }
107
- catch {
108
- // Never propagate: a failed ask must fall back, not break the turn.
109
- return 'unavailable';
110
- }
111
- });
112
- };
113
89
  /**
114
90
  * Workspace routing: the sidebar's selected workspace IS the chat session's
115
91
  * workspace, so the agent's session and its process both run there (see
@@ -203,11 +179,10 @@ export function apply(ctx, config) {
203
179
  return undefined;
204
180
  }
205
181
  };
206
- wireApproval();
207
182
  wireWorkspace();
208
183
  if (typeof ctx.inject === 'function') {
209
- // Late arrival for the workspace collaborators; kept separate from the
210
- // approval probe so a deployment missing one service still wires the other.
184
+ // Late arrival for the workspace collaborators: a deployment missing one
185
+ // service must not fail this plugin's activation.
211
186
  ctx.inject(['sandboxPolicy', 'sessions'], () => {
212
187
  wireWorkspace();
213
188
  });
@@ -220,13 +195,6 @@ export function apply(ctx, config) {
220
195
  wireAcpMode();
221
196
  });
222
197
  }
223
- if (typeof ctx.inject === 'function') {
224
- // Late arrival: both services may still be loading when this plugin
225
- // applies (profile order is not ours to control).
226
- ctx.inject(['approval', 'agents'], () => {
227
- wireApproval();
228
- });
229
- }
230
198
  const registerLlm = (llmHandle) => {
231
199
  ctx.effect(() => {
232
200
  // The route exists so the host's driver can reach this adapter at all: