dsh-ntr 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +4 -2
- package/README.md +4 -2
- package/cordis.patch.yml +56 -28
- package/lib/bridge.js +378 -330
- package/lib/client/acp-ui.js +60 -32
- package/lib/client/index.js +17 -0
- package/lib/client/panel.js +257 -103
- package/lib/client/permission-dock.js +72 -0
- package/lib/client/session-controls.js +4 -4
- package/lib/client/store.js +88 -22
- package/lib/client.js +584 -226
- package/lib/client.js.map +1 -1
- package/lib/impl.js +128 -45
- package/lib/index.js +20 -52
- package/lib/types/bridge.d.ts +164 -214
- package/lib/types/client/panel.d.ts +59 -20
- package/lib/types/client/permission-dock.d.ts +31 -0
- package/lib/types/client/store.d.ts +52 -15
- package/lib/types/impl.d.ts +93 -27
- package/lib/types/protocol.d.ts +137 -52
- package/package.json +1 -1
- package/src/bridge.ts +433 -417
- package/src/client/acp-ui.tsx +60 -32
- package/src/client/index.tsx +18 -0
- package/src/client/panel.tsx +395 -197
- package/src/client/permission-dock.tsx +133 -0
- package/src/client/session-controls.tsx +4 -4
- package/src/client/store.ts +112 -36
- package/src/impl.ts +166 -82
- package/src/index.ts +20 -65
- package/src/protocol.ts +143 -52
package/lib/bridge.js
CHANGED
|
@@ -12,7 +12,7 @@ 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, validateApprovalDeadlines, } from "./impl.js";
|
|
15
|
+
import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, 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`. */
|
|
@@ -73,9 +73,8 @@ function toAcpMcpServers(defs) {
|
|
|
73
73
|
}
|
|
74
74
|
const DEFAULT_TUNING = {
|
|
75
75
|
startupTimeoutMs: 60_000,
|
|
76
|
-
permissionTimeoutMs:
|
|
77
|
-
|
|
78
|
-
nativeApprovalTimeoutMs: 300_000,
|
|
76
|
+
permissionTimeoutMs: 300_000,
|
|
77
|
+
permissionTimeoutMode: 'wait',
|
|
79
78
|
cancelFallbackMs: 15_000,
|
|
80
79
|
staleCancelRetryDelayMs: 600,
|
|
81
80
|
staleCancelMaxRetries: 2,
|
|
@@ -90,7 +89,6 @@ export class AgentBridge {
|
|
|
90
89
|
onConnectionLost;
|
|
91
90
|
onCatalog;
|
|
92
91
|
onCommands;
|
|
93
|
-
approvalRequester;
|
|
94
92
|
sessionWorkspace;
|
|
95
93
|
state = 'stopped';
|
|
96
94
|
initResult;
|
|
@@ -106,29 +104,18 @@ export class AgentBridge {
|
|
|
106
104
|
/** Inflight `session/load` resumes, shared so concurrent prompts don't double-resume. */
|
|
107
105
|
resuming = new Map();
|
|
108
106
|
permissions = new PermissionLedger();
|
|
109
|
-
/**
|
|
110
|
-
* Why each outstanding ledger ask is on the ledger, keyed by request id and
|
|
111
|
-
* recorded before the `permission/request` broadcast (the hub is
|
|
112
|
-
* synchronous, so a subscriber can read it the moment the frame goes out).
|
|
113
|
-
* Entries live exactly as long as the ask: the ledger's resolve callback
|
|
114
|
-
* deletes them, and every terminal path (answer, timeout, session close,
|
|
115
|
-
* process death) resolves through it.
|
|
116
|
-
*/
|
|
117
|
-
ledgerFallbacks = new Map;
|
|
118
107
|
startupTimeoutMs;
|
|
119
|
-
permissionTimeoutMs;
|
|
120
108
|
/**
|
|
121
|
-
* The
|
|
122
|
-
* settings page rewrites them on a
|
|
123
|
-
* ({@link AgentBridge.applyApprovalDeadlines}), so a user
|
|
124
|
-
* does not have to kill every live session to see it take
|
|
125
|
-
* read per ask
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* holding an armed timer keeps the window it was started with.
|
|
109
|
+
* The ONE approval deadline, and the mode it settles with. Neither is
|
|
110
|
+
* readonly, unlike their neighbors: the settings page rewrites them on a
|
|
111
|
+
* running bridge ({@link AgentBridge.applyApprovalDeadlines}), so a user
|
|
112
|
+
* editing the card does not have to kill every live session to see it take
|
|
113
|
+
* effect. Both are read per ask, so a reassignment reaches every ask from
|
|
114
|
+
* that moment on. An ask already holding an armed timer keeps the window it
|
|
115
|
+
* was started with.
|
|
129
116
|
*/
|
|
130
|
-
|
|
131
|
-
|
|
117
|
+
permissionTimeoutMs;
|
|
118
|
+
permissionTimeoutMode;
|
|
132
119
|
cancelFallbackMs;
|
|
133
120
|
staleCancelRetryDelayMs;
|
|
134
121
|
staleCancelMaxRetries;
|
|
@@ -176,13 +163,8 @@ export class AgentBridge {
|
|
|
176
163
|
/** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
|
|
177
164
|
onCommands, tuning,
|
|
178
165
|
/**
|
|
179
|
-
* Resolved per
|
|
180
|
-
* was built
|
|
181
|
-
*/
|
|
182
|
-
approvalRequester,
|
|
183
|
-
/**
|
|
184
|
-
* Resolved per turn, like {@link approvalRequester}: the host entry may
|
|
185
|
-
* wire the access-mode mapping after this bridge was built.
|
|
166
|
+
* Resolved per turn: the host entry may wire the workspace reader after
|
|
167
|
+
* this bridge was built.
|
|
186
168
|
*/
|
|
187
169
|
sessionWorkspace) {
|
|
188
170
|
this.def = def;
|
|
@@ -191,13 +173,13 @@ export class AgentBridge {
|
|
|
191
173
|
this.onConnectionLost = onConnectionLost;
|
|
192
174
|
this.onCatalog = onCatalog;
|
|
193
175
|
this.onCommands = onCommands;
|
|
194
|
-
this.approvalRequester = approvalRequester;
|
|
195
176
|
this.sessionWorkspace = sessionWorkspace;
|
|
196
177
|
const merged = { ...DEFAULT_TUNING, ...tuning };
|
|
197
178
|
this.startupTimeoutMs = merged.startupTimeoutMs;
|
|
198
|
-
this.
|
|
199
|
-
|
|
200
|
-
|
|
179
|
+
this.permissionTimeoutMode = merged.permissionTimeoutMode;
|
|
180
|
+
// 不等待 (0) is the `auto` mode's business; under `abort` it would refuse
|
|
181
|
+
// every ask on arrival, so the window is floored to the shortest real wait.
|
|
182
|
+
this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, merged.permissionTimeoutMs);
|
|
201
183
|
this.cancelFallbackMs = merged.cancelFallbackMs;
|
|
202
184
|
this.staleCancelRetryDelayMs = merged.staleCancelRetryDelayMs;
|
|
203
185
|
this.staleCancelMaxRetries = merged.staleCancelMaxRetries;
|
|
@@ -206,20 +188,24 @@ export class AgentBridge {
|
|
|
206
188
|
this.probeIdleTimeoutMs = merged.probeIdleTimeoutMs;
|
|
207
189
|
}
|
|
208
190
|
/**
|
|
209
|
-
* Adopt a rewritten approval deadline (the settings page's write).
|
|
210
|
-
*
|
|
211
|
-
* construction, because a live bridge cannot meaningfully adopt a
|
|
212
|
-
* or cancel window mid-flight.
|
|
191
|
+
* Adopt a rewritten approval deadline or mode (the settings page's write).
|
|
192
|
+
* Only the window and the mode live here; every other tuning value is
|
|
193
|
+
* fixed at construction, because a live bridge cannot meaningfully adopt a
|
|
194
|
+
* new startup or cancel window mid-flight.
|
|
213
195
|
*
|
|
214
196
|
* Deliberately does NOT re-arm anything: an ask that already holds a timer
|
|
215
197
|
* keeps the window it was started with, and the next ask reads the new value.
|
|
216
198
|
* Re-arming would move the goalposts under a human who is already deciding.
|
|
217
199
|
*/
|
|
218
200
|
applyApprovalDeadlines(patch) {
|
|
219
|
-
if (patch.
|
|
220
|
-
this.
|
|
221
|
-
if (patch.
|
|
222
|
-
this.
|
|
201
|
+
if (patch.permissionTimeoutMs !== undefined)
|
|
202
|
+
this.permissionTimeoutMs = patch.permissionTimeoutMs;
|
|
203
|
+
if (patch.mode !== undefined)
|
|
204
|
+
this.permissionTimeoutMode = patch.mode;
|
|
205
|
+
// A mode switch can leave a window behind that the new mode cannot run
|
|
206
|
+
// (不等待 under `abort`): floor it, so the ask waits instead of refusing
|
|
207
|
+
// on arrival.
|
|
208
|
+
this.permissionTimeoutMs = normalizeApprovalWindow(this.permissionTimeoutMode, this.permissionTimeoutMs);
|
|
223
209
|
}
|
|
224
210
|
/**
|
|
225
211
|
* The idle-release window this agent honors for its CHAT process: its own
|
|
@@ -968,12 +954,15 @@ export class AgentBridge {
|
|
|
968
954
|
// Closing a session is activity of its own: restart the idle window here
|
|
969
955
|
// rather than at the prompt that opened it.
|
|
970
956
|
this.touch();
|
|
957
|
+
// Answer every outstanding ask BEFORE `session/close` is awaited: an agent
|
|
958
|
+
// blocked on an unanswered `session/request_permission` would otherwise be
|
|
959
|
+
// able to hold the close open indefinitely.
|
|
960
|
+
this.settlePermissions(session);
|
|
971
961
|
const conn = this.conn;
|
|
972
962
|
if (conn !== undefined && this.state === 'ready') {
|
|
973
963
|
// `session/close` is an optional capability; the local teardown stands regardless.
|
|
974
964
|
await conn.agent.request(methods.agent.session.close, { sessionId: session.acpSessionId }).catch(() => { });
|
|
975
965
|
}
|
|
976
|
-
this.settlePermissions(session);
|
|
977
966
|
this.sessions.delete(session.sessionId);
|
|
978
967
|
this.acpIndex.delete(session.acpSessionId);
|
|
979
968
|
this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason });
|
|
@@ -1038,26 +1027,57 @@ export class AgentBridge {
|
|
|
1038
1027
|
}, 3000).unref();
|
|
1039
1028
|
}
|
|
1040
1029
|
/**
|
|
1041
|
-
* Answer one ACP permission ask.
|
|
1042
|
-
*
|
|
1043
|
-
* ledger
|
|
1030
|
+
* Answer one ACP permission ask.
|
|
1031
|
+
*
|
|
1032
|
+
* EVERY ask is answered by a human through the plugin's own ledger. The
|
|
1033
|
+
* host's approval chain is deliberately not involved: it could accept an ask
|
|
1034
|
+
* and render nothing, leaving the turn hanging behind something invisible
|
|
1035
|
+
* with no deadline (issue #2). The ledger broadcasts `permission/request`,
|
|
1036
|
+
* the browser renders the answerable card, and `permission/respond` settles
|
|
1037
|
+
* it.
|
|
1038
|
+
*
|
|
1039
|
+
* The host answers on its own only when a deadline expires: `abort` refuses,
|
|
1040
|
+
* `auto` picks the option the agent's own kinds imply, `wait` never expires.
|
|
1041
|
+
* The ACP request's own `signal` (the agent withdrawing the ask, or the
|
|
1042
|
+
* connection dying) settles the same entry cancelled, so a withdrawn ask can
|
|
1043
|
+
* never leave a stale card behind.
|
|
1044
1044
|
*/
|
|
1045
1045
|
async handlePermissionRequest(acpSessionId, toolCall, options, signal) {
|
|
1046
1046
|
const session = this.acpIndex.get(acpSessionId);
|
|
1047
1047
|
if (session === undefined || session.closed)
|
|
1048
1048
|
return { outcome: { outcome: 'cancelled' } };
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1049
|
+
// The deadline and the mode are read ONCE, here: an ask that started under
|
|
1050
|
+
// one pair keeps it, even if the settings page rewrites them mid-flight.
|
|
1051
|
+
const mode = this.permissionTimeoutMode;
|
|
1052
|
+
const timeoutMs = this.permissionTimeoutMs;
|
|
1053
|
+
// 不等待(0)只属自动推进:请求到达即按那一档收场,不建 ledger 条目、不发卡
|
|
1054
|
+
// ——问都来不及问。超时中止没有这一档(它会立刻拒绝每一问,agent 在每个门口
|
|
1055
|
+
// 都停住),它的窗口最短是真实等待。
|
|
1056
|
+
if (mode === 'auto' && timeoutMs === 0) {
|
|
1057
|
+
return this.settleExpiredAsk(session, mode, toolCall, options, 0);
|
|
1058
|
+
}
|
|
1059
|
+
const optionViews = options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind }));
|
|
1060
|
+
const reason = permissionAskReason(toolCall);
|
|
1061
|
+
// Computed once, at ask creation: the frame, the chat line and the timer
|
|
1062
|
+
// all describe the same instant, and a slow reader cannot stretch it.
|
|
1063
|
+
const deadlineAt = timeoutMs > 0 && mode !== 'wait' ? Date.now() + timeoutMs : undefined;
|
|
1064
|
+
const view = {
|
|
1065
|
+
...(session.dshSessionId === undefined ? {} : { dshSessionId: session.dshSessionId }),
|
|
1066
|
+
// Kept whole for the reconnect replay: a client that connects after the
|
|
1067
|
+
// ask was broadcast must be re-shown the very card it has to answer.
|
|
1068
|
+
toolCall,
|
|
1069
|
+
toolName: permissionToolName(toolCall),
|
|
1070
|
+
...(reason === '' ? {} : { reason }),
|
|
1071
|
+
options: optionViews,
|
|
1072
|
+
timeoutMode: mode,
|
|
1073
|
+
...(deadlineAt === undefined ? {} : { deadlineAt }),
|
|
1074
|
+
};
|
|
1056
1075
|
return new Promise(resolve => {
|
|
1057
1076
|
let timer;
|
|
1077
|
+
let detachSignal;
|
|
1058
1078
|
const requestId = this.permissions.create(session.sessionId, (outcome) => {
|
|
1059
1079
|
clearTimeout(timer);
|
|
1060
|
-
|
|
1080
|
+
detachSignal?.();
|
|
1061
1081
|
this.touch();
|
|
1062
1082
|
this.hub.broadcast({
|
|
1063
1083
|
type: 'permission/resolved',
|
|
@@ -1068,161 +1088,91 @@ export class AgentBridge {
|
|
|
1068
1088
|
resolve(outcome.outcome === 'selected'
|
|
1069
1089
|
? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
|
|
1070
1090
|
: { outcome: { outcome: 'cancelled' } });
|
|
1071
|
-
},
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
});
|
|
1077
|
-
// Deadline (`permissionTimeoutMs`): an approval left unanswered is
|
|
1078
|
-
// answered `cancelled` on the human's behalf instead of hanging the turn
|
|
1079
|
-
// forever. Off by default — approvals belong to a person. The one
|
|
1080
|
-
// exception is a DEGRADED native route: the conversation just failed to
|
|
1081
|
-
// show the question, so waiting forever would hang the turn behind an
|
|
1082
|
-
// ask nobody can see there, and the fallback deadline applies instead
|
|
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.
|
|
1086
|
-
const timeoutMs = this.permissionTimeoutMs > 0
|
|
1087
|
-
? this.permissionTimeoutMs
|
|
1088
|
-
: native.reason === 'unavailable' || native.reason === 'native-timeout'
|
|
1089
|
-
? this.permissionFallbackTimeoutMs
|
|
1090
|
-
: 0;
|
|
1091
|
-
// Recorded before the broadcast: the hub is synchronous, and the chat
|
|
1092
|
-
// line `streamPrompt` folds from this frame words itself by the path
|
|
1093
|
-
// this ask actually took.
|
|
1094
|
-
this.ledgerFallbacks.set(requestId, {
|
|
1095
|
-
reason: native.reason,
|
|
1096
|
-
timeoutMs,
|
|
1097
|
-
...(native.reason === 'native-timeout' ? { waitedMs: this.nativeApprovalTimeoutMs } : {}),
|
|
1098
|
-
});
|
|
1099
|
-
if (timeoutMs > 0) {
|
|
1091
|
+
}, view);
|
|
1092
|
+
// The live frame and the replay frame come from ONE builder, so a
|
|
1093
|
+
// reconnecting client receives exactly what a live one did.
|
|
1094
|
+
this.hub.broadcast(permissionRequestFrame({ requestId, sessionId: session.sessionId, ...view }));
|
|
1095
|
+
if (deadlineAt !== undefined) {
|
|
1100
1096
|
timer = setTimeout(() => {
|
|
1101
1097
|
if (!this.permissions.has(requestId))
|
|
1102
1098
|
return;
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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"`,
|
|
1111
|
-
});
|
|
1112
|
-
this.permissions.respond(requestId, { outcome: 'cancelled' });
|
|
1113
|
-
}, timeoutMs);
|
|
1099
|
+
// Settle IN PLACE: exactly one notice, one `permission/expired` (the
|
|
1100
|
+
// conversation's trace of the expiry) and the ledger answer.
|
|
1101
|
+
const outcome = this.announceExpiredAsk(session, mode, toolCall, options, timeoutMs);
|
|
1102
|
+
this.permissions.respond(requestId, outcome);
|
|
1103
|
+
}, Math.max(0, deadlineAt - Date.now()));
|
|
1114
1104
|
timer.unref?.();
|
|
1115
1105
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1106
|
+
// The agent's own withdrawal settles the entry cancelled; the ledger's
|
|
1107
|
+
// resolve callback broadcasts `permission/resolved`, and drops this
|
|
1108
|
+
// listener, so nothing outlives the ask.
|
|
1109
|
+
if (signal !== undefined) {
|
|
1110
|
+
const onAbort = () => {
|
|
1111
|
+
if (!this.permissions.has(requestId))
|
|
1112
|
+
return;
|
|
1113
|
+
this.permissions.respond(requestId, { outcome: 'cancelled' });
|
|
1114
|
+
};
|
|
1115
|
+
if (signal.aborted) {
|
|
1116
|
+
onAbort();
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1120
|
+
detachSignal = () => { signal.removeEventListener('abort', onAbort); };
|
|
1121
|
+
}
|
|
1123
1122
|
});
|
|
1124
1123
|
}
|
|
1125
1124
|
/**
|
|
1126
|
-
*
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*/
|
|
1131
|
-
ledgerFallbackOf(requestId) {
|
|
1132
|
-
return this.ledgerFallbacks.get(requestId);
|
|
1133
|
-
}
|
|
1134
|
-
/**
|
|
1135
|
-
* Ask the host approval service for this permission (`ctx.approval` → the
|
|
1136
|
-
* browser's composer takeover). Only sessions bound to a DSH chat session
|
|
1137
|
-
* qualify: the service appends its audit pair to that session inside an
|
|
1138
|
-
* open turn, so a session started over `/acp` (no chat turn) cannot be
|
|
1139
|
-
* routed and must stay on the ledger path.
|
|
1140
|
-
*
|
|
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.
|
|
1154
|
-
*
|
|
1155
|
-
* @returns the agent-facing response, or the reason the ask must fall back
|
|
1156
|
-
* to the ledger (see {@link LedgerFallbackReason}).
|
|
1125
|
+
* Announce and compute one ask's expiry. Shared by the 不等待 arrival path
|
|
1126
|
+
* and the ledger deadline, so an expiry can never broadcast two notices or
|
|
1127
|
+
* skip the `permission/expired` line: once the card is gone that frame is
|
|
1128
|
+
* the conversation's only trace of what happened to the question.
|
|
1157
1129
|
*/
|
|
1158
|
-
|
|
1159
|
-
const
|
|
1160
|
-
|
|
1161
|
-
return { reason: 'no-requester' };
|
|
1162
|
-
if (session.dshSessionId === undefined)
|
|
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
|
-
})
|
|
1130
|
+
announceExpiredAsk(session, mode, toolCall, options, timeoutMs) {
|
|
1131
|
+
const picked = mode === 'auto'
|
|
1132
|
+
? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
|
|
1181
1133
|
: undefined;
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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;
|
|
1201
|
-
}
|
|
1202
|
-
catch {
|
|
1203
|
-
// Synchronous throw or rejection, either way: the ask falls back to the
|
|
1204
|
-
// ledger. An approval failure must never break the agent's turn.
|
|
1205
|
-
outcome = 'unavailable';
|
|
1206
|
-
}
|
|
1207
|
-
finally {
|
|
1208
|
-
clearTimeout(timer);
|
|
1209
|
-
}
|
|
1210
|
-
if (outcome === 'unavailable')
|
|
1211
|
-
return { reason: 'unavailable' };
|
|
1212
|
-
const response = nativeOutcomeResponse(outcome, options);
|
|
1213
|
-
// Keep the event stream symmetric with the ledger path: a routed decision
|
|
1214
|
-
// is still a resolution, and the browser uses it to drop any card it may
|
|
1215
|
-
// hold for this session. No `permission/request` is broadcast — the
|
|
1216
|
-
// settings panel must never show a card for a composer-routed ask.
|
|
1134
|
+
const outcome = picked === undefined
|
|
1135
|
+
? { outcome: 'cancelled' }
|
|
1136
|
+
: { outcome: 'selected', optionId: picked.optionId };
|
|
1137
|
+
const tool = permissionToolName(toolCall);
|
|
1138
|
+
// 不等待(0)不是「等了 0 秒」,是没等:两种说法的区别就在这里。
|
|
1139
|
+
const waited = timeoutMs > 0
|
|
1140
|
+
? `timed out after ${String(Math.round(timeoutMs / 1000))}s`
|
|
1141
|
+
: 'was not waited for';
|
|
1217
1142
|
this.hub.broadcast({
|
|
1218
|
-
type: '
|
|
1219
|
-
|
|
1143
|
+
type: 'agent/notice',
|
|
1144
|
+
agentId: this.def.id,
|
|
1145
|
+
level: 'info',
|
|
1146
|
+
// Name what was missed and what was done about it — the way a human
|
|
1147
|
+
// needs it: which operation sat unanswered, and what the plugin answered
|
|
1148
|
+
// instead. OmniTerm's reaper writes the same "you missed this" notice.
|
|
1149
|
+
text: picked === undefined
|
|
1150
|
+
? `acp: permission request for "${tool}" ${waited}; refused`
|
|
1151
|
+
: `acp: permission request for "${tool}" ${waited}; answered "${picked.name}"`,
|
|
1152
|
+
});
|
|
1153
|
+
this.hub.broadcast({
|
|
1154
|
+
type: 'permission/expired',
|
|
1220
1155
|
sessionId: session.sessionId,
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1156
|
+
toolCall,
|
|
1157
|
+
timeoutMs,
|
|
1158
|
+
outcome,
|
|
1159
|
+
...(picked === undefined ? {} : { optionName: picked.name }),
|
|
1224
1160
|
});
|
|
1225
|
-
return
|
|
1161
|
+
return outcome;
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Settle the 不等待 (`auto` + zero window) ask on arrival: no ledger entry
|
|
1165
|
+
* and no card — nothing asks a human, so there is nothing to show. `auto`
|
|
1166
|
+
* answers with the option the agent's own kinds imply ({@link pickAutoOption})
|
|
1167
|
+
* and the agent CONTINUES. A mode that cannot pick an option (no options, or
|
|
1168
|
+
* none with a usable id) degrades to refusing: safer than guessing, and
|
|
1169
|
+
* hanging is worse than either.
|
|
1170
|
+
*/
|
|
1171
|
+
settleExpiredAsk(session, mode, toolCall, options, timeoutMs) {
|
|
1172
|
+
const outcome = this.announceExpiredAsk(session, mode, toolCall, options, timeoutMs);
|
|
1173
|
+
return outcome.outcome === 'selected'
|
|
1174
|
+
? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
|
|
1175
|
+
: { outcome: { outcome: 'cancelled' } };
|
|
1226
1176
|
}
|
|
1227
1177
|
settlePermissions(session) {
|
|
1228
1178
|
this.permissions.settleFor(p => p.sessionId === session.sessionId);
|
|
@@ -1259,7 +1209,7 @@ export class AgentBridge {
|
|
|
1259
1209
|
* cannot reach into the agent's own process, so a second gate here would only
|
|
1260
1210
|
* be a fiction in front of a tool that can write without asking us at all.
|
|
1261
1211
|
* What the agent chooses to ask about goes to the human unchanged
|
|
1262
|
-
* (`
|
|
1212
|
+
* (`handlePermissionRequest`). See docs/acp-convergence.md §2.3.
|
|
1263
1213
|
*/
|
|
1264
1214
|
async handleFsWrite(params) {
|
|
1265
1215
|
const session = this.acpIndex.get(params.sessionId);
|
|
@@ -1361,20 +1311,57 @@ export function permissionToolName(toolCall) {
|
|
|
1361
1311
|
return row.kind;
|
|
1362
1312
|
return 'tool call';
|
|
1363
1313
|
}
|
|
1314
|
+
/** Longest reason line the approval card headline carries. */
|
|
1315
|
+
const APPROVAL_REASON_MAX = 240;
|
|
1316
|
+
/** Input keys that name the operation in one string, most specific first. */
|
|
1317
|
+
const APPROVAL_REASON_INPUT_KEYS = [
|
|
1318
|
+
'command',
|
|
1319
|
+
'cmd',
|
|
1320
|
+
'path',
|
|
1321
|
+
'file_path',
|
|
1322
|
+
'filePath',
|
|
1323
|
+
'pattern',
|
|
1324
|
+
'query',
|
|
1325
|
+
'url',
|
|
1326
|
+
'description',
|
|
1327
|
+
];
|
|
1364
1328
|
/**
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1329
|
+
* One line saying WHAT the permission ask is about.
|
|
1330
|
+
*
|
|
1331
|
+
* WHY: without it the human only sees the tool's NAME ("Bash") and cannot
|
|
1332
|
+
* judge the request; the ACP tool call already declares the missing half — its
|
|
1333
|
+
* title and (for execute/edit tools) a short scalar in `rawInput`. Only ONE declared
|
|
1334
|
+
* string is used, never a serialized object or a whole file, and the line is
|
|
1335
|
+
* capped so a pathological input cannot fill the card or the audit log.
|
|
1367
1336
|
*/
|
|
1368
|
-
function
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1337
|
+
export function permissionAskReason(toolCall) {
|
|
1338
|
+
const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
|
|
1339
|
+
const title = permissionToolName(toolCall);
|
|
1340
|
+
const raw = row.rawInput;
|
|
1341
|
+
const input = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
|
|
1342
|
+
? raw
|
|
1343
|
+
: typeof raw === 'string' && raw.length <= APPROVAL_REASON_MAX * 4
|
|
1344
|
+
// Some agents send the arguments as a JSON string; read one scalar out
|
|
1345
|
+
// of it instead of echoing the blob.
|
|
1346
|
+
? (() => {
|
|
1347
|
+
try {
|
|
1348
|
+
const parsed = JSON.parse(raw);
|
|
1349
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
1350
|
+
? parsed
|
|
1351
|
+
: undefined;
|
|
1352
|
+
}
|
|
1353
|
+
catch {
|
|
1354
|
+
return undefined;
|
|
1355
|
+
}
|
|
1356
|
+
})()
|
|
1357
|
+
: undefined;
|
|
1358
|
+
const detail = input === undefined
|
|
1359
|
+
? undefined
|
|
1360
|
+
: APPROVAL_REASON_INPUT_KEYS
|
|
1361
|
+
.map(key => input[key])
|
|
1362
|
+
.find((value) => typeof value === 'string' && value !== '');
|
|
1363
|
+
const line = detail === undefined || detail === title ? title : `${title} · ${detail}`;
|
|
1364
|
+
return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line;
|
|
1378
1365
|
}
|
|
1379
1366
|
function snapshot(session) {
|
|
1380
1367
|
return {
|
|
@@ -2077,7 +2064,6 @@ export function releaseAllToolOutputWaiters() {
|
|
|
2077
2064
|
class TurnStreamController {
|
|
2078
2065
|
manager;
|
|
2079
2066
|
hub;
|
|
2080
|
-
bridge;
|
|
2081
2067
|
session;
|
|
2082
2068
|
turnId;
|
|
2083
2069
|
endTurn;
|
|
@@ -2092,12 +2078,11 @@ class TurnStreamController {
|
|
|
2092
2078
|
pendingProse = [];
|
|
2093
2079
|
sawVisibleBlock = false;
|
|
2094
2080
|
cursorSeq;
|
|
2095
|
-
constructor(manager, hub,
|
|
2081
|
+
constructor(manager, hub, session, turnId,
|
|
2096
2082
|
/** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
|
|
2097
2083
|
endTurn, rollbackFirstTurn) {
|
|
2098
2084
|
this.manager = manager;
|
|
2099
2085
|
this.hub = hub;
|
|
2100
|
-
this.bridge = bridge;
|
|
2101
2086
|
this.session = session;
|
|
2102
2087
|
this.turnId = turnId;
|
|
2103
2088
|
this.endTurn = endTurn;
|
|
@@ -2319,24 +2304,36 @@ class TurnStreamController {
|
|
|
2319
2304
|
}
|
|
2320
2305
|
else if (row.type === 'permission/request') {
|
|
2321
2306
|
const typed = event;
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
return;
|
|
2328
|
-
}
|
|
2329
|
-
if (seg.hasCard) {
|
|
2330
|
-
// The card must stay the segment's last block: park the line for the
|
|
2331
|
-
// next segment instead of streaming it below the card.
|
|
2332
|
-
this.pendingProse.push(line);
|
|
2333
|
-
this.finishSegment(seg, { kind: 'stop' });
|
|
2334
|
-
return;
|
|
2335
|
-
}
|
|
2336
|
-
if (line !== '')
|
|
2337
|
-
this.emitProse(seg, 'reasoning', line);
|
|
2307
|
+
this.pushProse(this.permissionLine(typed));
|
|
2308
|
+
}
|
|
2309
|
+
else if (row.type === 'permission/expired') {
|
|
2310
|
+
const typed = event;
|
|
2311
|
+
this.pushProse(this.expiredLine(typed));
|
|
2338
2312
|
}
|
|
2339
2313
|
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Put one narrative line into the turn's prose stream.
|
|
2316
|
+
*
|
|
2317
|
+
* A card-bearing segment must stay the segment's LAST block, so a line that
|
|
2318
|
+
* arrives while a card is open is parked for the next segment instead of
|
|
2319
|
+
* streaming below the card. A line that arrives between segments waits for
|
|
2320
|
+
* the next one to open.
|
|
2321
|
+
*/
|
|
2322
|
+
pushProse(line) {
|
|
2323
|
+
if (line === '')
|
|
2324
|
+
return;
|
|
2325
|
+
const seg = this.segment;
|
|
2326
|
+
if (seg === undefined || seg.finished) {
|
|
2327
|
+
this.pendingProse.push(line);
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
if (seg.hasCard) {
|
|
2331
|
+
this.pendingProse.push(line);
|
|
2332
|
+
this.finishSegment(seg, { kind: 'stop' });
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
this.emitProse(seg, 'reasoning', line);
|
|
2336
|
+
}
|
|
2340
2337
|
/** Events that arrive between segments: recorded, replayed into the next one. */
|
|
2341
2338
|
recordWithoutSegment(update) {
|
|
2342
2339
|
const kind = String(update.sessionUpdate ?? '');
|
|
@@ -2471,30 +2468,46 @@ class TurnStreamController {
|
|
|
2471
2468
|
});
|
|
2472
2469
|
}
|
|
2473
2470
|
permissionLine(event) {
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
// The line must
|
|
2478
|
-
//
|
|
2479
|
-
//
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
//
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2471
|
+
// `toolName` is host-derived (`permissionToolName`) so agents that omit
|
|
2472
|
+
// `title` and declare it in `_meta` still name the operation here.
|
|
2473
|
+
const title = event.toolName;
|
|
2474
|
+
// The line must point at the place the answer entry actually is: a
|
|
2475
|
+
// chat-bound ask renders its card in the conversation; an `/acp`-RPC ask
|
|
2476
|
+
// has no conversation, so its card lives on the settings page.
|
|
2477
|
+
const lead = event.dshSessionId !== undefined
|
|
2478
|
+
? 'please answer the permission card above the composer'
|
|
2479
|
+
: 'please approve in Settings';
|
|
2480
|
+
// The last clause says what happens if nobody answers, and it follows the
|
|
2481
|
+
// MODE: refused, answered automatically, or (in `wait`) nothing at all.
|
|
2482
|
+
if (event.timeoutMode === 'wait' || event.deadlineAt === undefined) {
|
|
2483
|
+
return `\n> 🔐 [Permission Required: ${title}] - ${lead}\n`;
|
|
2484
|
+
}
|
|
2485
|
+
const remainingMs = Math.max(0, event.deadlineAt - Date.now());
|
|
2486
|
+
// Floor (+1 ms for the Date.now tick), min 1: rounding up would promise time already spent.
|
|
2487
|
+
const remainingS = Math.max(1, Math.floor((remainingMs + 1) / 1000));
|
|
2488
|
+
const otherwise = event.timeoutMode === 'auto'
|
|
2489
|
+
? 'this request is answered automatically'
|
|
2490
|
+
: 'this request is cancelled';
|
|
2491
|
+
return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(remainingS)}s or ${otherwise}\n`;
|
|
2492
|
+
}
|
|
2493
|
+
/**
|
|
2494
|
+
* The line for an ask the plugin settled itself when its deadline fired.
|
|
2495
|
+
*
|
|
2496
|
+
* The composer card is disappearing, so the conversation has to say what
|
|
2497
|
+
* happened to the question — otherwise the user is left with a vanished
|
|
2498
|
+
* card and no explanation. The wording follows the outcome: refused, or
|
|
2499
|
+
* answered with the option the agent's own kinds implied.
|
|
2500
|
+
*/
|
|
2501
|
+
expiredLine(event) {
|
|
2502
|
+
const title = permissionToolName(event.toolCall);
|
|
2503
|
+
const waited = event.timeoutMs > 0
|
|
2504
|
+
? `no answer within ${String(Math.round(event.timeoutMs / 1000))}s`
|
|
2505
|
+
: 'not waited for';
|
|
2506
|
+
if (event.outcome.outcome === 'selected') {
|
|
2507
|
+
const picked = event.optionName === undefined ? event.outcome.optionId : event.optionName;
|
|
2508
|
+
return `\n> 🔐 [Permission Required: ${title}] - ${waited}; answered "${picked}" automatically, continuing\n`;
|
|
2509
|
+
}
|
|
2510
|
+
return `\n> 🔐 [Permission Required: ${title}] - ${waited}; refused\n`;
|
|
2498
2511
|
}
|
|
2499
2512
|
}
|
|
2500
2513
|
/** Chunk text shared by the controller's fold (string / object / array forms). */
|
|
@@ -2539,17 +2552,23 @@ export class BridgeManager {
|
|
|
2539
2552
|
/** The agent tag's halo preference (persisted; see `persistAgents`). */
|
|
2540
2553
|
tagHaloSetting = { ...DEFAULT_TAG_HALO };
|
|
2541
2554
|
/**
|
|
2542
|
-
* What `cordis.patch.yml` asked for
|
|
2543
|
-
*
|
|
2555
|
+
* What `cordis.patch.yml` asked for — ONLY if it actually named the key.
|
|
2556
|
+
* This is the deployment baseline, kept apart from
|
|
2544
2557
|
* {@link deadlineOverride} so the settings card can tell the user which of
|
|
2545
|
-
* the two is winning. Empty when the file says nothing about
|
|
2546
|
-
*
|
|
2558
|
+
* the two is winning. Empty when the file says nothing about the window, in
|
|
2559
|
+
* which case {@link baselineDeadline} (the built-in default) applies.
|
|
2547
2560
|
*/
|
|
2548
2561
|
configuredDeadlines = {};
|
|
2549
2562
|
/**
|
|
2550
|
-
* The
|
|
2551
|
-
*
|
|
2552
|
-
*
|
|
2563
|
+
* The mode the config file named, if it named one. Kept apart from the
|
|
2564
|
+
* override for the same reason the window is: the card has to be able
|
|
2565
|
+
* to say "the file asks for `abort`, the value set here wins".
|
|
2566
|
+
*/
|
|
2567
|
+
configuredMode;
|
|
2568
|
+
/**
|
|
2569
|
+
* The window before any override — the tuning this manager was constructed
|
|
2570
|
+
* with, which already carries the validated config file (the host entry
|
|
2571
|
+
* passes `validateConfig`'s output) or the built-in default.
|
|
2553
2572
|
*
|
|
2554
2573
|
* Held separately from {@link configuredDeadlines} because the two answer
|
|
2555
2574
|
* different questions: this one is what RUNS when nothing is overridden, that
|
|
@@ -2557,17 +2576,23 @@ export class BridgeManager {
|
|
|
2557
2576
|
* and a bare default does not deserve). A manager built without tuning at all
|
|
2558
2577
|
* — a direct consumer, a test — gets `DEFAULT_TUNING` here and stays correct.
|
|
2559
2578
|
*/
|
|
2560
|
-
|
|
2579
|
+
baselineDeadline;
|
|
2580
|
+
/**
|
|
2581
|
+
* The mode before any override — the tuning this manager was constructed
|
|
2582
|
+
* with, which already carries the validated config file (the host entry
|
|
2583
|
+
* passes `validateConfig`'s output) or the built-in default.
|
|
2584
|
+
*/
|
|
2585
|
+
baselineMode;
|
|
2561
2586
|
/**
|
|
2562
2587
|
* The settings page's own write, persisted with the agent registry. Merged
|
|
2563
|
-
* OVER {@link
|
|
2588
|
+
* OVER {@link baselineDeadline}: once a user picks a window in the UI, that
|
|
2564
2589
|
* pick is what runs, and the config file becomes the value the reset button
|
|
2565
2590
|
* returns to. Without this split a hand-edited `cordis.patch.yml` and a UI
|
|
2566
2591
|
* write would fight with no way to tell which one is live.
|
|
2567
2592
|
*/
|
|
2568
2593
|
deadlineOverride = {};
|
|
2569
2594
|
/**
|
|
2570
|
-
* Tuning in force, defaults applied. The
|
|
2595
|
+
* Tuning in force, defaults applied. The approval deadline is rewritten
|
|
2571
2596
|
* IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
|
|
2572
2597
|
* rather than the constructor's parameter: every bridge built afterwards
|
|
2573
2598
|
* inherits the rewrite instead of the boot-time value.
|
|
@@ -2624,13 +2649,11 @@ export class BridgeManager {
|
|
|
2624
2649
|
// `applyDeadlineTuning`), and every bridge built afterwards must inherit
|
|
2625
2650
|
// the rewrite instead of the boot-time value.
|
|
2626
2651
|
this.tuning = { ...DEFAULT_TUNING, ...tuning };
|
|
2627
|
-
// The pre-override deadline
|
|
2628
|
-
//
|
|
2629
|
-
// only this
|
|
2630
|
-
this.
|
|
2631
|
-
|
|
2632
|
-
permissionFallbackTimeoutMs: this.tuning.permissionFallbackTimeoutMs,
|
|
2633
|
-
};
|
|
2652
|
+
// The pre-override deadline value, snapshotted before anything can rewrite
|
|
2653
|
+
// it. Everything else in `tuning` is fixed for the manager's lifetime, so
|
|
2654
|
+
// only this needs a snapshot.
|
|
2655
|
+
this.baselineDeadline = this.tuning.permissionTimeoutMs;
|
|
2656
|
+
this.baselineMode = this.tuning.permissionTimeoutMode;
|
|
2634
2657
|
for (const def of defs) {
|
|
2635
2658
|
this.defsMap.set(def.id, def);
|
|
2636
2659
|
}
|
|
@@ -2651,27 +2674,13 @@ export class BridgeManager {
|
|
|
2651
2674
|
routeBindingRegistry().defaultModel = this.defaultAgent();
|
|
2652
2675
|
}
|
|
2653
2676
|
makeBridge(def, onConnectionLost, onCatalog, onCommands) {
|
|
2654
|
-
return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.
|
|
2655
|
-
}
|
|
2656
|
-
/**
|
|
2657
|
-
* Route ACP permission asks through the host approval service (answered in
|
|
2658
|
-
* the chat composer). Absent → every ask stays on the settings-panel ledger.
|
|
2659
|
-
* Setting it also flips {@link approvalRouting}, which the browser reads to
|
|
2660
|
-
* stop rendering the settings-panel permission card.
|
|
2661
|
-
*/
|
|
2662
|
-
setApprovalRequester(requester) {
|
|
2663
|
-
this.approvalRequester = requester;
|
|
2664
|
-
}
|
|
2665
|
-
/** True when permission asks go to native approval instead of the settings panel. */
|
|
2666
|
-
approvalRouting() {
|
|
2667
|
-
return this.approvalRequester !== undefined;
|
|
2677
|
+
return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver);
|
|
2668
2678
|
}
|
|
2669
2679
|
/**
|
|
2670
|
-
* Every outstanding
|
|
2671
|
-
*
|
|
2672
|
-
* `permissionReplayFrames`).
|
|
2673
|
-
*
|
|
2674
|
-
* takeover the client already holds.
|
|
2680
|
+
* Every outstanding ask, across all agents, with the view the browser was
|
|
2681
|
+
* shown it — the source of the reconnect replay (see
|
|
2682
|
+
* `permissionReplayFrames`). Every ACP ask is a ledger entry, so this is the
|
|
2683
|
+
* whole live set.
|
|
2675
2684
|
*/
|
|
2676
2685
|
pendingPermissionViews() {
|
|
2677
2686
|
const views = [];
|
|
@@ -3469,7 +3478,31 @@ export class BridgeManager {
|
|
|
3469
3478
|
// validator accepts must be the value that runs (a UI write that did
|
|
3470
3479
|
// not survive a restart would read as a broken setting).
|
|
3471
3480
|
try {
|
|
3472
|
-
|
|
3481
|
+
const raw = parsed.approvalDeadlines ?? {};
|
|
3482
|
+
// The OLD card wrote the two legacy windows, and its 「关」 preset
|
|
3483
|
+
// wrote a zero — which then meant "this window never fires". The only
|
|
3484
|
+
// way to say that now is the `wait` MODE (a zero means 不等待 today),
|
|
3485
|
+
// so a legacy zero carries over as the mode instead of silently
|
|
3486
|
+
// turning "wait forever" into "answer immediately".
|
|
3487
|
+
const legacy = raw;
|
|
3488
|
+
const legacyWindow = legacy.nativeApprovalTimeoutMs ?? legacy.permissionFallbackTimeoutMs;
|
|
3489
|
+
const legacyNever = legacyWindow === 0;
|
|
3490
|
+
const window = legacyNever
|
|
3491
|
+
? undefined
|
|
3492
|
+
: validateApprovalDeadlines(raw).permissionTimeoutMs;
|
|
3493
|
+
const mode = legacyNever
|
|
3494
|
+
? 'wait'
|
|
3495
|
+
: (raw.mode === undefined
|
|
3496
|
+
? undefined
|
|
3497
|
+
: validatePermissionTimeoutMode(raw.mode, 'bad_payload'));
|
|
3498
|
+
// A stored window must be legal for the mode it runs under: 不等待
|
|
3499
|
+
// (0) belongs to `auto`; under `abort` it would refuse every ask on
|
|
3500
|
+
// arrival, so it floors to the shortest real wait.
|
|
3501
|
+
const effectiveMode = mode ?? this.baselineMode;
|
|
3502
|
+
this.deadlineOverride = {
|
|
3503
|
+
...(window === undefined ? {} : { permissionTimeoutMs: normalizeApprovalWindow(effectiveMode, window) }),
|
|
3504
|
+
...(mode === undefined ? {} : { mode }),
|
|
3505
|
+
};
|
|
3473
3506
|
}
|
|
3474
3507
|
catch {
|
|
3475
3508
|
this.deadlineOverride = {};
|
|
@@ -4527,7 +4560,7 @@ export class BridgeManager {
|
|
|
4527
4560
|
this.persistAgents();
|
|
4528
4561
|
}
|
|
4529
4562
|
/**
|
|
4530
|
-
* Declare the deployment baseline for the
|
|
4563
|
+
* Declare the deployment baseline for the approval deadline — the values
|
|
4531
4564
|
* `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
|
|
4532
4565
|
*
|
|
4533
4566
|
* Kept separate from the validated tuning because "the file says 600000" and
|
|
@@ -4537,36 +4570,46 @@ export class BridgeManager {
|
|
|
4537
4570
|
* also moves the baseline, so a manager built without tuning (a direct
|
|
4538
4571
|
* consumer, a test) still runs what the deployment asked for.
|
|
4539
4572
|
*/
|
|
4540
|
-
setConfiguredApprovalDeadlines(values) {
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4573
|
+
setConfiguredApprovalDeadlines(values, mode) {
|
|
4574
|
+
// The baseline must be legal for the mode it runs under, or the card would
|
|
4575
|
+
// advertise a number the deployment cannot actually run (不等待 under
|
|
4576
|
+
// `abort`) and the reset button would restore it.
|
|
4577
|
+
const effectiveMode = mode ?? this.configuredMode ?? this.baselineMode;
|
|
4578
|
+
const raw = values.permissionTimeoutMs;
|
|
4579
|
+
const window = raw === undefined ? undefined : normalizeApprovalWindow(effectiveMode, raw);
|
|
4580
|
+
this.configuredDeadlines = window === undefined ? {} : { permissionTimeoutMs: window };
|
|
4581
|
+
if (window !== undefined)
|
|
4582
|
+
this.baselineDeadline = window;
|
|
4583
|
+
if (mode !== undefined) {
|
|
4584
|
+
this.configuredMode = mode;
|
|
4585
|
+
this.baselineMode = mode;
|
|
4586
|
+
}
|
|
4546
4587
|
this.applyDeadlineTuning();
|
|
4547
4588
|
}
|
|
4548
4589
|
/**
|
|
4549
|
-
* The approval
|
|
4550
|
-
* user did not pick: the config-file baseline an override is outranking
|
|
4551
|
-
*
|
|
4552
|
-
*
|
|
4553
|
-
*
|
|
4590
|
+
* The approval deadline in force, plus the facts that explain a value the
|
|
4591
|
+
* user did not pick: the config-file baseline an override is outranking. The
|
|
4592
|
+
* mode rides along with its own baseline, for the same reason: the card has
|
|
4593
|
+
* to be able to say "the file asks for `abort`, the value set here wins".
|
|
4594
|
+
*
|
|
4595
|
+
* Everything is reported only when it is actually in play, so the card can
|
|
4596
|
+
* stay silent about a plain default.
|
|
4554
4597
|
*/
|
|
4555
4598
|
approvalDeadlines() {
|
|
4556
4599
|
const deadlines = {
|
|
4557
|
-
|
|
4558
|
-
|
|
4600
|
+
permissionTimeoutMs: this.tuning.permissionTimeoutMs,
|
|
4601
|
+
mode: this.tuning.permissionTimeoutMode,
|
|
4559
4602
|
};
|
|
4560
4603
|
if (Object.keys(this.configuredDeadlines).length > 0)
|
|
4561
4604
|
deadlines.configured = { ...this.configuredDeadlines };
|
|
4562
|
-
if (this.
|
|
4563
|
-
deadlines.
|
|
4605
|
+
if (this.configuredMode !== undefined)
|
|
4606
|
+
deadlines.configuredMode = this.configuredMode;
|
|
4564
4607
|
return deadlines;
|
|
4565
4608
|
}
|
|
4566
4609
|
/**
|
|
4567
|
-
* Write
|
|
4568
|
-
* drop the persisted override with `reset` so the config-file baseline —
|
|
4569
|
-
* the built-in default — applies again.
|
|
4610
|
+
* Write the approval deadline, or the mode (omitted keys keep their value),
|
|
4611
|
+
* or drop the persisted override with `reset` so the config-file baseline —
|
|
4612
|
+
* or the built-in default — applies again.
|
|
4570
4613
|
*
|
|
4571
4614
|
* HOT: the rewrite reaches every live bridge, so a user changing the window
|
|
4572
4615
|
* never has to kill a running session to see it take effect (the one thing an
|
|
@@ -4577,25 +4620,35 @@ export class BridgeManager {
|
|
|
4577
4620
|
async setApprovalDeadlines(patch, reset = false) {
|
|
4578
4621
|
// Merged BEFORE validating, so the whole resulting override passes the same
|
|
4579
4622
|
// gate the config file does (an override written by an older build is
|
|
4580
|
-
// re-gated here rather than trusted).
|
|
4581
|
-
//
|
|
4623
|
+
// re-gated here rather than trusted). The validators also drop unknown
|
|
4624
|
+
// keys, which is what keeps a stale payload from poisoning state.
|
|
4582
4625
|
const merged = reset ? {} : { ...this.deadlineOverride, ...patch };
|
|
4583
|
-
|
|
4626
|
+
const window = validateApprovalDeadlines(merged).permissionTimeoutMs;
|
|
4627
|
+
const mode = merged.mode === undefined
|
|
4628
|
+
? undefined
|
|
4629
|
+
: validatePermissionTimeoutMode(merged.mode, 'bad_payload');
|
|
4630
|
+
// The stored override must be legal for the mode it runs under, not just
|
|
4631
|
+
// legal on its own: 不等待 (0) belongs to `auto`, and a mode switch can
|
|
4632
|
+
// leave it behind. Flooring it HERE (not only in the running value) keeps
|
|
4633
|
+
// the card's number and the running number the same number.
|
|
4634
|
+
const effectiveMode = mode ?? this.baselineMode;
|
|
4635
|
+
this.deadlineOverride = {
|
|
4636
|
+
...(window === undefined ? {} : { permissionTimeoutMs: normalizeApprovalWindow(effectiveMode, window) }),
|
|
4637
|
+
...(mode === undefined ? {} : { mode }),
|
|
4638
|
+
};
|
|
4584
4639
|
this.applyDeadlineTuning();
|
|
4585
4640
|
this.persistAgents();
|
|
4586
4641
|
}
|
|
4587
4642
|
/**
|
|
4588
|
-
* Recompute the
|
|
4589
|
-
* the result to every live bridge.
|
|
4643
|
+
* Recompute the deadline and the mode from override → deployment baseline,
|
|
4644
|
+
* and push the result to every live bridge.
|
|
4590
4645
|
*/
|
|
4591
4646
|
applyDeadlineTuning() {
|
|
4592
|
-
const
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
this.tuning.permissionFallbackTimeoutMs = permissionFallbackTimeoutMs;
|
|
4598
|
-
const patch = { nativeApprovalTimeoutMs, permissionFallbackTimeoutMs };
|
|
4647
|
+
const permissionTimeoutMode = this.deadlineOverride.mode ?? this.baselineMode;
|
|
4648
|
+
const permissionTimeoutMs = normalizeApprovalWindow(permissionTimeoutMode, this.deadlineOverride.permissionTimeoutMs ?? this.baselineDeadline);
|
|
4649
|
+
this.tuning.permissionTimeoutMs = permissionTimeoutMs;
|
|
4650
|
+
this.tuning.permissionTimeoutMode = permissionTimeoutMode;
|
|
4651
|
+
const patch = { permissionTimeoutMs, mode: permissionTimeoutMode };
|
|
4599
4652
|
for (const bridge of this.bridges.values())
|
|
4600
4653
|
bridge.applyApprovalDeadlines(patch);
|
|
4601
4654
|
}
|
|
@@ -4615,11 +4668,6 @@ export class BridgeManager {
|
|
|
4615
4668
|
this.dshSessionMap.delete(key);
|
|
4616
4669
|
}
|
|
4617
4670
|
}
|
|
4618
|
-
/**
|
|
4619
|
-
* Latest native-approval requester. Held as a field (read through a thunk by
|
|
4620
|
-
* each bridge) so a requester wired after bridges were built still applies.
|
|
4621
|
-
*/
|
|
4622
|
-
approvalRequester;
|
|
4623
4671
|
sessionWorkspaceResolver;
|
|
4624
4672
|
acpModeResolver;
|
|
4625
4673
|
/** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
|
|
@@ -4754,7 +4802,7 @@ export class BridgeManager {
|
|
|
4754
4802
|
if (existing !== undefined && existing.released) {
|
|
4755
4803
|
const resumed = await this.bridge(agentId).resumeSession(existing);
|
|
4756
4804
|
// Rebind explicitly: a resume keeps the record's identity, but the chat
|
|
4757
|
-
// session id is what
|
|
4805
|
+
// session id is what the conversation's permission card addresses.
|
|
4758
4806
|
resumed.dshSessionId = dshSessionId;
|
|
4759
4807
|
// A resume without an explicit pick must keep serving the agent the
|
|
4760
4808
|
// conversation was ON (its own model string when one was recorded). The
|
|
@@ -4792,7 +4840,7 @@ export class BridgeManager {
|
|
|
4792
4840
|
// `ensureReady` for why `session/new`'s cwd alone is not enough.
|
|
4793
4841
|
workspaceRoot);
|
|
4794
4842
|
// Absent for `/acp`-RPC sessions and for adapter calls without a chat
|
|
4795
|
-
// session;
|
|
4843
|
+
// session; their permission cards are addressed by the ACP session only.
|
|
4796
4844
|
session.dshSessionId = dshSessionId;
|
|
4797
4845
|
this.dshSessionMap.set(key, session);
|
|
4798
4846
|
// Turn the displayed defaults into this conversation's pick, so the first
|
|
@@ -5072,7 +5120,7 @@ export class BridgeManager {
|
|
|
5072
5120
|
// loop then executes the card and proposes the continuation step, which
|
|
5073
5121
|
// resumes the SAME prompt through the branch above.
|
|
5074
5122
|
const endTurn = bridge.beginTurn(session);
|
|
5075
|
-
const controller = new TurnStreamController(this, this.hub,
|
|
5123
|
+
const controller = new TurnStreamController(this, this.hub, session, turnId, endTurn, victim => this.rollbackFirstTurn(victim));
|
|
5076
5124
|
if (session.dshSessionId !== undefined)
|
|
5077
5125
|
this.rememberTurnStream(session.dshSessionId, controller);
|
|
5078
5126
|
const segment = controller.startSegment();
|