dsh-ntr 0.1.6 → 0.2.1
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 +6 -3
- package/README.md +6 -3
- package/cordis.patch.yml +18 -18
- package/lib/bridge.js +134 -314
- package/lib/client/acp-ui.js +60 -32
- package/lib/client/index.js +17 -0
- package/lib/client/panel.js +127 -114
- package/lib/client/permission-dock.js +72 -0
- package/lib/client/session-controls.js +4 -4
- package/lib/client/store.js +58 -12
- package/lib/client.js +696 -393
- package/lib/client.js.map +1 -1
- package/lib/impl.js +30 -19
- package/lib/index.js +6 -51
- package/lib/types/bridge.d.ts +63 -157
- package/lib/types/client/panel.d.ts +12 -20
- package/lib/types/client/permission-dock.d.ts +31 -0
- package/lib/types/client/store.d.ts +42 -12
- package/lib/types/impl.d.ts +31 -17
- package/lib/types/protocol.d.ts +27 -12
- package/package.json +5 -1
- package/src/bridge.ts +163 -379
- package/src/client/acp-ui.tsx +60 -32
- package/src/client/index.tsx +18 -0
- package/src/client/panel.tsx +243 -258
- package/src/client/permission-dock.tsx +133 -0
- package/src/client/session-controls.tsx +4 -4
- package/src/client/store.ts +81 -25
- package/src/impl.ts +49 -29
- package/src/index.ts +6 -65
- package/src/protocol.ts +32 -12
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, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } 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`. */
|
|
@@ -89,7 +89,6 @@ export class AgentBridge {
|
|
|
89
89
|
onConnectionLost;
|
|
90
90
|
onCatalog;
|
|
91
91
|
onCommands;
|
|
92
|
-
approvalRequester;
|
|
93
92
|
sessionWorkspace;
|
|
94
93
|
state = 'stopped';
|
|
95
94
|
initResult;
|
|
@@ -105,15 +104,6 @@ export class AgentBridge {
|
|
|
105
104
|
/** Inflight `session/load` resumes, shared so concurrent prompts don't double-resume. */
|
|
106
105
|
resuming = new Map();
|
|
107
106
|
permissions = new PermissionLedger();
|
|
108
|
-
/**
|
|
109
|
-
* Why each outstanding ledger ask is on the ledger, keyed by request id and
|
|
110
|
-
* recorded before the `permission/request` broadcast (the hub is
|
|
111
|
-
* synchronous, so a subscriber can read it the moment the frame goes out).
|
|
112
|
-
* Entries live exactly as long as the ask: the ledger's resolve callback
|
|
113
|
-
* deletes them, and every terminal path (answer, timeout, session close,
|
|
114
|
-
* process death) resolves through it.
|
|
115
|
-
*/
|
|
116
|
-
ledgerFallbacks = new Map;
|
|
117
107
|
startupTimeoutMs;
|
|
118
108
|
/**
|
|
119
109
|
* The ONE approval deadline, and the mode it settles with. Neither is
|
|
@@ -173,13 +163,8 @@ export class AgentBridge {
|
|
|
173
163
|
/** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
|
|
174
164
|
onCommands, tuning,
|
|
175
165
|
/**
|
|
176
|
-
* Resolved per
|
|
177
|
-
* was built
|
|
178
|
-
*/
|
|
179
|
-
approvalRequester,
|
|
180
|
-
/**
|
|
181
|
-
* Resolved per turn, like {@link approvalRequester}: the host entry may
|
|
182
|
-
* 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.
|
|
183
168
|
*/
|
|
184
169
|
sessionWorkspace) {
|
|
185
170
|
this.def = def;
|
|
@@ -188,7 +173,6 @@ export class AgentBridge {
|
|
|
188
173
|
this.onConnectionLost = onConnectionLost;
|
|
189
174
|
this.onCatalog = onCatalog;
|
|
190
175
|
this.onCommands = onCommands;
|
|
191
|
-
this.approvalRequester = approvalRequester;
|
|
192
176
|
this.sessionWorkspace = sessionWorkspace;
|
|
193
177
|
const merged = { ...DEFAULT_TUNING, ...tuning };
|
|
194
178
|
this.startupTimeoutMs = merged.startupTimeoutMs;
|
|
@@ -970,12 +954,15 @@ export class AgentBridge {
|
|
|
970
954
|
// Closing a session is activity of its own: restart the idle window here
|
|
971
955
|
// rather than at the prompt that opened it.
|
|
972
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);
|
|
973
961
|
const conn = this.conn;
|
|
974
962
|
if (conn !== undefined && this.state === 'ready') {
|
|
975
963
|
// `session/close` is an optional capability; the local teardown stands regardless.
|
|
976
964
|
await conn.agent.request(methods.agent.session.close, { sessionId: session.acpSessionId }).catch(() => { });
|
|
977
965
|
}
|
|
978
|
-
this.settlePermissions(session);
|
|
979
966
|
this.sessions.delete(session.sessionId);
|
|
980
967
|
this.acpIndex.delete(session.acpSessionId);
|
|
981
968
|
this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason });
|
|
@@ -1040,30 +1027,27 @@ export class AgentBridge {
|
|
|
1040
1027
|
}, 3000).unref();
|
|
1041
1028
|
}
|
|
1042
1029
|
/**
|
|
1043
|
-
* Answer one ACP permission ask.
|
|
1044
|
-
*
|
|
1045
|
-
* 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.
|
|
1046
1044
|
*/
|
|
1047
1045
|
async handlePermissionRequest(acpSessionId, toolCall, options, signal) {
|
|
1048
1046
|
const session = this.acpIndex.get(acpSessionId);
|
|
1049
1047
|
if (session === undefined || session.closed)
|
|
1050
1048
|
return { outcome: { outcome: 'cancelled' } };
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
const native = await this.askNativeApproval(session, toolCall, options, signal);
|
|
1054
|
-
if ('response' in native) {
|
|
1055
|
-
this.touch();
|
|
1056
|
-
return native.response;
|
|
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.
|
|
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.
|
|
1067
1051
|
const mode = this.permissionTimeoutMode;
|
|
1068
1052
|
const timeoutMs = this.permissionTimeoutMs;
|
|
1069
1053
|
// 不等待(0)只属自动推进:请求到达即按那一档收场,不建 ledger 条目、不发卡
|
|
@@ -1072,11 +1056,28 @@ export class AgentBridge {
|
|
|
1072
1056
|
if (mode === 'auto' && timeoutMs === 0) {
|
|
1073
1057
|
return this.settleExpiredAsk(session, mode, toolCall, options, 0);
|
|
1074
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
|
+
};
|
|
1075
1075
|
return new Promise(resolve => {
|
|
1076
1076
|
let timer;
|
|
1077
|
+
let detachSignal;
|
|
1077
1078
|
const requestId = this.permissions.create(session.sessionId, (outcome) => {
|
|
1078
1079
|
clearTimeout(timer);
|
|
1079
|
-
|
|
1080
|
+
detachSignal?.();
|
|
1080
1081
|
this.touch();
|
|
1081
1082
|
this.hub.broadcast({
|
|
1082
1083
|
type: 'permission/resolved',
|
|
@@ -1087,194 +1088,52 @@ export class AgentBridge {
|
|
|
1087
1088
|
resolve(outcome.outcome === 'selected'
|
|
1088
1089
|
? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
|
|
1089
1090
|
: { outcome: { outcome: 'cancelled' } });
|
|
1090
|
-
},
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
});
|
|
1096
|
-
// Recorded before the broadcast: the hub is synchronous, and the chat
|
|
1097
|
-
// line `streamPrompt` folds from this frame words itself by the path
|
|
1098
|
-
// this ask actually took.
|
|
1099
|
-
this.ledgerFallbacks.set(requestId, { reason: native.reason, timeoutMs, mode });
|
|
1100
|
-
if (timeoutMs > 0 && mode !== 'wait') {
|
|
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) {
|
|
1101
1096
|
timer = setTimeout(() => {
|
|
1102
1097
|
if (!this.permissions.has(requestId))
|
|
1103
1098
|
return;
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
type: 'agent/notice',
|
|
1110
|
-
agentId: this.def.id,
|
|
1111
|
-
level: 'info',
|
|
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}"`,
|
|
1118
|
-
});
|
|
1119
|
-
this.permissions.respond(requestId, picked === undefined
|
|
1120
|
-
? { outcome: 'cancelled' }
|
|
1121
|
-
: { outcome: 'selected', optionId: picked.optionId });
|
|
1122
|
-
}, 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()));
|
|
1123
1104
|
timer.unref?.();
|
|
1124
1105
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
ledgerFallbackOf(requestId) {
|
|
1141
|
-
return this.ledgerFallbacks.get(requestId);
|
|
1142
|
-
}
|
|
1143
|
-
/**
|
|
1144
|
-
* Ask the host approval service for this permission (`ctx.approval` → the
|
|
1145
|
-
* browser's composer takeover). Only sessions bound to a DSH chat session
|
|
1146
|
-
* qualify: the service appends its audit pair to that session inside an
|
|
1147
|
-
* open turn, so a session started over `/acp` (no chat turn) cannot be
|
|
1148
|
-
* routed and must stay on the ledger path.
|
|
1149
|
-
*
|
|
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.
|
|
1163
|
-
*
|
|
1164
|
-
* @returns the agent-facing response, or the reason the ask must fall back
|
|
1165
|
-
* to the ledger (see {@link LedgerFallbackReason}).
|
|
1166
|
-
*/
|
|
1167
|
-
async askNativeApproval(session, toolCall, options, signal) {
|
|
1168
|
-
const requester = this.approvalRequester?.();
|
|
1169
|
-
if (requester === undefined)
|
|
1170
|
-
return { reason: 'no-requester' };
|
|
1171
|
-
if (session.dshSessionId === undefined)
|
|
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;
|
|
1204
|
-
let outcome;
|
|
1205
|
-
try {
|
|
1206
|
-
const row = (typeof toolCall === 'object' && toolCall !== null ? toolCall : {});
|
|
1207
|
-
const asked = requester({
|
|
1208
|
-
dshSessionId: session.dshSessionId,
|
|
1209
|
-
agentId: this.def.id,
|
|
1210
|
-
toolName: permissionToolName(toolCall),
|
|
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,
|
|
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) };
|
|
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); };
|
|
1229
1121
|
}
|
|
1230
|
-
outcome = settled.outcome;
|
|
1231
|
-
}
|
|
1232
|
-
catch {
|
|
1233
|
-
// Synchronous throw or rejection, either way: the ask falls back to the
|
|
1234
|
-
// ledger. An approval failure must never break the agent's turn.
|
|
1235
|
-
outcome = 'unavailable';
|
|
1236
|
-
}
|
|
1237
|
-
finally {
|
|
1238
|
-
clearTimeout(timer);
|
|
1239
|
-
}
|
|
1240
|
-
if (outcome === 'unavailable')
|
|
1241
|
-
return { reason: 'unavailable' };
|
|
1242
|
-
const response = nativeOutcomeResponse(outcome, options);
|
|
1243
|
-
// Keep the event stream symmetric with the ledger path: a routed decision
|
|
1244
|
-
// is still a resolution, and the browser uses it to drop any card it may
|
|
1245
|
-
// hold for this session. No `permission/request` is broadcast — the
|
|
1246
|
-
// settings panel must never show a card for a composer-routed ask.
|
|
1247
|
-
this.hub.broadcast({
|
|
1248
|
-
type: 'permission/resolved',
|
|
1249
|
-
requestId: randomUUID(),
|
|
1250
|
-
sessionId: session.sessionId,
|
|
1251
|
-
outcome: response.outcome.outcome === 'selected'
|
|
1252
|
-
? { outcome: 'selected', optionId: response.outcome.optionId }
|
|
1253
|
-
: { outcome: 'cancelled' },
|
|
1254
1122
|
});
|
|
1255
|
-
return { response };
|
|
1256
1123
|
}
|
|
1257
1124
|
/**
|
|
1258
|
-
*
|
|
1259
|
-
*
|
|
1260
|
-
* `
|
|
1261
|
-
*
|
|
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.
|
|
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.
|
|
1270
1129
|
*/
|
|
1271
|
-
|
|
1130
|
+
announceExpiredAsk(session, mode, toolCall, options, timeoutMs) {
|
|
1272
1131
|
const picked = mode === 'auto'
|
|
1273
1132
|
? pickAutoOption(options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })))
|
|
1274
1133
|
: undefined;
|
|
1275
|
-
const
|
|
1276
|
-
? { outcome:
|
|
1277
|
-
: { outcome:
|
|
1134
|
+
const outcome = picked === undefined
|
|
1135
|
+
? { outcome: 'cancelled' }
|
|
1136
|
+
: { outcome: 'selected', optionId: picked.optionId };
|
|
1278
1137
|
const tool = permissionToolName(toolCall);
|
|
1279
1138
|
// 不等待(0)不是「等了 0 秒」,是没等:两种说法的区别就在这里。
|
|
1280
1139
|
const waited = timeoutMs > 0
|
|
@@ -1291,19 +1150,29 @@ export class AgentBridge {
|
|
|
1291
1150
|
? `acp: permission request for "${tool}" ${waited}; refused`
|
|
1292
1151
|
: `acp: permission request for "${tool}" ${waited}; answered "${picked.name}"`,
|
|
1293
1152
|
});
|
|
1294
|
-
// The conversation's only trace of the expiry: the composer card is going
|
|
1295
|
-
// away and nothing else says why.
|
|
1296
1153
|
this.hub.broadcast({
|
|
1297
1154
|
type: 'permission/expired',
|
|
1298
1155
|
sessionId: session.sessionId,
|
|
1299
1156
|
toolCall,
|
|
1300
1157
|
timeoutMs,
|
|
1301
|
-
outcome
|
|
1302
|
-
? { outcome: 'selected', optionId: response.outcome.optionId }
|
|
1303
|
-
: { outcome: 'cancelled' },
|
|
1158
|
+
outcome,
|
|
1304
1159
|
...(picked === undefined ? {} : { optionName: picked.name }),
|
|
1305
1160
|
});
|
|
1306
|
-
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' } };
|
|
1307
1176
|
}
|
|
1308
1177
|
settlePermissions(session) {
|
|
1309
1178
|
this.permissions.settleFor(p => p.sessionId === session.sessionId);
|
|
@@ -1340,7 +1209,7 @@ export class AgentBridge {
|
|
|
1340
1209
|
* cannot reach into the agent's own process, so a second gate here would only
|
|
1341
1210
|
* be a fiction in front of a tool that can write without asking us at all.
|
|
1342
1211
|
* What the agent chooses to ask about goes to the human unchanged
|
|
1343
|
-
* (`
|
|
1212
|
+
* (`handlePermissionRequest`). See docs/acp-convergence.md §2.3.
|
|
1344
1213
|
*/
|
|
1345
1214
|
async handleFsWrite(params) {
|
|
1346
1215
|
const session = this.acpIndex.get(params.sessionId);
|
|
@@ -1457,12 +1326,11 @@ const APPROVAL_REASON_INPUT_KEYS = [
|
|
|
1457
1326
|
'description',
|
|
1458
1327
|
];
|
|
1459
1328
|
/**
|
|
1460
|
-
* One line saying WHAT the
|
|
1329
|
+
* One line saying WHAT the permission ask is about.
|
|
1461
1330
|
*
|
|
1462
|
-
* WHY: the
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
* (for execute/edit tools) a short scalar in `rawInput`. Only ONE declared
|
|
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
|
|
1466
1334
|
* string is used, never a serialized object or a whole file, and the line is
|
|
1467
1335
|
* capped so a pathological input cannot fill the card or the audit log.
|
|
1468
1336
|
*/
|
|
@@ -1495,21 +1363,6 @@ export function permissionAskReason(toolCall) {
|
|
|
1495
1363
|
const line = detail === undefined || detail === title ? title : `${title} · ${detail}`;
|
|
1496
1364
|
return line.length > APPROVAL_REASON_MAX ? `${line.slice(0, APPROVAL_REASON_MAX - 1)}…` : line;
|
|
1497
1365
|
}
|
|
1498
|
-
/**
|
|
1499
|
-
* Translate a native approval outcome into the ACP option the agent must see.
|
|
1500
|
-
* `'allowed-once'` is the chain's only grant; a rejection prefers the once-variant.
|
|
1501
|
-
*/
|
|
1502
|
-
function nativeOutcomeResponse(outcome, options) {
|
|
1503
|
-
if (outcome === 'allowed-once') {
|
|
1504
|
-
const allow = options.find(o => o.kind === 'allow_once') ?? options.find(o => o.kind === 'allow_always');
|
|
1505
|
-
return allow === undefined ? { outcome: { outcome: 'cancelled' } } : { outcome: { outcome: 'selected', optionId: allow.optionId } };
|
|
1506
|
-
}
|
|
1507
|
-
if (outcome === 'rejected') {
|
|
1508
|
-
const reject = options.find(o => o.kind === 'reject_once') ?? options.find(o => o.kind === 'reject_always');
|
|
1509
|
-
return reject === undefined ? { outcome: { outcome: 'cancelled' } } : { outcome: { outcome: 'selected', optionId: reject.optionId } };
|
|
1510
|
-
}
|
|
1511
|
-
return { outcome: { outcome: 'cancelled' } };
|
|
1512
|
-
}
|
|
1513
1366
|
function snapshot(session) {
|
|
1514
1367
|
return {
|
|
1515
1368
|
sessionId: session.sessionId,
|
|
@@ -2198,6 +2051,8 @@ export function releaseAllToolOutputWaiters() {
|
|
|
2198
2051
|
for (const waiter of [...set])
|
|
2199
2052
|
waiter(undefined);
|
|
2200
2053
|
}
|
|
2054
|
+
/** ACP `usage_update` is context occupancy, not request tokens; host usage is a 4-chars/token OUTPUT estimate (tool args excluded). */
|
|
2055
|
+
const OUTPUT_CHARS_PER_TOKEN = 4;
|
|
2201
2056
|
/**
|
|
2202
2057
|
* The state of ONE ACP prompt, shared across every driver step of the turn.
|
|
2203
2058
|
*
|
|
@@ -2206,12 +2061,11 @@ export function releaseAllToolOutputWaiters() {
|
|
|
2206
2061
|
* `transcript.since(cursor)` — events that arrive while no segment is open are
|
|
2207
2062
|
* recorded, never lost), the seen-call registry (a call id gets exactly one
|
|
2208
2063
|
* card per turn, wherever its restatements land), and the turn-level facts
|
|
2209
|
-
* (`sawVisibleBlock
|
|
2064
|
+
* (`sawVisibleBlock`) that outlive any single segment.
|
|
2210
2065
|
*/
|
|
2211
2066
|
class TurnStreamController {
|
|
2212
2067
|
manager;
|
|
2213
2068
|
hub;
|
|
2214
|
-
bridge;
|
|
2215
2069
|
session;
|
|
2216
2070
|
turnId;
|
|
2217
2071
|
endTurn;
|
|
@@ -2221,17 +2075,15 @@ class TurnStreamController {
|
|
|
2221
2075
|
disposed = false;
|
|
2222
2076
|
seenCalls = new Set();
|
|
2223
2077
|
announcedKinds = new Map();
|
|
2224
|
-
latestUsage = null;
|
|
2225
2078
|
/** Narrative lines (permission asks) that arrived while no segment was open. */
|
|
2226
2079
|
pendingProse = [];
|
|
2227
2080
|
sawVisibleBlock = false;
|
|
2228
2081
|
cursorSeq;
|
|
2229
|
-
constructor(manager, hub,
|
|
2082
|
+
constructor(manager, hub, session, turnId,
|
|
2230
2083
|
/** Releases the agent-side turn slot (the `beginTurn` settler; idempotent). */
|
|
2231
2084
|
endTurn, rollbackFirstTurn) {
|
|
2232
2085
|
this.manager = manager;
|
|
2233
2086
|
this.hub = hub;
|
|
2234
|
-
this.bridge = bridge;
|
|
2235
2087
|
this.session = session;
|
|
2236
2088
|
this.turnId = turnId;
|
|
2237
2089
|
this.endTurn = endTurn;
|
|
@@ -2268,6 +2120,7 @@ class TurnStreamController {
|
|
|
2268
2120
|
index: -1,
|
|
2269
2121
|
activeType: null,
|
|
2270
2122
|
activeText: '',
|
|
2123
|
+
outputChars: 0,
|
|
2271
2124
|
activeToolCall: undefined,
|
|
2272
2125
|
hasCard: false,
|
|
2273
2126
|
finished: false,
|
|
@@ -2394,8 +2247,10 @@ class TurnStreamController {
|
|
|
2394
2247
|
seg.activeType = null;
|
|
2395
2248
|
seg.activeToolCall = undefined;
|
|
2396
2249
|
}
|
|
2397
|
-
|
|
2398
|
-
|
|
2250
|
+
const outputTokens = Math.round(seg.outputChars / OUTPUT_CHARS_PER_TOKEN);
|
|
2251
|
+
// Zero input keeps the host's cache-hit pill hidden instead of faking 0%.
|
|
2252
|
+
if (outputTokens > 0)
|
|
2253
|
+
seg.queue.push({ type: 'usage', usage: { inputTokens: 0, outputTokens } });
|
|
2399
2254
|
seg.queue.push({ type: 'finish', reason });
|
|
2400
2255
|
seg.queue.close();
|
|
2401
2256
|
if (this.segment === seg)
|
|
@@ -2438,11 +2293,10 @@ class TurnStreamController {
|
|
|
2438
2293
|
const seg = this.segment;
|
|
2439
2294
|
if (seg === undefined || seg.finished) {
|
|
2440
2295
|
// Between segments: tool outputs still reach the shared registry (the
|
|
2441
|
-
// guard's virtual tool may be parked on this very record)
|
|
2442
|
-
//
|
|
2443
|
-
//
|
|
2444
|
-
//
|
|
2445
|
-
// executed on the host.
|
|
2296
|
+
// guard's virtual tool may be parked on this very record), but the
|
|
2297
|
+
// cursor does NOT advance — the next segment replays everything since
|
|
2298
|
+
// it, which re-records those idempotently and folds the prose the agent
|
|
2299
|
+
// streamed while its cards executed on the host.
|
|
2446
2300
|
this.recordWithoutSegment(update);
|
|
2447
2301
|
return;
|
|
2448
2302
|
}
|
|
@@ -2486,17 +2340,8 @@ class TurnStreamController {
|
|
|
2486
2340
|
/** Events that arrive between segments: recorded, replayed into the next one. */
|
|
2487
2341
|
recordWithoutSegment(update) {
|
|
2488
2342
|
const kind = String(update.sessionUpdate ?? '');
|
|
2489
|
-
if (kind === '
|
|
2490
|
-
this.noteUsage(update);
|
|
2491
|
-
}
|
|
2492
|
-
else if (kind === 'tool_call' || kind === 'tool_call_update') {
|
|
2343
|
+
if (kind === 'tool_call' || kind === 'tool_call_update')
|
|
2493
2344
|
this.noteToolRecord(update);
|
|
2494
|
-
}
|
|
2495
|
-
}
|
|
2496
|
-
noteUsage(update) {
|
|
2497
|
-
const used = typeof update.used === 'number' ? update.used : 0;
|
|
2498
|
-
const size = typeof update.size === 'number' ? update.size : 0;
|
|
2499
|
-
this.latestUsage = { inputTokens: size, outputTokens: used };
|
|
2500
2345
|
}
|
|
2501
2346
|
/** Record a tool call's output (idempotent) and wake any waiter. */
|
|
2502
2347
|
noteToolRecord(update) {
|
|
@@ -2522,6 +2367,7 @@ class TurnStreamController {
|
|
|
2522
2367
|
this.finishSegment(seg, { kind: 'stop' });
|
|
2523
2368
|
return false;
|
|
2524
2369
|
}
|
|
2370
|
+
seg.outputChars += text.length;
|
|
2525
2371
|
this.emitProse(seg, kind === 'agent_thought_chunk' ? 'reasoning' : 'text', text);
|
|
2526
2372
|
return true;
|
|
2527
2373
|
}
|
|
@@ -2544,10 +2390,6 @@ class TurnStreamController {
|
|
|
2544
2390
|
this.openToolCallBlock(seg, snapshot);
|
|
2545
2391
|
return true;
|
|
2546
2392
|
}
|
|
2547
|
-
else if (kind === 'usage_update') {
|
|
2548
|
-
this.noteUsage(update);
|
|
2549
|
-
return true;
|
|
2550
|
-
}
|
|
2551
2393
|
else if (!BOOKKEEPING_UPDATES.has(kind)) {
|
|
2552
2394
|
const text = describeUnhandledUpdate(kind, update);
|
|
2553
2395
|
if (this.announcedKinds.get(kind) === text)
|
|
@@ -2617,28 +2459,27 @@ class TurnStreamController {
|
|
|
2617
2459
|
});
|
|
2618
2460
|
}
|
|
2619
2461
|
permissionLine(event) {
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
// The line must
|
|
2624
|
-
//
|
|
2625
|
-
//
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
//
|
|
2462
|
+
// `toolName` is host-derived (`permissionToolName`) so agents that omit
|
|
2463
|
+
// `title` and declare it in `_meta` still name the operation here.
|
|
2464
|
+
const title = event.toolName;
|
|
2465
|
+
// The line must point at the place the answer entry actually is: a
|
|
2466
|
+
// chat-bound ask renders its card in the conversation; an `/acp`-RPC ask
|
|
2467
|
+
// has no conversation, so its card lives on the settings page.
|
|
2468
|
+
const lead = event.dshSessionId !== undefined
|
|
2469
|
+
? 'please answer the permission card above the composer'
|
|
2470
|
+
: 'please approve in Settings';
|
|
2630
2471
|
// The last clause says what happens if nobody answers, and it follows the
|
|
2631
2472
|
// MODE: refused, answered automatically, or (in `wait`) nothing at all.
|
|
2632
|
-
|
|
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') {
|
|
2473
|
+
if (event.timeoutMode === 'wait' || event.deadlineAt === undefined) {
|
|
2636
2474
|
return `\n> 🔐 [Permission Required: ${title}] - ${lead}\n`;
|
|
2637
2475
|
}
|
|
2638
|
-
const
|
|
2476
|
+
const remainingMs = Math.max(0, event.deadlineAt - Date.now());
|
|
2477
|
+
// Floor (+1 ms for the Date.now tick), min 1: rounding up would promise time already spent.
|
|
2478
|
+
const remainingS = Math.max(1, Math.floor((remainingMs + 1) / 1000));
|
|
2479
|
+
const otherwise = event.timeoutMode === 'auto'
|
|
2639
2480
|
? 'this request is answered automatically'
|
|
2640
2481
|
: 'this request is cancelled';
|
|
2641
|
-
return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(
|
|
2482
|
+
return `\n> 🔐 [Permission Required: ${title}] - ${lead} within ${String(remainingS)}s or ${otherwise}\n`;
|
|
2642
2483
|
}
|
|
2643
2484
|
/**
|
|
2644
2485
|
* The line for an ask the plugin settled itself when its deadline fired.
|
|
@@ -2649,9 +2490,7 @@ class TurnStreamController {
|
|
|
2649
2490
|
* answered with the option the agent's own kinds implied.
|
|
2650
2491
|
*/
|
|
2651
2492
|
expiredLine(event) {
|
|
2652
|
-
const title =
|
|
2653
|
-
? String(event.toolCall.title)
|
|
2654
|
-
: 'permission';
|
|
2493
|
+
const title = permissionToolName(event.toolCall);
|
|
2655
2494
|
const waited = event.timeoutMs > 0
|
|
2656
2495
|
? `no answer within ${String(Math.round(event.timeoutMs / 1000))}s`
|
|
2657
2496
|
: 'not waited for';
|
|
@@ -2826,27 +2665,13 @@ export class BridgeManager {
|
|
|
2826
2665
|
routeBindingRegistry().defaultModel = this.defaultAgent();
|
|
2827
2666
|
}
|
|
2828
2667
|
makeBridge(def, onConnectionLost, onCatalog, onCommands) {
|
|
2829
|
-
return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.
|
|
2668
|
+
return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.sessionWorkspaceResolver);
|
|
2830
2669
|
}
|
|
2831
2670
|
/**
|
|
2832
|
-
*
|
|
2833
|
-
*
|
|
2834
|
-
*
|
|
2835
|
-
*
|
|
2836
|
-
*/
|
|
2837
|
-
setApprovalRequester(requester) {
|
|
2838
|
-
this.approvalRequester = requester;
|
|
2839
|
-
}
|
|
2840
|
-
/** True when permission asks go to native approval instead of the settings panel. */
|
|
2841
|
-
approvalRouting() {
|
|
2842
|
-
return this.approvalRequester !== undefined;
|
|
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.
|
|
2671
|
+
* Every outstanding ask, across all agents, with the view the browser was
|
|
2672
|
+
* shown it — the source of the reconnect replay (see
|
|
2673
|
+
* `permissionReplayFrames`). Every ACP ask is a ledger entry, so this is the
|
|
2674
|
+
* whole live set.
|
|
2850
2675
|
*/
|
|
2851
2676
|
pendingPermissionViews() {
|
|
2852
2677
|
const views = [];
|
|
@@ -4834,11 +4659,6 @@ export class BridgeManager {
|
|
|
4834
4659
|
this.dshSessionMap.delete(key);
|
|
4835
4660
|
}
|
|
4836
4661
|
}
|
|
4837
|
-
/**
|
|
4838
|
-
* Latest native-approval requester. Held as a field (read through a thunk by
|
|
4839
|
-
* each bridge) so a requester wired after bridges were built still applies.
|
|
4840
|
-
*/
|
|
4841
|
-
approvalRequester;
|
|
4842
4662
|
sessionWorkspaceResolver;
|
|
4843
4663
|
acpModeResolver;
|
|
4844
4664
|
/** Cached `chatSessionAcpMode` answers, keyed by DSH chat session id. */
|
|
@@ -4973,7 +4793,7 @@ export class BridgeManager {
|
|
|
4973
4793
|
if (existing !== undefined && existing.released) {
|
|
4974
4794
|
const resumed = await this.bridge(agentId).resumeSession(existing);
|
|
4975
4795
|
// Rebind explicitly: a resume keeps the record's identity, but the chat
|
|
4976
|
-
// session id is what
|
|
4796
|
+
// session id is what the conversation's permission card addresses.
|
|
4977
4797
|
resumed.dshSessionId = dshSessionId;
|
|
4978
4798
|
// A resume without an explicit pick must keep serving the agent the
|
|
4979
4799
|
// conversation was ON (its own model string when one was recorded). The
|
|
@@ -5011,7 +4831,7 @@ export class BridgeManager {
|
|
|
5011
4831
|
// `ensureReady` for why `session/new`'s cwd alone is not enough.
|
|
5012
4832
|
workspaceRoot);
|
|
5013
4833
|
// Absent for `/acp`-RPC sessions and for adapter calls without a chat
|
|
5014
|
-
// session;
|
|
4834
|
+
// session; their permission cards are addressed by the ACP session only.
|
|
5015
4835
|
session.dshSessionId = dshSessionId;
|
|
5016
4836
|
this.dshSessionMap.set(key, session);
|
|
5017
4837
|
// Turn the displayed defaults into this conversation's pick, so the first
|
|
@@ -5291,7 +5111,7 @@ export class BridgeManager {
|
|
|
5291
5111
|
// loop then executes the card and proposes the continuation step, which
|
|
5292
5112
|
// resumes the SAME prompt through the branch above.
|
|
5293
5113
|
const endTurn = bridge.beginTurn(session);
|
|
5294
|
-
const controller = new TurnStreamController(this, this.hub,
|
|
5114
|
+
const controller = new TurnStreamController(this, this.hub, session, turnId, endTurn, victim => this.rollbackFirstTurn(victim));
|
|
5295
5115
|
if (session.dshSessionId !== undefined)
|
|
5296
5116
|
this.rememberTurnStream(session.dshSessionId, controller);
|
|
5297
5117
|
const segment = controller.startSegment();
|