dsh-ntr 0.1.4 → 0.1.5

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,17 +12,70 @@ 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, } from "./impl.js";
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";
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`. */
19
19
  const FS_MAX_READ_BYTES = 2 * 1024 * 1024;
20
20
  /** Single fs/write_text_file payload cap. */
21
21
  const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024;
22
+ /**
23
+ * The plugin's normalized MCP server shape → the ACP wire shape.
24
+ *
25
+ * THE WIRE SHAPE IS ASYMMETRIC, and getting it wrong is SILENT:
26
+ *
27
+ * - the stdio variant carries NO `type` field — an entry that carries
28
+ * `type:'stdio'` is accepted by the agent and then never connected
29
+ * (measured against codebuddy: the server never appeared, the agent just
30
+ * reported the tool as missing);
31
+ * - http/sse DO carry `type`;
32
+ * - `env` / `headers` are `[{name,value}]` arrays on the wire, while the
33
+ * plugin stores them as records (like `AcpAgentDef.env`).
34
+ *
35
+ * So the conversion lives here, once, and each branch is annotated with the
36
+ * SDK's OWN variant type — a wrong conversion does not compile. (The bare
37
+ * `McpServer` union is too loose to catch it: its excess-property check
38
+ * accepts any member's keys, so a stdio entry carrying `type:'stdio'` would
39
+ * type-check against the http variant and only fail at runtime, inside the
40
+ * agent, as a server that never connects.)
41
+ */
42
+ function toAcpMcpServers(defs) {
43
+ if (defs === undefined)
44
+ return [];
45
+ return defs.map(def => {
46
+ if (def.transport === 'stdio') {
47
+ // NO `type` here — see the note above.
48
+ const server = {
49
+ name: def.name,
50
+ command: def.command,
51
+ args: def.args ?? [],
52
+ env: Object.entries(def.env ?? {}).map(([name, value]) => ({ name, value })),
53
+ };
54
+ return server;
55
+ }
56
+ if (def.transport === 'http') {
57
+ const server = {
58
+ type: 'http',
59
+ name: def.name,
60
+ url: def.url,
61
+ headers: Object.entries(def.headers ?? {}).map(([name, value]) => ({ name, value })),
62
+ };
63
+ return server;
64
+ }
65
+ const server = {
66
+ type: 'sse',
67
+ name: def.name,
68
+ url: def.url,
69
+ headers: Object.entries(def.headers ?? {}).map(([name, value]) => ({ name, value })),
70
+ };
71
+ return server;
72
+ });
73
+ }
22
74
  const DEFAULT_TUNING = {
23
75
  startupTimeoutMs: 60_000,
24
76
  permissionTimeoutMs: 0,
25
77
  permissionFallbackTimeoutMs: 300_000,
78
+ nativeApprovalTimeoutMs: 300_000,
26
79
  cancelFallbackMs: 15_000,
27
80
  staleCancelRetryDelayMs: 600,
28
81
  staleCancelMaxRetries: 2,
@@ -64,7 +117,18 @@ export class AgentBridge {
64
117
  ledgerFallbacks = new Map;
65
118
  startupTimeoutMs;
66
119
  permissionTimeoutMs;
120
+ /**
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.
129
+ */
67
130
  permissionFallbackTimeoutMs;
131
+ nativeApprovalTimeoutMs;
68
132
  cancelFallbackMs;
69
133
  staleCancelRetryDelayMs;
70
134
  staleCancelMaxRetries;
@@ -133,6 +197,7 @@ export class AgentBridge {
133
197
  this.startupTimeoutMs = merged.startupTimeoutMs;
134
198
  this.permissionTimeoutMs = merged.permissionTimeoutMs;
135
199
  this.permissionFallbackTimeoutMs = merged.permissionFallbackTimeoutMs;
200
+ this.nativeApprovalTimeoutMs = merged.nativeApprovalTimeoutMs;
136
201
  this.cancelFallbackMs = merged.cancelFallbackMs;
137
202
  this.staleCancelRetryDelayMs = merged.staleCancelRetryDelayMs;
138
203
  this.staleCancelMaxRetries = merged.staleCancelMaxRetries;
@@ -140,6 +205,22 @@ export class AgentBridge {
140
205
  this.defaultIdleTimeoutMs = merged.idleTimeoutMs;
141
206
  this.probeIdleTimeoutMs = merged.probeIdleTimeoutMs;
142
207
  }
208
+ /**
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.
213
+ *
214
+ * Deliberately does NOT re-arm anything: an ask that already holds a timer
215
+ * keeps the window it was started with, and the next ask reads the new value.
216
+ * Re-arming would move the goalposts under a human who is already deciding.
217
+ */
218
+ applyApprovalDeadlines(patch) {
219
+ if (patch.nativeApprovalTimeoutMs !== undefined)
220
+ this.nativeApprovalTimeoutMs = patch.nativeApprovalTimeoutMs;
221
+ if (patch.permissionFallbackTimeoutMs !== undefined)
222
+ this.permissionFallbackTimeoutMs = patch.permissionFallbackTimeoutMs;
223
+ }
143
224
  /**
144
225
  * The idle-release window this agent honors for its CHAT process: its own
145
226
  * field, else the host default. This is the number the panel prefills and
@@ -528,7 +609,7 @@ export class AgentBridge {
528
609
  if (conn === undefined)
529
610
  throw new AcpError('agent_unreachable', 'acp: connection is not open');
530
611
  const resolved = resolveSessionCwd(this.def, cwd, p => realpathSync(p), extraRoots);
531
- const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: [] }), 'session/new');
612
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/new');
532
613
  // An empty `configOptions` answer is a non-answer — see `usableConfigOptions`.
533
614
  if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
534
615
  this.onCatalog(this.def.id, result.configOptions);
@@ -573,6 +654,15 @@ export class AgentBridge {
573
654
  * Spawn a throwaway ACP session purely to read its `configOptions` (the
574
655
  * model catalog probe). Deliberately bypasses the live-session bookkeeping
575
656
  * so probe sessions never surface in the browser session list.
657
+ *
658
+ * THE PROBE SENDS THE AGENT'S OWN MCP LIST, not an empty one. Measured
659
+ * against codebuddy: the servers handed to `session/new` land in the
660
+ * agent PROCESS's dynamic MCP scope, so a probe that sent an empty list
661
+ * would REPLACE that scope and drop the MCP servers of the live sessions
662
+ * on the same connection (the agent logged
663
+ * `serverManager.delete("injected")` and the live session's
664
+ * `mcp__injected__*` tools disappeared). One list per agent process is the
665
+ * only shape that keeps every session of that process consistent.
576
666
  */
577
667
  async probeConfigOptions() {
578
668
  // A probe is the reason an agent process can be resident with NOTHING else
@@ -585,7 +675,7 @@ export class AgentBridge {
585
675
  if (conn === undefined)
586
676
  throw new AcpError('agent_unreachable', 'acp: connection is not open');
587
677
  const resolved = resolveSessionCwd(this.def, undefined, p => realpathSync(p));
588
- const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: [] }), 'session/new');
678
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/new');
589
679
  await conn.agent.request(methods.agent.session.close, { sessionId: result.sessionId }).catch(() => { });
590
680
  return Array.isArray(result.configOptions) ? result.configOptions : [];
591
681
  }
@@ -640,6 +730,13 @@ export class AgentBridge {
640
730
  // own turn apart from a later one.
641
731
  session.cancelRequestedAtTurn = session.turnSeq;
642
732
  await this.conn.agent.notify(methods.agent.session.cancel, { sessionId: session.acpSessionId }).catch(() => { });
733
+ // The ACP spec's counterpart to the notification we just sent: a client that
734
+ // cancels MUST answer every pending `session/request_permission` with
735
+ // `cancelled`. Leaving them open would hold the agent's tool call blocked on
736
+ // an answer that can no longer arrive — the turn is over, the human's
737
+ // decision is "stop". OmniTerm does the same from `AcpClient::cancel`
738
+ // (`PermissionManager::cancel_all`).
739
+ this.settlePermissions(session);
643
740
  this.armCancelFallback(session);
644
741
  }
645
742
  /**
@@ -733,7 +830,7 @@ export class AgentBridge {
733
830
  // is in flight, and let the browser drop its blocks/watermark first.
734
831
  this.acpIndex.set(session.acpSessionId, session);
735
832
  this.hub.broadcast({ type: 'session/reset', sessionId: session.sessionId, session: snapshot(session) });
736
- const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.load, { sessionId: session.acpSessionId, cwd: resolved, mcpServers: [] }), 'session/load');
833
+ const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.load, { sessionId: session.acpSessionId, cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/load');
737
834
  if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
738
835
  this.onCatalog(this.def.id, result.configOptions);
739
836
  }
@@ -971,6 +1068,11 @@ export class AgentBridge {
971
1068
  resolve(outcome.outcome === 'selected'
972
1069
  ? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
973
1070
  : { outcome: { outcome: 'cancelled' } });
1071
+ }, {
1072
+ // Kept for the reconnect replay: a client that connects after the ask
1073
+ // was broadcast must be re-shown the very card it has to answer.
1074
+ toolCall,
1075
+ options: options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
974
1076
  });
975
1077
  // Deadline (`permissionTimeoutMs`): an approval left unanswered is
976
1078
  // answered `cancelled` on the human's behalf instead of hanging the turn
@@ -978,14 +1080,22 @@ export class AgentBridge {
978
1080
  // exception is a DEGRADED native route: the conversation just failed to
979
1081
  // show the question, so waiting forever would hang the turn behind an
980
1082
  // ask nobody can see there, and the fallback deadline applies instead
981
- // (see `BridgeTuning.permissionFallbackTimeoutMs`).
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.
982
1086
  const timeoutMs = this.permissionTimeoutMs > 0
983
1087
  ? this.permissionTimeoutMs
984
- : native.reason === 'unavailable' ? this.permissionFallbackTimeoutMs : 0;
1088
+ : native.reason === 'unavailable' || native.reason === 'native-timeout'
1089
+ ? this.permissionFallbackTimeoutMs
1090
+ : 0;
985
1091
  // Recorded before the broadcast: the hub is synchronous, and the chat
986
1092
  // line `streamPrompt` folds from this frame words itself by the path
987
1093
  // this ask actually took.
988
- this.ledgerFallbacks.set(requestId, { reason: native.reason, timeoutMs });
1094
+ this.ledgerFallbacks.set(requestId, {
1095
+ reason: native.reason,
1096
+ timeoutMs,
1097
+ ...(native.reason === 'native-timeout' ? { waitedMs: this.nativeApprovalTimeoutMs } : {}),
1098
+ });
989
1099
  if (timeoutMs > 0) {
990
1100
  timer = setTimeout(() => {
991
1101
  if (!this.permissions.has(requestId))
@@ -994,7 +1104,10 @@ export class AgentBridge {
994
1104
  type: 'agent/notice',
995
1105
  agentId: this.def.id,
996
1106
  level: 'info',
997
- text: `acp: permission request timed out after ${String(Math.round(timeoutMs / 1000))}s; answered "cancelled"`,
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"`,
998
1111
  });
