dsh-ntr 0.1.4 → 0.1.6
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 +22 -1
- package/README.md +22 -1
- package/cordis.patch.yml +65 -5
- package/lib/bridge.js +612 -54
- package/lib/client/panel.js +338 -10
- package/lib/client/store.js +77 -3
- package/lib/client.js +511 -28
- package/lib/client.js.map +1 -1
- package/lib/impl.js +290 -7
- package/lib/index.js +46 -3
- package/lib/types/bridge.d.ts +246 -22
- package/lib/types/client/panel.d.ts +92 -0
- package/lib/types/client/store.d.ts +28 -1
- package/lib/types/impl.d.ts +151 -2
- package/lib/types/protocol.d.ts +209 -4
- package/package.json +1 -1
- package/src/bridge.ts +673 -66
- package/src/client/panel.tsx +527 -10
- package/src/client/store.ts +87 -4
- package/src/impl.ts +338 -9
- package/src/index.ts +47 -5
- package/src/protocol.ts +211 -4
package/lib/bridge.js
CHANGED
|
@@ -12,17 +12,69 @@ 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, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } 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
|
-
permissionTimeoutMs:
|
|
25
|
-
|
|
76
|
+
permissionTimeoutMs: 300_000,
|
|
77
|
+
permissionTimeoutMode: 'wait',
|
|
26
78
|
cancelFallbackMs: 15_000,
|
|
27
79
|
staleCancelRetryDelayMs: 600,
|
|
28
80
|
staleCancelMaxRetries: 2,
|
|
@@ -63,8 +115,17 @@ export class AgentBridge {
|
|
|
63
115
|
*/
|
|
64
116
|
ledgerFallbacks = new Map;
|
|
65
117
|
startupTimeoutMs;
|
|
118
|
+
/**
|
|
119
|
+
* The ONE approval deadline, and the mode it settles with. Neither is
|
|
120
|
+
* readonly, unlike their neighbors: the settings page rewrites them on a
|
|
121
|
+
* running bridge ({@link AgentBridge.applyApprovalDeadlines}), so a user
|
|
122
|
+
* editing the card does not have to kill every live session to see it take
|
|
123
|
+
* effect. Both are read per ask, so a reassignment reaches every ask from
|
|
124
|
+
* that moment on. An ask already holding an armed timer keeps the window it
|
|
125
|
+
* was started with.
|
|
126
|
+
*/
|
|
66
127
|
permissionTimeoutMs;
|
|
67
|
-
|
|
128
|
+
permissionTimeoutMode;
|
|
68
129
|
cancelFallbackMs;
|
|
69
130
|
staleCancelRetryDelayMs;
|
|
70
131
|
staleCancelMaxRetries;
|
|
@@ -131,8 +192,10 @@ export class AgentBridge {
|
|
|
131
192
|
this.sessionWorkspace = sessionWorkspace;
|
|
132
193
|
const merged = { ...DEFAULT_TUNING, ...tuning };
|
|
133
194
|
this.startupTimeoutMs = merged.startupTimeoutMs;
|
|
134
|
-
this.
|
|
135
|
-
|
|
195
|
+
this.permissionTimeoutMode = merged.permissionTimeoutMode;
|
|
196
|
+
// 不等待 (0) is the `auto` mode's business; under `abort` it would refuse
|
|
197
|
+
// every ask on arrival, so the window is floored to the shortest real wait.
|
|
198
|
+
this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, merged.permissionTimeoutMs);
|
|
136
199
|
this.cancelFallbackMs = merged.cancelFallbackMs;
|
|
137
200
|
this.staleCancelRetryDelayMs = merged.staleCancelRetryDelayMs;
|
|
138
201
|
this.staleCancelMaxRetries = merged.staleCancelMaxRetries;
|
|
@@ -140,6 +203,26 @@ export class AgentBridge {
|
|
|
140
203
|
this.defaultIdleTimeoutMs = merged.idleTimeoutMs;
|
|
141
204
|
this.probeIdleTimeoutMs = merged.probeIdleTimeoutMs;
|
|
142
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Adopt a rewritten approval deadline or mode (the settings page's write).
|
|
208
|
+
* Only the window and the mode live here; every other tuning value is
|
|
209
|
+
* fixed at construction, because a live bridge cannot meaningfully adopt a
|
|
210
|
+
* new startup or cancel window mid-flight.
|
|
211
|
+
*
|
|
212
|
+
* Deliberately does NOT re-arm anything: an ask that already holds a timer
|
|
213
|
+
* keeps the window it was started with, and the next ask reads the new value.
|
|
214
|
+
* Re-arming would move the goalposts under a human who is already deciding.
|
|
215
|
+
*/
|
|
216
|
+
applyApprovalDeadlines(patch) {
|
|
217
|
+
if (patch.permissionTimeoutMs !== undefined)
|
|
218
|
+
this.permissionTimeoutMs = patch.permissionTimeoutMs;
|
|
219
|
+
if (patch.mode !== undefined)
|
|
220
|
+
this.permissionTimeoutMode = patch.mode;
|
|
221
|
+
// A mode switch can leave a window behind that the new mode cannot run
|
|
222
|
+
// (不等待 under `abort`): floor it, so the ask waits instead of refusing
|
|
223
|
+
// on arrival.
|
|
224
|
+
this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, this.permissionTimeoutMs);
|
|
225
|
+
}
|
|
143
226
|
/**
|
|
144
227
|
* The idle-release window this agent honors for its CHAT process: its own
|
|
145
228
|
* field, else the host default. This is the number the panel prefills and
|
|
@@ -528,7 +611,7 @@ export class AgentBridge {
|
|
|
528
611
|
if (conn === undefined)
|
|
529
612
|
throw new AcpError('agent_unreachable', 'acp: connection is not open');
|
|
530
613
|
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:
|
|
614
|
+
const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/new');
|
|
532
615
|
// An empty `configOptions` answer is a non-answer — see `usableConfigOptions`.
|
|
533
616
|
if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
|
|
534
617
|
this.onCatalog(this.def.id, result.configOptions);
|
|
@@ -573,6 +656,15 @@ export class AgentBridge {
|
|
|
573
656
|
* Spawn a throwaway ACP session purely to read its `configOptions` (the
|
|
574
657
|
* model catalog probe). Deliberately bypasses the live-session bookkeeping
|
|
575
658
|
* so probe sessions never surface in the browser session list.
|
|
659
|
+
*
|
|
660
|
+
* THE PROBE SENDS THE AGENT'S OWN MCP LIST, not an empty one. Measured
|
|
661
|
+
* against codebuddy: the servers handed to `session/new` land in the
|
|
662
|
+
* agent PROCESS's dynamic MCP scope, so a probe that sent an empty list
|
|
663
|
+
* would REPLACE that scope and drop the MCP servers of the live sessions
|
|
664
|
+
* on the same connection (the agent logged
|
|
665
|
+
* `serverManager.delete("injected")` and the live session's
|
|
666
|
+
* `mcp__injected__*` tools disappeared). One list per agent process is the
|
|
667
|
+
* only shape that keeps every session of that process consistent.
|
|
576
668
|
*/
|
|
577
669
|
async probeConfigOptions() {
|
|
578
670
|
// A probe is the reason an agent process can be resident with NOTHING else
|
|
@@ -585,7 +677,7 @@ export class AgentBridge {
|
|
|
585
677
|
if (conn === undefined)
|
|
586
678
|
throw new AcpError('agent_unreachable', 'acp: connection is not open');
|
|
587
679
|
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:
|
|
680
|
+
const result = await this.withStartupTimeout(conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: toAcpMcpServers(this.def.mcpServers) }), 'session/new');
|
|
589
681
|
await conn.agent.request(methods.agent.session.close, { sessionId: result.sessionId }).catch(() => { });
|
|
590
682
|
return Array.isArray(result.configOptions) ? result.configOptions : [];
|
|
591
683
|
}
|
|
@@ -640,6 +732,13 @@ export class AgentBridge {
|
|
|
640
732
|
// own turn apart from a later one.
|
|
641
733
|
session.cancelRequestedAtTurn = session.turnSeq;
|
|
642
734
|
await this.conn.agent.notify(methods.agent.session.cancel, { sessionId: session.acpSessionId }).catch(() => { });
|
|
735
|
+
// The ACP spec's counterpart to the notification we just sent: a client that
|
|
736
|
+
// cancels MUST answer every pending `session/request_permission` with
|
|
737
|
+
// `cancelled`. Leaving them open would hold the agent's tool call blocked on
|
|
738
|
+
// an answer that can no longer arrive — the turn is over, the human's
|
|
739
|
+
// decision is "stop". OmniTerm does the same from `AcpClient::cancel`
|
|
740
|
+
// (`PermissionManager::cancel_all`).
|
|
741
|
+
this.settlePermissions(session);
|
|
643
742
|
this.armCancelFallback(session);
|
|
644
743
|
}
|
|
645
744
|
/**
|
|
@@ -733,7 +832,7 @@ export class AgentBridge {
|
|
|
733
832
|
// is in flight, and let the browser drop its blocks/watermark first.
|
|
734
833
|
this.acpIndex.set(session.acpSessionId, session);
|
|
735
834
|
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:
|
|
835
|
+
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
836
|
if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
|
|
738
837
|
this.onCatalog(this.def.id, result.configOptions);
|
|
739
838
|
}
|
|
@@ -956,6 +1055,23 @@ export class AgentBridge {
|
|
|
956
1055
|
this.touch();
|
|
957
1056
|
return native.response;
|
|
958
1057
|
}
|
|
1058
|
+
// Deadline: how long this ask may sit unanswered. ONE window for every
|
|
1059
|
+
// route — an ask that never reached the conversation included. They have
|
|
1060
|
+
// no other answer entry, so exempting them would be the very invisible
|
|
1061
|
+
// hang this mechanism exists to bound.
|
|
1062
|
+
//
|
|
1063
|
+
// What happens when it fires is the MODE's business: refused, answered
|
|
1064
|
+
// with the option the agent's own kinds imply, or (in `wait`) never.
|
|
1065
|
+
// The mode is read once, here, for the same reason the window is: an ask
|
|
1066
|
+
// that started under one mode keeps it.
|
|
1067
|
+
const mode = this.permissionTimeoutMode;
|
|
1068
|
+
const timeoutMs = this.permissionTimeoutMs;
|
|
1069
|
+
// 不等待(0)只属自动推进:请求到达即按那一档收场,不建 ledger 条目、不发卡
|
|
1070
|
+
// ——问都来不及问。超时中止没有这一档(它会立刻拒绝每一问,agent 在每个门口
|
|
1071
|
+
// 都停住),它的窗口最短是真实等待。
|
|
1072
|
+
if (mode === 'auto' && timeoutMs === 0) {
|
|
1073
|
+
return this.settleExpiredAsk(session, mode, toolCall, options, 0);
|
|
1074
|
+
}
|
|
959
1075
|
return new Promise(resolve => {
|
|
960
1076
|
let timer;
|
|
961
1077
|
const requestId = this.permissions.create(session.sessionId, (outcome) => {
|
|
@@ -971,32 +1087,38 @@ export class AgentBridge {
|
|
|
971
1087
|
resolve(outcome.outcome === 'selected'
|
|
972
1088
|
? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
|
|
973
1089
|
: { outcome: { outcome: 'cancelled' } });
|
|
1090
|
+
}, {
|
|
1091
|
+
// Kept for the reconnect replay: a client that connects after the ask
|
|
1092
|
+
// was broadcast must be re-shown the very card it has to answer.
|
|
1093
|
+
toolCall,
|
|
1094
|
+
options: options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
|
|
974
1095
|
});
|
|
975
|
-
// Deadline (`permissionTimeoutMs`): an approval left unanswered is
|
|
976
|
-
// answered `cancelled` on the human's behalf instead of hanging the turn
|
|
977
|
-
// forever. Off by default — approvals belong to a person. The one
|
|
978
|
-
// exception is a DEGRADED native route: the conversation just failed to
|
|
979
|
-
// show the question, so waiting forever would hang the turn behind an
|
|
980
|
-
// ask nobody can see there, and the fallback deadline applies instead
|
|
981
|
-
// (see `BridgeTuning.permissionFallbackTimeoutMs`).
|
|
982
|
-
const timeoutMs = this.permissionTimeoutMs > 0
|
|
983
|
-
? this.permissionTimeoutMs
|
|
984
|
-
: native.reason === 'unavailable' ? this.permissionFallbackTimeoutMs : 0;
|
|
985
1096
|
// Recorded before the broadcast: the hub is synchronous, and the chat
|
|
986
1097
|
// line `streamPrompt` folds from this frame words itself by the path
|
|
987
1098
|
// this ask actually took.
|
|
988
|
-
this.ledgerFallbacks.set(requestId, { reason: native.reason, timeoutMs });
|
|
989
|
-
if (timeoutMs > 0) {
|
|
1099
|
+
this.ledgerFallbacks.set(requestId, { reason: native.reason, timeoutMs, mode });
|
|
1100
|
+
if (timeoutMs > 0 && mode !== 'wait') {
|
|
990
1101
|
timer = setTimeout(() => {
|
|
991
1102
|
if (!this.permissions.has(requestId))
|
|
992
1103
|
return;
|
|
1104
|
+
const picked = mode === 'auto'
|
|
1105
|
+
? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
|
|
1106
|
+
: undefined;
|
|
1107
|
+
const tool = permissionToolName(toolCall);
|
|
993
1108
|
this.hub.broadcast({
|
|
994
1109
|
type: 'agent/notice',
|
|
995
1110
|
agentId: this.def.id,
|
|
996
1111
|
level: 'info',
|
|
997
|
-
|
|
1112
|
+
// Name what was missed and what was done about it, the way a human
|
|
1113
|
+
// needs it: which operation sat unanswered, and what the plugin
|
|
1114
|
+
// answered instead (OmniTerm's reaper writes the same notice).
|
|
1115
|
+
text: picked === undefined
|
|
1116
|
+
? `acp: permission request for "${tool}" timed out after ${String(Math.round(timeoutMs / 1000))}s; refused`
|
|
1117
|
+
: `acp: permission request for "${tool}" timed out after ${String(Math.round(timeoutMs / 1000))}s; answered "${picked.name}"`,
|
|
998
1118
|
});
|
|
999
|
-
this.permissions.respond(requestId,
|
|
1119
|
+
this.permissions.respond(requestId, picked === undefined
|
|
1120
|
+
? { outcome: 'cancelled' }
|
|
1121
|
+
: { outcome: 'selected', optionId: picked.optionId });
|
|
1000
1122
|
}, timeoutMs);
|
|
1001
1123
|
timer.unref?.();
|
|
1002
1124
|
}
|
|
@@ -1025,8 +1147,19 @@ export class AgentBridge {
|
|
|
1025
1147
|
* open turn, so a session started over `/acp` (no chat turn) cannot be
|
|
1026
1148
|
* routed and must stay on the ledger path.
|
|
1027
1149
|
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1150
|
+
* The ask is raced against {@link BridgeTuning.permissionTimeoutMs}. A
|
|
1151
|
+
* routed ask has exactly one answer entry — the composer takeover — and no
|
|
1152
|
+
* broadcast at all, so a host that takes the question and never renders it
|
|
1153
|
+
* would leave the turn hanging behind something invisible. Past the deadline
|
|
1154
|
+
* the ask is settled RIGHT THERE, by
|
|
1155
|
+
* {@link BridgeTuning.permissionTimeoutMode}: refused (`abort`), answered
|
|
1156
|
+
* with the option the agent's own kinds imply (`auto`), or never expiring
|
|
1157
|
+
* at all (`wait`).
|
|
1158
|
+
*
|
|
1159
|
+
* It is NOT handed somewhere else. An earlier revision withdrew the ask and
|
|
1160
|
+
* re-asked it on the settings ledger, which made the question travel to a
|
|
1161
|
+
* page the user was not looking at — and then cancelled it there a few
|
|
1162
|
+
* minutes later anyway. A deadline now ends the ask where it was asked.
|
|
1030
1163
|
*
|
|
1031
1164
|
* @returns the agent-facing response, or the reason the ask must fall back
|
|
1032
1165
|
* to the ledger (see {@link LedgerFallbackReason}).
|
|
@@ -1037,20 +1170,73 @@ export class AgentBridge {
|
|
|
1037
1170
|
return { reason: 'no-requester' };
|
|
1038
1171
|
if (session.dshSessionId === undefined)
|
|
1039
1172
|
return { reason: 'no-chat-session' };
|
|
1173
|
+
// The mode is read ONCE, here: it decides both whether a deadline exists
|
|
1174
|
+
// and what happens when it fires. An ask that started under one mode keeps
|
|
1175
|
+
// it — the same rule the window follows (an ask already holding a timer
|
|
1176
|
+
// keeps the one it started with).
|
|
1177
|
+
const mode = this.permissionTimeoutMode;
|
|
1178
|
+
// 不等待(0)只属自动推进:没有问人的必要。直接按那一档收场,composer 接管
|
|
1179
|
+
// 根本不必出现——它只会闪一下就没了,问了也来不及答。超时中止没有这一档。
|
|
1180
|
+
if (mode === 'auto' && this.permissionTimeoutMs === 0) {
|
|
1181
|
+
return { response: this.settleExpiredAsk(session, mode, toolCall, options, 0) };
|
|
1182
|
+
}
|
|
1183
|
+
// One signal carries both withdrawals: the agent's own cancel (whatever
|
|
1184
|
+
// aborted the ACP request) and the deadline. The host's waterfall races its
|
|
1185
|
+
// answerers against this signal, so aborting is a clean withdrawal rather
|
|
1186
|
+
// than a leak.
|
|
1187
|
+
const withdraw = new AbortController();
|
|
1188
|
+
const askSignal = signal === undefined ? withdraw.signal : AbortSignal.any([signal, withdraw.signal]);
|
|
1189
|
+
let timer;
|
|
1190
|
+
let timedOut = false;
|
|
1191
|
+
// `wait` never expires, so it never arms a timer at all. A window of `0`
|
|
1192
|
+
// never reaches here: 不等待 was already settled above.
|
|
1193
|
+
const windowMs = this.permissionTimeoutMs;
|
|
1194
|
+
const deadline = mode !== 'wait' && windowMs > 0
|
|
1195
|
+
? new Promise(resolve => {
|
|
1196
|
+
timer = setTimeout(() => {
|
|
1197
|
+
timedOut = true;
|
|
1198
|
+
withdraw.abort();
|
|
1199
|
+
resolve({ timedOut: true });
|
|
1200
|
+
}, windowMs);
|
|
1201
|
+
timer.unref?.();
|
|
1202
|
+
})
|
|
1203
|
+
: undefined;
|
|
1040
1204
|
let outcome;
|
|
1041
1205
|
try {
|
|
1042
|
-
|
|
1206
|
+
const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
|
|
1207
|
+
const asked = requester({
|
|
1043
1208
|
dshSessionId: session.dshSessionId,
|
|
1044
1209
|
agentId: this.def.id,
|
|
1045
1210
|
toolName: permissionToolName(toolCall),
|
|
1046
|
-
|
|
1211
|
+
reason: permissionAskReason(toolCall),
|
|
1212
|
+
// The ACP call id is the id the adapter streamed on the host tool card
|
|
1213
|
+
// (`TurnStreamController` fills `tool-call` blocks from
|
|
1214
|
+
// `snapshot.callId`), so the host's approval detail slot can attach
|
|
1215
|
+
// the operation's own card to this ask.
|
|
1216
|
+
...(typeof row.toolCallId === 'string' && row.toolCallId !== '' ? { callId: row.toolCallId } : {}),
|
|
1217
|
+
signal: askSignal,
|
|
1047
1218
|
});
|
|
1219
|
+
// A withdrawn ask settles on the host's own schedule; dropping it here
|
|
1220
|
+
// must not surface as an unhandled rejection later.
|
|
1221
|
+
asked.catch(() => { });
|
|
1222
|
+
const settled = deadline === undefined
|
|
1223
|
+
? await asked.then(o => ({ outcome: o }))
|
|
1224
|
+
: await Promise.race([asked.then(o => ({ outcome: o })), deadline]);
|
|
1225
|
+
// The flag, not the race winner, decides: the host may settle `cancelled`
|
|
1226
|
+
// from the very abort the deadline caused, and that is still a timeout.
|
|
1227
|
+
if (timedOut || 'timedOut' in settled) {
|
|
1228
|
+
return { response: this.settleExpiredAsk(session, mode, toolCall, options, windowMs) };
|
|
1229
|
+
}
|
|
1230
|
+
outcome = settled.outcome;
|
|
1048
1231
|
}
|
|
1049
1232
|
catch {
|
|
1050
1233
|
// Synchronous throw or rejection, either way: the ask falls back to the
|
|
1051
1234
|
// ledger. An approval failure must never break the agent's turn.
|
|
1052
1235
|
outcome = 'unavailable';
|
|
1053
1236
|
}
|
|
1237
|
+
finally {
|
|
1238
|
+
clearTimeout(timer);
|
|
1239
|
+
}
|
|
1054
1240
|
if (outcome === 'unavailable')
|
|
1055
1241
|
return { reason: 'unavailable' };
|
|
1056
1242
|
const response = nativeOutcomeResponse(outcome, options);
|
|
@@ -1068,6 +1254,57 @@ export class AgentBridge {
|
|
|
1068
1254
|
});
|
|
1069
1255
|
return { response };
|
|
1070
1256
|
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Settle an ask whose deadline fired, WHERE IT WAS ASKED.
|
|
1259
|
+
*
|
|
1260
|
+
* `abort` refuses it — the operation does not happen and the agent carries
|
|
1261
|
+
* on with that answer. `auto` answers it with the option the agent's own
|
|
1262
|
+
* kinds imply ({@link pickAutoOption}) so the agent CONTINUES; it says which
|
|
1263
|
+
* option it picked, because an unattended approval nobody looked at has to
|
|
1264
|
+
* be visible afterwards. A mode that cannot pick an option (no options, or
|
|
1265
|
+
* none with a usable id) degrades to `abort`: refusing is safer than
|
|
1266
|
+
* guessing, and hanging is worse than either.
|
|
1267
|
+
*
|
|
1268
|
+
* The `permission/resolved` frame keeps the event stream symmetric with the
|
|
1269
|
+
* ledger path, so the browser drops any card it may hold for this session.
|
|
1270
|
+
*/
|
|
1271
|
+
settleExpiredAsk(session, mode, toolCall, options, timeoutMs) {
|
|
1272
|
+
const picked = mode === 'auto'
|
|
1273
|
+
? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
|
|
1274
|
+
: undefined;
|
|
1275
|
+
const response = picked === undefined
|
|
1276
|
+
? { outcome: { outcome: 'cancelled' } }
|
|
1277
|
+
: { outcome: { outcome: 'selected', optionId: picked.optionId } };
|
|
1278
|
+
const tool = permissionToolName(toolCall);
|
|
1279
|
+
// 不等待(0)不是「等了 0 秒」,是没等:两种说法的区别就在这里。
|
|
1280
|
+
const waited = timeoutMs > 0
|
|
1281
|
+
? `timed out after ${String(Math.round(timeoutMs / 1000))}s`
|
|
1282
|
+
: 'was not waited for';
|
|
1283
|
+
this.hub.broadcast({
|
|
1284
|
+
type: 'agent/notice',
|
|
1285
|
+
agentId: this.def.id,
|
|
1286
|
+
level: 'info',
|
|
1287
|
+
// Name what was missed and what was done about it — the way a human
|
|
1288
|
+
// needs it: which operation sat unanswered, and what the plugin answered
|
|
1289
|
+
// instead. OmniTerm's reaper writes the same "you missed this" notice.
|
|
1290
|
+
text: picked === undefined
|
|
1291
|
+
? `acp: permission request for "${tool}" ${waited}; refused`
|
|
1292
|
+
: `acp: permission request for "${tool}" ${waited}; answered "${picked.name}"`,
|
|
1293
|
+
});
|
|
1294
|
+
// The conversation's only trace of the expiry: the composer card is going
|
|
1295
|
+
// away and nothing else says why.
|
|
1296
|
+
this.hub.broadcast({
|
|
1297
|
+
type: 'permission/expired',
|
|
1298
|
+
sessionId: session.sessionId,
|
|
1299
|
+
toolCall,
|
|
1300
|
+
timeoutMs,
|
|
1301
|
+
outcome: response.outcome.outcome === 'selected'
|
|
1302
|
+
? { outcome: 'selected', optionId: response.outcome.optionId }
|
|
1303
|
+
: { outcome: 'cancelled' },
|
|
1304
|
+
...(picked === undefined ? {} : { optionName: picked.name }),
|
|
1305
|
+
});
|
|
1306
|
+
return response;
|
|
1307
|
+
}
|
|
1071
1308
|
settlePermissions(session) {
|
|
1072
1309
|
this.permissions.settleFor(p => p.sessionId === session.sessionId);
|
|
1073
1310
|
}
|
|
@@ -1179,9 +1416,84 @@ export class AgentBridge {
|
|
|
1179
1416
|
}
|
|
1180
1417
|
}
|
|
1181
1418
|
/** Human-facing identity of an ACP tool call (the approval headline). */
|
|
1182
|
-
|
|
1419
|
+
/**
|
|
1420
|
+
* Human-facing name of the tool an ask is about, for the chat line and the
|
|
1421
|
+
* timeout notice.
|
|
1422
|
+
*
|
|
1423
|
+
* The ACP `toolCall.title` is the declared field, but it is OPTIONAL and real
|
|
1424
|
+
* agents omit it: codebuddy puts the name in `_meta["codebuddy.ai/toolName"]`
|
|
1425
|
+
* (observed live — without the fallback the notice reads `for "tool call"`,
|
|
1426
|
+
* which tells the human nothing about what they missed). So read `title` first,
|
|
1427
|
+
* then any `_meta` key whose last segment is `toolName`, then the standard
|
|
1428
|
+
* `kind`, and only then give up with the generic phrase. Nothing is invented:
|
|
1429
|
+
* every step reads a key the agent itself declared.
|
|
1430
|
+
*/
|
|
1431
|
+
export function permissionToolName(toolCall) {
|
|
1432
|
+
const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
|
|
1433
|
+
if (typeof row.title === 'string' && row.title !== '')
|
|
1434
|
+
return row.title;
|
|
1435
|
+
const meta = (typeof row._meta === 'object' && row._meta !== null ? row._meta : {});
|
|
1436
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
1437
|
+
const segment = key.split('/').at(-1) ?? key;
|
|
1438
|
+
if (segment.toLowerCase() === 'toolname' && typeof value === 'string' && value !== '')
|
|
1439
|
+
return value;
|
|
1440
|
+
}
|
|
1441
|
+
if (typeof row.kind === 'string' && row.kind !== '')
|
|
1442
|
+
return row.kind;
|
|
1443
|
+
return 'tool call';
|
|
1444
|
+
}
|
|
1445
|
+
/** Longest reason line the approval card headline carries. */
|
|
1446
|
+
const APPROVAL_REASON_MAX = 240;
|
|
1447
|
+
/** Input keys that name the operation in one string, most specific first. */
|
|
1448
|
+
const APPROVAL_REASON_INPUT_KEYS = [
|
|
1449
|
+
'command',
|
|
1450
|
+
'cmd',
|
|
1451
|
+
'path',
|
|
1452
|
+
'file_path',
|
|
1453
|
+
'filePath',
|
|
1454
|
+
'pattern',
|
|
1455
|
+
'query',
|
|
1456
|
+
'url',
|
|
1457
|
+
'description',
|
|
1458
|
+
];
|
|
1459
|
+
/**
|
|
1460
|
+
* One line saying WHAT the native approval card is about.
|
|
1461
|
+
*
|
|
1462
|
+
* WHY: the host renders `reason ?? "工具 <name> 请求越权执行"`. Without a
|
|
1463
|
+
* reason the human only sees the tool's NAME ("Bash") and cannot judge the
|
|
1464
|
+
* request; the ACP tool call already declares the missing half — its title and
|
|
1465
|
+
* (for execute/edit tools) a short scalar in `rawInput`. Only ONE declared
|
|
1466
|
+
* string is used, never a serialized object or a whole file, and the line is
|
|
1467
|
+
* capped so a pathological input cannot fill the card or the audit log.
|
|
1468
|
+
*/
|
|
1469
|
+
export function permissionAskReason(toolCall) {
|
|
1183
1470
|
const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
|
|
1184
|
-
|
|
1471
|
+
const title = permissionToolName(toolCall);
|
|
1472
|
+
const raw = row.rawInput;
|
|
1473
|
+
const input = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
|
|
1474
|
+
? raw
|
|
1475
|
+
: typeof raw === 'string' && raw.length <= APPROVAL_REASON_MAX * 4
|
|
1476
|
+
// Some agents send the arguments as a JSON string; read one scalar out
|
|
1477
|
+
// of it instead of echoing the blob.
|
|
1478
|
+
? (() => {
|
|
1479
|
+
try {
|
|
1480
|
+
const parsed = JSON.parse(raw);
|
|
1481
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
1482
|
+
? parsed
|
|
1483
|
+
: undefined;
|
|
1484
|
+
}
|
|
1485
|
+
catch {
|
|
1486
|
+
return undefined;
|
|
1487
|
+
}
|
|
1488
|
+
})()
|
|
1489
|
+
: undefined;
|
|
1490
|
+
const detail = input === undefined
|
|
1491
|
+
? undefined
|
|
1492
|
+
: APPROVAL_REASON_INPUT_KEYS
|
|
1493
|
+
.map(key => input[key])
|
|
1494
|
+
.find((value) => typeof value === 'string' && value !== '');
|
|
1495
|
+
const line = detail === undefined || detail === title ? title : `${title} · ${detail}`;
|
|
1496
|
+
return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line;
|
|
1185
1497
|
}
|
|
1186
1498
|
/**
|
|
1187
1499
|
* Translate a native approval outcome into the ACP option the agent must see.
|
|
@@ -2141,24 +2453,36 @@ class TurnStreamController {
|
|
|
2141
2453
|
}
|
|
2142
2454
|
else if (row.type === 'permission/request') {
|
|
2143
2455
|
const typed = event;
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
return;
|
|
2150
|
-
}
|
|
2151
|
-
if (seg.hasCard) {
|
|
2152
|
-
// The card must stay the segment's last block: park the line for the
|
|
2153
|
-
// next segment instead of streaming it below the card.
|
|
2154
|
-
this.pendingProse.push(line);
|
|
2155
|
-
this.finishSegment(seg, { kind: 'stop' });
|
|
2156
|
-
return;
|
|
2157
|
-
}
|
|
2158
|
-
if (line !== '')
|
|
2159
|
-
this.emitProse(seg, 'reasoning', line);
|
|
2456
|
+
this.pushProse(this.permissionLine(typed));
|
|
2457
|
+
}
|
|
2458
|
+
else if (row.type === 'permission/expired') {
|
|
2459
|
+
const typed = event;
|
|
2460
|
+
this.pushProse(this.expiredLine(typed));
|
|
2160
2461
|
}
|
|
2161
2462
|
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Put one narrative line into the turn's prose stream.
|
|
2465
|
+
*
|
|
2466
|
+
* A card-bearing segment must stay the segment's LAST block, so a line that
|
|
2467
|
+
* arrives while a card is open is parked for the next segment instead of
|
|
2468
|
+
* streaming below the card. A line that arrives between segments waits for
|
|
2469
|
+
* the next one to open.
|
|
2470
|
+
*/
|
|
2471
|
+
pushProse(line) {
|
|
2472
|
+
if (line === '')
|
|
2473
|
+
return;
|
|
2474
|
+
const seg = this.segment;
|
|
2475
|
+
if (seg === undefined || seg.finished) {
|
|
2476
|
+
this.pendingProse.push(line);
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2479
|
+
if (seg.hasCard) {
|
|
2480
|
+
this.pendingProse.push(line);
|
|
2481
|
+
this.finishSegment(seg, { kind: 'stop' });
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
this.emitProse(seg, 'reasoning', line);
|
|
2485
|
+
}
|
|
2162
2486
|
/** Events that arrive between segments: recorded, replayed into the next one. */
|
|
2163
2487
|
recordWithoutSegment(update) {
|
|
2164
2488
|
const kind = String(update.sessionUpdate ?? '');
|
|
@@ -2298,14 +2622,44 @@ class TurnStreamController {
|
|
|
2298
2622
|
: 'permission';
|
|
2299
2623
|
// The line must not point at a place with no answer entry, and the bridge
|
|
2300
2624
|
// knows which path this ask took (`ledgerFallbackOf`, recorded before the
|
|
2301
|
-
// frame was broadcast). On the
|
|
2302
|
-
//
|
|
2625
|
+
// frame was broadcast). On the ledger paths the settings panel's card is
|
|
2626
|
+
// the answer entry. A DEGRADED native route has to say why the
|
|
2627
|
+
// conversation is not showing the question — as an OBSERVED FACT, not a
|
|
2628
|
+
// guessed cause.
|
|
2629
|
+
//
|
|
2630
|
+
// The last clause says what happens if nobody answers, and it follows the
|
|
2631
|
+
// MODE: refused, answered automatically, or (in `wait`) nothing at all.
|
|
2303
2632
|
const fallback = this.bridge.ledgerFallbackOf(event.requestId);
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2633
|
+
const why = fallback?.reason === 'unavailable' ? 'the approval service is unavailable' : undefined;
|
|
2634
|
+
const lead = why === undefined ? 'please approve in Settings' : `${why}; approve in Settings`;
|
|
2635
|
+
if (fallback === undefined || fallback.timeoutMs <= 0 || fallback.mode === 'wait') {
|
|
2636
|
+
return `\n> 🔐 [Permission Required: ${title}] - ${lead}\n`;
|
|
2637
|
+
}
|
|
2638
|
+
const otherwise = fallback.mode === 'auto'
|
|
2639
|
+
? 'this request is answered automatically'
|
|
2640
|
+
: 'this request is cancelled';
|
|
2641
|
+
return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(Math.round(fallback.timeoutMs / 1000))}s or ${otherwise}\n`;
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* The line for an ask the plugin settled itself when its deadline fired.
|
|
2645
|
+
*
|
|
2646
|
+
* The composer card is disappearing, so the conversation has to say what
|
|
2647
|
+
* happened to the question — otherwise the user is left with a vanished
|
|
2648
|
+
* card and no explanation. The wording follows the outcome: refused, or
|
|
2649
|
+
* answered with the option the agent's own kinds implied.
|
|
2650
|
+
*/
|
|
2651
|
+
expiredLine(event) {
|
|
2652
|
+
const title = typeof event.toolCall?.title === 'string'
|
|
2653
|
+
? String(event.toolCall.title)
|
|
2654
|
+
: 'permission';
|
|
2655
|
+
const waited = event.timeoutMs > 0
|
|
2656
|
+
? `no answer within ${String(Math.round(event.timeoutMs / 1000))}s`
|
|
2657
|
+
: 'not waited for';
|
|
2658
|
+
if (event.outcome.outcome === 'selected') {
|
|
2659
|
+
const picked = event.optionName === undefined ? event.outcome.optionId : event.optionName;
|
|
2660
|
+
return `\n> 🔐 [Permission Required: ${title}] - ${waited}; answered "${picked}" automatically, continuing\n`;
|
|
2661
|
+
}
|
|
2662
|
+
return `\n> 🔐 [Permission Required: ${title}] - ${waited}; refused\n`;
|
|
2309
2663
|
}
|
|
2310
2664
|
}
|
|
2311
2665
|
/** Chunk text shared by the controller's fold (string / object / array forms). */
|
|
@@ -2343,13 +2697,59 @@ export class BridgeManager {
|
|
|
2343
2697
|
configStoragePath;
|
|
2344
2698
|
catalogStoragePath;
|
|
2345
2699
|
probeModelsOnBoot;
|
|
2346
|
-
tuning;
|
|
2347
2700
|
bridges = new Map();
|
|
2348
2701
|
defsMap = new Map();
|
|
2349
2702
|
/** The plugin's own default ACP agent id (persisted; see `persistAgents`). */
|
|
2350
2703
|
defaultAgentId;
|
|
2351
2704
|
/** The agent tag's halo preference (persisted; see `persistAgents`). */
|
|
2352
2705
|
tagHaloSetting = { ...DEFAULT_TAG_HALO };
|
|
2706
|
+
/**
|
|
2707
|
+
* What `cordis.patch.yml` asked for — ONLY if it actually named the key.
|
|
2708
|
+
* This is the deployment baseline, kept apart from
|
|
2709
|
+
* {@link deadlineOverride} so the settings card can tell the user which of
|
|
2710
|
+
* the two is winning. Empty when the file says nothing about the window, in
|
|
2711
|
+
* which case {@link baselineDeadline} (the built-in default) applies.
|
|
2712
|
+
*/
|
|
2713
|
+
configuredDeadlines = {};
|
|
2714
|
+
/**
|
|
2715
|
+
* The mode the config file named, if it named one. Kept apart from the
|
|
2716
|
+
* override for the same reason the window is: the card has to be able
|
|
2717
|
+
* to say "the file asks for `abort`, the value set here wins".
|
|
2718
|
+
*/
|
|
2719
|
+
configuredMode;
|
|
2720
|
+
/**
|
|
2721
|
+
* The window before any override — the tuning this manager was constructed
|
|
2722
|
+
* with, which already carries the validated config file (the host entry
|
|
2723
|
+
* passes `validateConfig`'s output) or the built-in default.
|
|
2724
|
+
*
|
|
2725
|
+
* Held separately from {@link configuredDeadlines} because the two answer
|
|
2726
|
+
* different questions: this one is what RUNS when nothing is overridden, that
|
|
2727
|
+
* one is what the config file NAMED (which the card shows as the reset target
|
|
2728
|
+
* and a bare default does not deserve). A manager built without tuning at all
|
|
2729
|
+
* — a direct consumer, a test — gets `DEFAULT_TUNING` here and stays correct.
|
|
2730
|
+
*/
|
|
2731
|
+
baselineDeadline;
|
|
2732
|
+
/**
|
|
2733
|
+
* The mode before any override — the tuning this manager was constructed
|
|
2734
|
+
* with, which already carries the validated config file (the host entry
|
|
2735
|
+
* passes `validateConfig`'s output) or the built-in default.
|
|
2736
|
+
*/
|
|
2737
|
+
baselineMode;
|
|
2738
|
+
/**
|
|
2739
|
+
* The settings page's own write, persisted with the agent registry. Merged
|
|
2740
|
+
* OVER {@link baselineDeadline}: once a user picks a window in the UI, that
|
|
2741
|
+
* pick is what runs, and the config file becomes the value the reset button
|
|
2742
|
+
* returns to. Without this split a hand-edited `cordis.patch.yml` and a UI
|
|
2743
|
+
* write would fight with no way to tell which one is live.
|
|
2744
|
+
*/
|
|
2745
|
+
deadlineOverride = {};
|
|
2746
|
+
/**
|
|
2747
|
+
* Tuning in force, defaults applied. The approval deadline is rewritten
|
|
2748
|
+
* IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
|
|
2749
|
+
* rather than the constructor's parameter: every bridge built afterwards
|
|
2750
|
+
* inherits the rewrite instead of the boot-time value.
|
|
2751
|
+
*/
|
|
2752
|
+
tuning;
|
|
2353
2753
|
/**
|
|
2354
2754
|
* The in-flight (or just-settled) segmented turn of one DSH chat session.
|
|
2355
2755
|
* Keyed by chat session id because the continuation steps of a segmented
|
|
@@ -2396,7 +2796,16 @@ export class BridgeManager {
|
|
|
2396
2796
|
this.configStoragePath = configStoragePath;
|
|
2397
2797
|
this.catalogStoragePath = catalogStoragePath;
|
|
2398
2798
|
this.probeModelsOnBoot = probeModelsOnBoot;
|
|
2399
|
-
|
|
2799
|
+
// Held as a live object rather than the raw parameter: the two approval
|
|
2800
|
+
// deadlines are rewritten in place when the settings page writes them (see
|
|
2801
|
+
// `applyDeadlineTuning`), and every bridge built afterwards must inherit
|
|
2802
|
+
// the rewrite instead of the boot-time value.
|
|
2803
|
+
this.tuning = { ...DEFAULT_TUNING, ...tuning };
|
|
2804
|
+
// The pre-override deadline value, snapshotted before anything can rewrite
|
|
2805
|
+
// it. Everything else in `tuning` is fixed for the manager's lifetime, so
|
|
2806
|
+
// only this needs a snapshot.
|
|
2807
|
+
this.baselineDeadline = this.tuning.permissionTimeoutMs;
|
|
2808
|
+
this.baselineMode = this.tuning.permissionTimeoutMode;
|
|
2400
2809
|
for (const def of defs) {
|
|
2401
2810
|
this.defsMap.set(def.id, def);
|
|
2402
2811
|
}
|
|
@@ -2409,6 +2818,9 @@ export class BridgeManager {
|
|
|
2409
2818
|
}
|
|
2410
2819
|
this.loadPersistedAgents();
|
|
2411
2820
|
this.loadPersistedCatalogs();
|
|
2821
|
+
// A persisted deadline override must reach the tuning the bridges are built
|
|
2822
|
+
// from, not just the settings card's readout.
|
|
2823
|
+
this.applyDeadlineTuning();
|
|
2412
2824
|
// The request-path rewrite falls back to this default for a conversation
|
|
2413
2825
|
// the user never bound explicitly — the same agent the composer chip shows.
|
|
2414
2826
|
routeBindingRegistry().defaultModel = this.defaultAgent();
|
|
@@ -2429,6 +2841,19 @@ export class BridgeManager {
|
|
|
2429
2841
|
approvalRouting() {
|
|
2430
2842
|
return this.approvalRequester !== undefined;
|
|
2431
2843
|
}
|
|
2844
|
+
/**
|
|
2845
|
+
* Every outstanding ledger ask, across all agents, with the view the browser
|
|
2846
|
+
* was shown it — the source of the reconnect replay (see
|
|
2847
|
+
* `permissionReplayFrames`). Asks on the native route are deliberately
|
|
2848
|
+
* absent: they broadcast nothing and their answer entry is the composer
|
|
2849
|
+
* takeover the client already holds.
|
|
2850
|
+
*/
|
|
2851
|
+
pendingPermissionViews() {
|
|
2852
|
+
const views = [];
|
|
2853
|
+
for (const bridge of this.bridges.values())
|
|
2854
|
+
views.push(...bridge.permissions.pendingViews());
|
|
2855
|
+
return views;
|
|
2856
|
+
}
|
|
2432
2857
|
/**
|
|
2433
2858
|
* Let the host resolve a chat session's workspace root — the sidebar's
|
|
2434
2859
|
* selected workspace, which the agent's session and its process both run in.
|
|
@@ -3213,6 +3638,41 @@ export class BridgeManager {
|
|
|
3213
3638
|
catch {
|
|
3214
3639
|
this.tagHaloSetting = { ...DEFAULT_TAG_HALO };
|
|
3215
3640
|
}
|
|
3641
|
+
// The settings page's approval-deadline override. Re-validated on every
|
|
3642
|
+
// boot because the state file is user- and version-editable: an
|
|
3643
|
+
// unreadable value must not poison the deadline, and a value the
|
|
3644
|
+
// validator accepts must be the value that runs (a UI write that did
|
|
3645
|
+
// not survive a restart would read as a broken setting).
|
|
3646
|
+
try {
|
|
3647
|
+
const raw = parsed.approvalDeadlines ?? {};
|
|
3648
|
+
// The OLD card wrote the two legacy windows, and its 「关」 preset
|
|
3649
|
+
// wrote a zero — which then meant "this window never fires". The only
|
|
3650
|
+
// way to say that now is the `wait` MODE (a zero means 不等待 today),
|
|
3651
|
+
// so a legacy zero carries over as the mode instead of silently
|
|
3652
|
+
// turning "wait forever" into "answer immediately".
|
|
3653
|
+
const legacy = raw;
|
|
3654
|
+
const legacyWindow = legacy.nativeApprovalTimeoutMs ?? legacy.permissionFallbackTimeoutMs;
|
|
3655
|
+
const legacyNever = legacyWindow === 0;
|
|
3656
|
+
const window = legacyNever
|
|
3657
|
+
? undefined
|
|
3658
|
+
: validateApprovalDeadlines(raw).permissionTimeoutMs;
|
|
3659
|
+
const mode = legacyNever
|
|
3660
|
+
? 'wait'
|
|
3661
|
+
: (raw.mode === undefined
|
|
3662
|
+
? undefined
|
|
3663
|
+
: validatePermissionTimeoutMode(raw.mode, 'bad_payload'));
|
|
3664
|
+
// A stored window must be legal for the mode it runs under: 不等待
|
|
3665
|
+
// (0) belongs to `auto`; under `abort` it would refuse every ask on
|
|
3666
|
+
// arrival, so it floors to the shortest real wait.
|
|
3667
|
+
const effectiveMode = mode ?? this.baselineMode;
|
|
3668
|
+
this.deadlineOverride = {
|
|
3669
|
+
...(window === undefined ? {} : { permissionTimeoutMs: normalizeApprovalWindow(effectiveMode, window) }),
|
|
3670
|
+
...(mode === undefined ? {} : { mode }),
|
|
3671
|
+
};
|
|
3672
|
+
}
|
|
3673
|
+
catch {
|
|
3674
|
+
this.deadlineOverride = {};
|
|
3675
|
+
}
|
|
3216
3676
|
this.loadPersistedChoices(parsed.conversations);
|
|
3217
3677
|
}
|
|
3218
3678
|
}
|
|
@@ -3267,6 +3727,11 @@ export class BridgeManager {
|
|
|
3267
3727
|
...(this.defaultAgentId === undefined ? {} : { defaultAgentId: this.defaultAgentId }),
|
|
3268
3728
|
tagHalo: this.tagHaloSetting,
|
|
3269
3729
|
agents: [...this.defsMap.values()],
|
|
3730
|
+
// The settings page's approval deadlines. Absent while none was ever
|
|
3731
|
+
// written, so a fresh deployment's state file stays exactly as it was.
|
|
3732
|
+
...(Object.keys(this.deadlineOverride).length === 0
|
|
3733
|
+
? {}
|
|
3734
|
+
: { approvalDeadlines: { ...this.deadlineOverride } }),
|
|
3270
3735
|
// The per-conversation picks: agent + model + thought level + mode +
|
|
3271
3736
|
// extra config values chosen in the composer, including for
|
|
3272
3737
|
// conversations with no live session yet.
|
|
@@ -4260,6 +4725,99 @@ export class BridgeManager {
|
|
|
4260
4725
|
this.tagHaloSetting = validateTagHalo(halo);
|
|
4261
4726
|
this.persistAgents();
|
|
4262
4727
|
}
|
|
4728
|
+
/**
|
|
4729
|
+
* Declare the deployment baseline for the approval deadline — the values
|
|
4730
|
+
* `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
|
|
4731
|
+
*
|
|
4732
|
+
* Kept separate from the validated tuning because "the file says 600000" and
|
|
4733
|
+
* "the file says nothing, so 300000 applies" are different facts: the settings
|
|
4734
|
+
* card shows the first as the reset target and the second as a built-in
|
|
4735
|
+
* default. Called once by the host entry, before any bridge exists — and it
|
|
4736
|
+
* also moves the baseline, so a manager built without tuning (a direct
|
|
4737
|
+
* consumer, a test) still runs what the deployment asked for.
|
|
4738
|
+
*/
|
|
4739
|
+
setConfiguredApprovalDeadlines(values, mode) {
|
|
4740
|
+
// The baseline must be legal for the mode it runs under, or the card would
|
|
4741
|
+
// advertise a number the deployment cannot actually run (不等待 under
|
|
4742
|
+
// `abort`) and the reset button would restore it.
|
|
4743
|
+
const effectiveMode = mode ?? this.configuredMode ?? this.baselineMode;
|
|
4744
|
+
const raw = values.permissionTimeoutMs;
|
|
4745
|
+
const window = raw === undefined ? undefined : normalizeApprovalWindow(effectiveMode, raw);
|
|
4746
|
+
this.configuredDeadlines = window === undefined ? {} : { permissionTimeoutMs: window };
|
|
4747
|
+
if (window !== undefined)
|
|
4748
|
+
this.baselineDeadline = window;
|
|
4749
|
+
if (mode !== undefined) {
|
|
4750
|
+
this.configuredMode = mode;
|
|
4751
|
+
this.baselineMode = mode;
|
|
4752
|
+
}
|
|
4753
|
+
this.applyDeadlineTuning();
|
|
4754
|
+
}
|
|
4755
|
+
/**
|
|
4756
|
+
* The approval deadline in force, plus the facts that explain a value the
|
|
4757
|
+
* user did not pick: the config-file baseline an override is outranking. The
|
|
4758
|
+
* mode rides along with its own baseline, for the same reason: the card has
|
|
4759
|
+
* to be able to say "the file asks for `abort`, the value set here wins".
|
|
4760
|
+
*
|
|
4761
|
+
* Everything is reported only when it is actually in play, so the card can
|
|
4762
|
+
* stay silent about a plain default.
|
|
4763
|
+
*/
|
|
4764
|
+
approvalDeadlines() {
|
|
4765
|
+
const deadlines = {
|
|
4766
|
+
permissionTimeoutMs: this.tuning.permissionTimeoutMs,
|
|
4767
|
+
mode: this.tuning.permissionTimeoutMode,
|
|
4768
|
+
};
|
|
4769
|
+
if (Object.keys(this.configuredDeadlines).length > 0)
|
|
4770
|
+
deadlines.configured = { ...this.configuredDeadlines };
|
|
4771
|
+
if (this.configuredMode !== undefined)
|
|
4772
|
+
deadlines.configuredMode = this.configuredMode;
|
|
4773
|
+
return deadlines;
|
|
4774
|
+
}
|
|
4775
|
+
/**
|
|
4776
|
+
* Write the approval deadline, or the mode (omitted keys keep their value),
|
|
4777
|
+
* or drop the persisted override with `reset` so the config-file baseline —
|
|
4778
|
+
* or the built-in default — applies again.
|
|
4779
|
+
*
|
|
4780
|
+
* HOT: the rewrite reaches every live bridge, so a user changing the window
|
|
4781
|
+
* never has to kill a running session to see it take effect (the one thing an
|
|
4782
|
+
* `agent/save`-style restart would cost them). Only asks armed AFTER the
|
|
4783
|
+
* write see the new window; an ask already holding a timer keeps the one it
|
|
4784
|
+
* started with.
|
|
4785
|
+
*/
|
|
4786
|
+
async setApprovalDeadlines(patch, reset = false) {
|
|
4787
|
+
// Merged BEFORE validating, so the whole resulting override passes the same
|
|
4788
|
+
// gate the config file does (an override written by an older build is
|
|
4789
|
+
// re-gated here rather than trusted). The validators also drop unknown
|
|
4790
|
+
// keys, which is what keeps a stale payload from poisoning state.
|
|
4791
|
+
const merged = reset ? {} : { ...this.deadlineOverride, ...patch };
|
|
4792
|
+
const window = validateApprovalDeadlines(merged).permissionTimeoutMs;
|
|
4793
|
+
const mode = merged.mode === undefined
|
|
4794
|
+
? undefined
|
|
4795
|
+
: validatePermissionTimeoutMode(merged.mode, 'bad_payload');
|
|
4796
|
+
// The stored override must be legal for the mode it runs under, not just
|
|
4797
|
+
// legal on its own: 不等待 (0) belongs to `auto`, and a mode switch can
|
|
4798
|
+
// leave it behind. Flooring it HERE (not only in the running value) keeps
|
|
4799
|
+
// the card's number and the running number the same number.
|
|
4800
|
+
const effectiveMode = mode ?? this.baselineMode;
|
|
4801
|
+
this.deadlineOverride = {
|
|
4802
|
+
...(window === undefined ? {} : { permissionTimeoutMs: normalizeApprovalWindow(effectiveMode, window) }),
|
|
4803
|
+
...(mode === undefined ? {} : { mode }),
|
|
4804
|
+
};
|
|
4805
|
+
this.applyDeadlineTuning();
|
|
4806
|
+
this.persistAgents();
|
|
4807
|
+
}
|
|
4808
|
+
/**
|
|
4809
|
+
* Recompute the deadline and the mode from override → deployment baseline,
|
|
4810
|
+
* and push the result to every live bridge.
|
|
4811
|
+
*/
|
|
4812
|
+
applyDeadlineTuning() {
|
|
4813
|
+
const permissionTimeoutMode = this.deadlineOverride.mode ?? this.baselineMode;
|
|
4814
|
+
const permissionTimeoutMs = normalizeApprovalWindow(permissionTimeoutMode, this.deadlineOverride.permissionTimeoutMs ?? this.baselineDeadline);
|
|
4815
|
+
this.tuning.permissionTimeoutMs = permissionTimeoutMs;
|
|
4816
|
+
this.tuning.permissionTimeoutMode = permissionTimeoutMode;
|
|
4817
|
+
const patch = { permissionTimeoutMs, mode: permissionTimeoutMode };
|
|
4818
|
+
for (const bridge of this.bridges.values())
|
|
4819
|
+
bridge.applyApprovalDeadlines(patch);
|
|
4820
|
+
}
|
|
4263
4821
|
dshSessionMap = new Map();
|
|
4264
4822
|
/**
|
|
4265
4823
|
* What each DSH chat session has chosen for its ACP agent — the agent itself
|