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/README.en.md +20 -1
- package/README.md +20 -1
- package/cordis.patch.yml +32 -0
- package/lib/bridge.js +361 -22
- package/lib/client/panel.js +179 -10
- package/lib/client/store.js +55 -1
- package/lib/client.js +352 -26
- package/lib/client.js.map +1 -1
- package/lib/impl.js +216 -5
- package/lib/index.js +31 -1
- package/lib/types/bridge.d.ts +191 -11
- package/lib/types/client/panel.d.ts +46 -0
- package/lib/types/client/store.d.ts +21 -1
- package/lib/types/impl.d.ts +99 -2
- package/lib/types/protocol.d.ts +135 -0
- package/package.json +1 -1
- package/src/bridge.ts +408 -27
- package/src/client/panel.tsx +322 -7
- package/src/client/store.ts +65 -2
- package/src/impl.ts +272 -7
- package/src/index.ts +30 -2
- package/src/protocol.ts +136 -0
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:
|
|
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:
|
|
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:
|
|
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'
|
|
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, {
|
|
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
|
-
|
|
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
|
-
*
|
|
1029
|
-
*
|
|
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
|
-
|
|
1184
|
+
const asked = requester({
|
|
1043
1185
|
dshSessionId: session.dshSessionId,
|
|
1044
1186
|
agentId: this.def.id,
|
|
1045
1187
|
toolName: permissionToolName(toolCall),
|
|
1046
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2305
|
-
?
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
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
|
-
|
|
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
|