999
1112
  this.permissions.respond(requestId, { outcome: 'cancelled' });
1000
1113
  }, timeoutMs);
@@ -1025,8 +1138,19 @@ export class AgentBridge {
1025
1138
  * open turn, so a session started over `/acp` (no chat turn) cannot be
1026
1139
  * routed and must stay on the ledger path.
1027
1140
  *
1028
- * No `permissionTimeoutMs` timer is armed here on purpose: the human answers
1029
- * inside the conversation, and a host-side deadline would answer for them.
1141
+ * The ask is raced against {@link BridgeTuning.nativeApprovalTimeoutMs}. A
1142
+ * routed ask has exactly one answer entry — the composer takeover — and no
1143
+ * broadcast at all, so a host that takes the question and never renders it
1144
+ * 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.
1150
+ *
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.
1030
1154
  *
1031
1155
  * @returns the agent-facing response, or the reason the ask must fall back
1032
1156
  * to the ledger (see {@link LedgerFallbackReason}).
@@ -1037,20 +1161,52 @@ export class AgentBridge {
1037
1161
  return { reason: 'no-requester' };
1038
1162
  if (session.dshSessionId === undefined)
1039
1163
  return { reason: 'no-chat-session' };
1164
+ // One signal carries both withdrawals: the agent's own cancel (whatever
1165
+ // aborted the ACP request) and the deadline. The host's waterfall races its
1166
+ // answerers against this signal, so aborting is a clean withdrawal rather
1167
+ // than a leak.
1168
+ const withdraw = new AbortController();
1169
+ const askSignal = signal === undefined ? withdraw.signal : AbortSignal.any([signal, withdraw.signal]);
1170
+ let timer;
1171
+ let timedOut = false;
1172
+ const deadline = this.nativeApprovalTimeoutMs > 0
1173
+ ? new Promise(resolve => {
1174
+ timer = setTimeout(() => {
1175
+ timedOut = true;
1176
+ withdraw.abort();
1177
+ resolve({ timedOut: true });
1178
+ }, this.nativeApprovalTimeoutMs);
1179
+ timer.unref?.();
1180
+ })
1181
+ : undefined;
1040
1182
  let outcome;
1041
1183
  try {
1042
- outcome = await requester({
1184
+ const asked = requester({
1043
1185
  dshSessionId: session.dshSessionId,
1044
1186
  agentId: this.def.id,
1045
1187
  toolName: permissionToolName(toolCall),
1046
- ...(signal === undefined ? {} : { signal }),
1188
+ signal: askSignal,
1047
1189
  });
1190
+ // A withdrawn ask settles on the host's own schedule; dropping it here
1191
+ // must not surface as an unhandled rejection later.
1192
+ asked.catch(() => { });
1193
+ const settled = deadline === undefined
1194
+ ? await asked.then(o => ({ outcome: o }))
1195
+ : await Promise.race([asked.then(o => ({ outcome: o })), deadline]);
1196
+ // The flag, not the race winner, decides: the host may settle `cancelled`
1197
+ // from the very abort the deadline caused, and that is still a timeout.
1198
+ if (timedOut || 'timedOut' in settled)
1199
+ return { reason: 'native-timeout' };
1200
+ outcome = settled.outcome;
1048
1201
  }
1049
1202
  catch {
1050
1203
  // Synchronous throw or rejection, either way: the ask falls back to the
1051
1204
  // ledger. An approval failure must never break the agent's turn.
1052
1205
  outcome = 'unavailable';
1053
1206
  }
1207
+ finally {
1208
+ clearTimeout(timer);
1209
+ }
1054
1210
  if (outcome === 'unavailable')
1055
1211
  return { reason: 'unavailable' };
1056
1212
  const response = nativeOutcomeResponse(outcome, options);
@@ -1179,9 +1335,31 @@ export class AgentBridge {
1179
1335
  }
1180
1336
  }
1181
1337
  /** Human-facing identity of an ACP tool call (the approval headline). */
1182
- function permissionToolName(toolCall) {
1338
+ /**
1339
+ * Human-facing name of the tool an ask is about, for the chat line and the
1340
+ * timeout notice.
1341
+ *
1342
+ * The ACP `toolCall.title` is the declared field, but it is OPTIONAL and real
1343
+ * agents omit it: codebuddy puts the name in `_meta["codebuddy.ai/toolName"]`
1344
+ * (observed live — without the fallback the notice reads `for "tool call"`,
1345
+ * which tells the human nothing about what they missed). So read `title` first,
1346
+ * then any `_meta` key whose last segment is `toolName`, then the standard
1347
+ * `kind`, and only then give up with the generic phrase. Nothing is invented:
1348
+ * every step reads a key the agent itself declared.
1349
+ */
1350
+ export function permissionToolName(toolCall) {
1183
1351
  const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
1184
- return typeof row.title === 'string' && row.title !== '' ? row.title : 'tool call';
1352
+ if (typeof row.title === 'string' && row.title !== '')
1353
+ return row.title;
1354
+ const meta = (typeof row._meta === 'object' && row._meta !== null ? row._meta : {});
1355
+ for (const [key, value] of Object.entries(meta)) {
1356
+ const segment = key.split('/').at(-1) ?? key;
1357
+ if (segment.toLowerCase() === 'toolname' && typeof value === 'string' && value !== '')
1358
+ return value;
1359
+ }
1360
+ if (typeof row.kind === 'string' && row.kind !== '')
1361
+ return row.kind;
1362
+ return 'tool call';
1185
1363
  }
1186
1364
  /**
1187
1365
  * Translate a native approval outcome into the ACP option the agent must see.
@@ -2299,13 +2477,24 @@ class TurnStreamController {
2299
2477
  // The line must not point at a place with no answer entry, and the bridge
2300
2478
  // knows which path this ask took (`ledgerFallbackOf`, recorded before the
2301
2479
  // frame was broadcast). On the DESIGNED ledger paths the settings panel's
2302
- // card is the answer entry. A DEGRADED native route carries a deadline.
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.
2303
2484
  const fallback = this.bridge.ledgerFallbackOf(event.requestId);
2304
- return fallback?.reason === 'unavailable'
2305
- ? fallback.timeoutMs > 0
2306
- ? `\n> 🔐 [Permission Required: ${title}] - the approval service is unavailable; approve in Settings within ${String(Math.round(fallback.timeoutMs / 1000))}s or this request is cancelled\n`
2307
- : `\n> 🔐 [Permission Required: ${title}] - the approval service is unavailable; please approve in Settings\n`
2308
- : `\n> 🔐 [Permission Required: ${title}] - please approve in Settings\n`;
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`;
2309
2498
  }
2310
2499
  }
2311
2500
  /** Chunk text shared by the controller's fold (string / object / array forms). */
@@ -2343,13 +2532,47 @@ export class BridgeManager {
2343
2532
  configStoragePath;
2344
2533
  catalogStoragePath;
2345
2534
  probeModelsOnBoot;
2346
- tuning;
2347
2535
  bridges = new Map();
2348
2536
  defsMap = new Map();
2349
2537
  /** The plugin's own default ACP agent id (persisted; see `persistAgents`). */
2350
2538
  defaultAgentId;
2351
2539
  /** The agent tag's halo preference (persisted; see `persistAgents`). */
2352
2540
  tagHaloSetting = { ...DEFAULT_TAG_HALO };
2541
+ /**
2542
+ * What `cordis.patch.yml` asked for, by key — ONLY the keys it actually
2543
+ * names. This is the deployment baseline, kept apart from
2544
+ * {@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.
2547
+ */
2548
+ configuredDeadlines = {};
2549
+ /**
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.
2553
+ *
2554
+ * Held separately from {@link configuredDeadlines} because the two answer
2555
+ * different questions: this one is what RUNS when nothing is overridden, that
2556
+ * one is what the config file NAMED (which the card shows as the reset target
2557
+ * and a bare default does not deserve). A manager built without tuning at all
2558
+ * — a direct consumer, a test — gets `DEFAULT_TUNING` here and stays correct.
2559
+ */
2560
+ baselineDeadlines;
2561
+ /**
2562
+ * 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
2564
+ * pick is what runs, and the config file becomes the value the reset button
2565
+ * returns to. Without this split a hand-edited `cordis.patch.yml` and a UI
2566
+ * write would fight with no way to tell which one is live.
2567
+ */
2568
+ deadlineOverride = {};
2569
+ /**
2570
+ * Tuning in force, defaults applied. The two approval deadlines are rewritten
2571
+ * IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
2572
+ * rather than the constructor's parameter: every bridge built afterwards
2573
+ * inherits the rewrite instead of the boot-time value.
2574
+ */
2575
+ tuning;
2353
2576
  /**
2354
2577
  * The in-flight (or just-settled) segmented turn of one DSH chat session.
2355
2578
  * Keyed by chat session id because the continuation steps of a segmented
@@ -2396,7 +2619,18 @@ export class BridgeManager {
2396
2619
  this.configStoragePath = configStoragePath;
2397
2620
  this.catalogStoragePath = catalogStoragePath;
2398
2621
  this.probeModelsOnBoot = probeModelsOnBoot;
2399
- this.tuning = tuning;
2622
+ // Held as a live object rather than the raw parameter: the two approval
2623
+ // deadlines are rewritten in place when the settings page writes them (see
2624
+ // `applyDeadlineTuning`), and every bridge built afterwards must inherit
2625
+ // the rewrite instead of the boot-time value.
2626
+ 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
+ };
2400
2634
  for (const def of defs) {
2401
2635
  this.defsMap.set(def.id, def);
2402
2636
  }
@@ -2409,6 +2643,9 @@ export class BridgeManager {
2409
2643
  }
2410
2644
  this.loadPersistedAgents();
2411
2645
  this.loadPersistedCatalogs();
2646
+ // A persisted deadline override must reach the tuning the bridges are built
2647
+ // from, not just the settings card's readout.
2648
+ this.applyDeadlineTuning();
2412
2649
  // The request-path rewrite falls back to this default for a conversation
2413
2650
  // the user never bound explicitly — the same agent the composer chip shows.
2414
2651
  routeBindingRegistry().defaultModel = this.defaultAgent();
@@ -2429,6 +2666,19 @@ export class BridgeManager {
2429
2666
  approvalRouting() {
2430
2667
  return this.approvalRequester !== undefined;
2431
2668
  }
2669
+ /**
2670
+ * Every outstanding ledger ask, across all agents, with the view the browser
2671
+ * was shown it — the source of the reconnect replay (see
2672
+ * `permissionReplayFrames`). Asks on the native route are deliberately
2673
+ * absent: they broadcast nothing and their answer entry is the composer
2674
+ * takeover the client already holds.
2675
+ */
2676
+ pendingPermissionViews() {
2677
+ const views = [];
2678
+ for (const bridge of this.bridges.values())
2679
+ views.push(...bridge.permissions.pendingViews());
2680
+ return views;
2681
+ }
2432
2682
  /**
2433
2683
  * Let the host resolve a chat session's workspace root — the sidebar's
2434
2684
  * selected workspace, which the agent's session and its process both run in.
@@ -3213,6 +3463,17 @@ export class BridgeManager {
3213
3463
  catch {
3214
3464
  this.tagHaloSetting = { ...DEFAULT_TAG_HALO };
3215
3465
  }
3466
+ // The settings page's approval-deadline override. Re-validated on every
3467
+ // boot because the state file is user- and version-editable: an
3468
+ // unreadable value must not poison the deadline, and a value the
3469
+ // validator accepts must be the value that runs (a UI write that did
3470
+ // not survive a restart would read as a broken setting).
3471
+ try {
3472
+ this.deadlineOverride = validateApprovalDeadlines(parsed.approvalDeadlines ?? {});
3473
+ }
3474
+ catch {
3475
+ this.deadlineOverride = {};
3476
+ }
3216
3477
  this.loadPersistedChoices(parsed.conversations);
3217
3478
  }
3218
3479
  }
@@ -3267,6 +3528,11 @@ export class BridgeManager {
3267
3528
  ...(this.defaultAgentId === undefined ? {} : { defaultAgentId: this.defaultAgentId }),
3268
3529
  tagHalo: this.tagHaloSetting,
3269
3530
  agents: [...this.defsMap.values()],
3531
+ // The settings page's approval deadlines. Absent while none was ever
3532
+ // written, so a fresh deployment's state file stays exactly as it was.
3533
+ ...(Object.keys(this.deadlineOverride).length === 0
3534
+ ? {}
3535
+ : { approvalDeadlines: { ...this.deadlineOverride } }),
3270
3536
  // The per-conversation picks: agent + model + thought level + mode +
3271
3537
  // extra config values chosen in the composer, including for
3272
3538
  // conversations with no live session yet.
@@ -4260,6 +4526,79 @@ export class BridgeManager {
4260
4526
  this.tagHaloSetting = validateTagHalo(halo);
4261
4527
  this.persistAgents();
4262
4528
  }
4529
+ /**
4530
+ * Declare the deployment baseline for the two approval deadlines — the values
4531
+ * `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
4532
+ *
4533
+ * Kept separate from the validated tuning because "the file says 600000" and
4534
+ * "the file says nothing, so 300000 applies" are different facts: the settings
4535
+ * card shows the first as the reset target and the second as a built-in
4536
+ * default. Called once by the host entry, before any bridge exists — and it
4537
+ * also moves the baseline, so a manager built without tuning (a direct
4538
+ * consumer, a test) still runs what the deployment asked for.
4539
+ */
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;
4546
+ this.applyDeadlineTuning();
4547
+ }
4548
+ /**
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.
4554
+ */
4555
+ approvalDeadlines() {
4556
+ const deadlines = {
4557
+ nativeApprovalTimeoutMs: this.tuning.nativeApprovalTimeoutMs,
4558
+ permissionFallbackTimeoutMs: this.tuning.permissionFallbackTimeoutMs,
4559
+ };
4560
+ if (Object.keys(this.configuredDeadlines).length > 0)
4561
+ deadlines.configured = { ...this.configuredDeadlines };
4562
+ if (this.tuning.permissionTimeoutMs > 0)
4563
+ deadlines.permissionTimeoutMs = this.tuning.permissionTimeoutMs;
4564
+ return deadlines;
4565
+ }
4566
+ /**
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.
4570
+ *
4571
+ * HOT: the rewrite reaches every live bridge, so a user changing the window
4572
+ * never has to kill a running session to see it take effect (the one thing an
4573
+ * `agent/save`-style restart would cost them). Only asks armed AFTER the
4574
+ * write see the new window; an ask already holding a timer keeps the one it
4575
+ * started with.
4576
+ */
4577
+ async setApprovalDeadlines(patch, reset = false) {
4578
+ // Merged BEFORE validating, so the whole resulting override passes the same
4579
+ // 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.
4582
+ const merged = reset ? {} : { ...this.deadlineOverride, ...patch };
4583
+ this.deadlineOverride = validateApprovalDeadlines(merged);
4584
+ this.applyDeadlineTuning();
4585
+ this.persistAgents();
4586
+ }
4587
+ /**
4588
+ * Recompute the two deadlines from override → deployment baseline, and push
4589
+ * the result to every live bridge.
4590
+ */
4591
+ 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 };
4599
+ for (const bridge of this.bridges.values())
4600
+ bridge.applyApprovalDeadlines(patch);
4601
+ }
4263
4602
  dshSessionMap = new Map();
4264
4603
  /**
4265
4604
  * What each DSH chat session has chosen for its ACP agent — the agent itself