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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Pending ACP permission asks for the staged DSH conversation, rendered as
|
|
4
|
+
* composer-width entries above the composer card (`conversation.input.dock`).
|
|
5
|
+
* The slot itself spans the conversation column; the dock mirrors the host's
|
|
6
|
+
* InputBar width formula so the cards line up with the input box.
|
|
7
|
+
*
|
|
8
|
+
* The host broadcasts every ask as `permission/request`, carrying the DSH chat
|
|
9
|
+
* session id when the ask belongs to a conversation. This dock is the primary
|
|
10
|
+
* answer surface: it renders exactly the asks whose `dshSessionId` is the
|
|
11
|
+
* conversation it was mounted for, and nothing otherwise. There is no
|
|
12
|
+
* ACP-mode gate — the frame set itself is the gate, so an ordinary
|
|
13
|
+
* conversation shows this card if and only if it actually has an ask.
|
|
14
|
+
*
|
|
15
|
+
* The ask's description and options are the agent's own: buttons appear in the
|
|
16
|
+
* agent's order with the agent's names (including "Always Allow"), and
|
|
17
|
+
* `kind` is not rendered. A separate cancel button answers with no option,
|
|
18
|
+
* which the host forwards as `cancelled`. No Enter/Escape handlers: the
|
|
19
|
+
* composer owns its keyboard, and these are ordinary focusable buttons.
|
|
20
|
+
*
|
|
21
|
+
* Multiple asks stack in ARRIVAL order, oldest first: the question ignored
|
|
22
|
+
* longest stays at the top. The list is capped in height by CSS and scrolls.
|
|
23
|
+
* Asks without a chat identity never appear here — they stay in the
|
|
24
|
+
* settings-panel ledger, the cross-session mirror.
|
|
25
|
+
*/
|
|
26
|
+
import { useEffect, useState } from 'react';
|
|
27
|
+
import { ensureAcpStyle, useUi } from "./acp-ui.js";
|
|
28
|
+
import { deadlineRemainingLabel, PERMISSION_DEADLINE_EXPIRED, permissionsForSession, } from "./store.js";
|
|
29
|
+
/** Tick interval for the countdown (seconds are all the label shows). */
|
|
30
|
+
const COUNTDOWN_TICK_MS = 1000;
|
|
31
|
+
export function PermissionDock({ acp, sessionId }) {
|
|
32
|
+
ensureAcpStyle();
|
|
33
|
+
const ui = useUi(acp);
|
|
34
|
+
const dshSessionId = String(sessionId);
|
|
35
|
+
const pending = permissionsForSession(ui.permissions, dshSessionId);
|
|
36
|
+
const counting = pending.some(ask => ask.timeoutMode !== 'wait' && ask.deadlineAt !== undefined);
|
|
37
|
+
const [now, setNow] = useState(() => Date.now());
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!counting)
|
|
40
|
+
return;
|
|
41
|
+
setNow(Date.now());
|
|
42
|
+
const timer = setInterval(() => { setNow(Date.now()); }, COUNTDOWN_TICK_MS);
|
|
43
|
+
return () => { clearInterval(timer); };
|
|
44
|
+
}, [counting]);
|
|
45
|
+
if (pending.length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
return (_jsx("div", { className: "ntr-permission-dock", role: "region", "aria-label": acp.label('待处理的权限请求', 'Pending permission requests'), children: pending.map(permission => (_jsx(PermissionAskCard, { acp: acp, permission: permission, now: now }, permission.requestId))) }));
|
|
48
|
+
}
|
|
49
|
+
function PermissionAskCard(props) {
|
|
50
|
+
const { acp, permission, now } = props;
|
|
51
|
+
const [busy, setBusy] = useState(false);
|
|
52
|
+
const [error, setError] = useState();
|
|
53
|
+
const remaining = deadlineRemainingLabel(permission.deadlineAt, now, permission.timeoutMode);
|
|
54
|
+
const expired = remaining === PERMISSION_DEADLINE_EXPIRED;
|
|
55
|
+
const respond = (optionId) => {
|
|
56
|
+
if (busy)
|
|
57
|
+
return;
|
|
58
|
+
setBusy(true);
|
|
59
|
+
setError(undefined);
|
|
60
|
+
void acp.respondPermission(permission.requestId, optionId)
|
|
61
|
+
// `respondPermission` reports to the store's notice too, but that notice
|
|
62
|
+
// lives on the settings page; this card pins the failure where the user
|
|
63
|
+
// is looking.
|
|
64
|
+
.catch((failure) => {
|
|
65
|
+
setError(failure instanceof Error ? failure.message : String(failure));
|
|
66
|
+
})
|
|
67
|
+
.finally(() => { setBusy(false); });
|
|
68
|
+
};
|
|
69
|
+
return (_jsxs("div", { className: "ntr-permission-card", children: [_jsxs("div", { className: "ntr-permission-head", children: [_jsx("span", { className: "ntr-permission-tool", children: permission.toolName }), permission.reason !== undefined && (_jsx("span", { className: "ntr-permission-reason", title: permission.reason, children: permission.reason })), remaining !== undefined && (_jsx("span", { className: `ntr-permission-deadline${expired ? ' is-expired' : ''}`, children: expired
|
|
70
|
+
? acp.label('已超时', 'expired')
|
|
71
|
+
: acp.label(`剩余 ${remaining}`, `${remaining} left`) }))] }), _jsxs("div", { className: "ntr-permission-actions", children: [permission.options.map(option => (_jsx("button", { type: "button", className: "ntr-permission-option", disabled: busy, title: option.name, onClick: () => { respond(option.optionId); }, children: option.name }, option.optionId))), _jsx("button", { type: "button", className: "ntr-permission-cancel", disabled: busy, title: acp.label('取消这次询问(不选择任何选项)', 'Cancel this ask without choosing an option'), onClick: () => { respond(); }, children: acp.label('取消', 'Cancel') })] }), error !== undefined && (_jsx("div", { className: "ntr-permission-error", role: "alert", children: error }))] }));
|
|
72
|
+
}
|
|
@@ -61,10 +61,10 @@ import { useEffect, useState } from 'react';
|
|
|
61
61
|
import { AcpChip, AcpMenu, AcpShatterParts, buildAcpModelId, closeAcpMenu, ensureAcpStyle, openAcpMenu, openAcpSettingsDialog, setAcpMenuError, splitAcpModelId, useAcpMenu, useUi, } from "./acp-ui.js";
|
|
62
62
|
import { playAgentLockAnim, useAgentLockPhase } from "./agent-lock-anim.js";
|
|
63
63
|
const STATE_COLORS = {
|
|
64
|
-
ready: '#22c55e',
|
|
65
|
-
starting: '#eab308',
|
|
66
|
-
error: '#ef4444',
|
|
67
|
-
stopped: '#9ca3af',
|
|
64
|
+
ready: 'var(--dsw-alias-state-success-primary, #22c55e)',
|
|
65
|
+
starting: 'var(--dsw-alias-state-warn-primary, #eab308)',
|
|
66
|
+
error: 'var(--dsw-alias-state-error-primary, #ef4444)',
|
|
67
|
+
stopped: 'var(--dsw-alias-state-idle-primary, #9ca3af)',
|
|
68
68
|
};
|
|
69
69
|
function agentStateText(acp, state) {
|
|
70
70
|
switch (state) {
|
package/lib/client/store.js
CHANGED
|
@@ -6,6 +6,39 @@
|
|
|
6
6
|
* Deliberately framework-free: React binds through `useSyncExternalStore`.
|
|
7
7
|
*/
|
|
8
8
|
import { DEFAULT_TAG_HALO } from "../protocol.js";
|
|
9
|
+
/** Returned by {@link deadlineRemainingLabel} once the deadline has passed. */
|
|
10
|
+
export const PERMISSION_DEADLINE_EXPIRED = 'expired';
|
|
11
|
+
/**
|
|
12
|
+
* The asks that belong to one DSH chat conversation. An ask without a
|
|
13
|
+
* `dshSessionId` belongs to no conversation (an `/acp` RPC-only session), so it
|
|
14
|
+
* is deliberately absent from every conversation's dock and stays in the
|
|
15
|
+
* settings-panel ledger.
|
|
16
|
+
*/
|
|
17
|
+
export function permissionsForSession(permissions, dshSessionId) {
|
|
18
|
+
return permissions.filter(permission => permission.dshSessionId === dshSessionId);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Compact countdown for one ask, or `undefined` when there is nothing to count
|
|
22
|
+
* down (`wait` mode, or a frame without a deadline). Pure so the formatting is
|
|
23
|
+
* testable without a clock: the dock passes `Date.now()` on its tick.
|
|
24
|
+
*
|
|
25
|
+
* Past the deadline the answer is {@link PERMISSION_DEADLINE_EXPIRED} rather
|
|
26
|
+
* than a negative number — the host has already armed the expiry, and a card
|
|
27
|
+
* counting backwards would claim a deadline that no longer exists.
|
|
28
|
+
*/
|
|
29
|
+
export function deadlineRemainingLabel(deadlineAt, now, mode) {
|
|
30
|
+
if (mode === 'wait' || deadlineAt === undefined)
|
|
31
|
+
return undefined;
|
|
32
|
+
const remainingMs = deadlineAt - now;
|
|
33
|
+
if (remainingMs <= 0)
|
|
34
|
+
return PERMISSION_DEADLINE_EXPIRED;
|
|
35
|
+
const seconds = Math.ceil(remainingMs / 1000);
|
|
36
|
+
if (seconds < 60)
|
|
37
|
+
return `${String(seconds)}s`;
|
|
38
|
+
const minutes = Math.floor(seconds / 60);
|
|
39
|
+
const rest = seconds % 60;
|
|
40
|
+
return rest === 0 ? `${String(minutes)}m` : `${String(minutes)}m ${String(rest)}s`;
|
|
41
|
+
}
|
|
9
42
|
const MAX_BLOCKS = 400;
|
|
10
43
|
/** How many observed ACP transcripts stay live at once (insertion-ordered eviction). */
|
|
11
44
|
const MAX_WATCHED = 6;
|
|
@@ -298,7 +331,6 @@ export class AcpStore {
|
|
|
298
331
|
seatCatalogs: {},
|
|
299
332
|
probingModels: {},
|
|
300
333
|
probeErrors: {},
|
|
301
|
-
approvalRouted: false,
|
|
302
334
|
chatSessions: [],
|
|
303
335
|
tagHalo: { ...DEFAULT_TAG_HALO },
|
|
304
336
|
approvalDeadlines: undefined,
|
|
@@ -501,7 +533,7 @@ export class AcpStore {
|
|
|
501
533
|
/** Snapshot refresh used on panel open and after a stream outage. */
|
|
502
534
|
async resync() {
|
|
503
535
|
try {
|
|
504
|
-
const [{ agents
|
|
536
|
+
const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult] = await Promise.all([
|
|
505
537
|
this.call('agents/list'),
|
|
506
538
|
this.call('sessions/list'),
|
|
507
539
|
// The plugin's OWN default ACP agent — a plugin-private id, not a DSH
|
|
@@ -526,7 +558,6 @@ export class AcpStore {
|
|
|
526
558
|
sessions,
|
|
527
559
|
defaultAgent: defaultAgentInfo?.agentId,
|
|
528
560
|
detectedAgents,
|
|
529
|
-
approvalRouted: approvalRouted === true,
|
|
530
561
|
preset: presetResult?.preset ?? undefined,
|
|
531
562
|
tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
|
|
532
563
|
approvalDeadlines: deadlinesResult.deadlines,
|
|
@@ -790,12 +821,23 @@ export class AcpStore {
|
|
|
790
821
|
async loadTranscript(sessionId) {
|
|
791
822
|
try {
|
|
792
823
|
const { session, entries } = await this.call('transcript/get', { sessionId });
|
|
824
|
+
// An answer BEHIND what this page already folded must not replace it.
|
|
825
|
+
//
|
|
826
|
+
// The host fills its transcript from the notification pipeline, which the
|
|
827
|
+
// SDK paces independently of the `session/prompt` response, so a read
|
|
828
|
+
// taken right after a turn can still be missing the agent's last chunk.
|
|
829
|
+
// `lastSeq` already refuses to go backwards; the blocks must not either,
|
|
830
|
+
// or switching away and back inside that window truncates a turn the user
|
|
831
|
+
// just watched finish. A resumed session replays as fresh updates, but
|
|
832
|
+
// only after `session/reset` zeroes the watermark — so this cannot swallow
|
|
833
|
+
// a legitimate replay.
|
|
834
|
+
const seen = this.lastSeq.get(sessionId) ?? 0;
|
|
835
|
+
if ((entries.at(-1)?.seq ?? 0) < seen)
|
|
836
|
+
return;
|
|
793
837
|
const blocks = [];
|
|
794
838
|
for (const entry of entries) {
|
|
795
|
-
const before = blocks.length;
|
|
796
839
|
foldUpdate(blocks, entry.update);
|
|
797
840
|
this.lastSeq.set(sessionId, Math.max(this.lastSeq.get(sessionId) ?? 0, entry.seq));
|
|
798
|
-
void before;
|
|
799
841
|
}
|
|
800
842
|
const sessions = this.state.sessions.some(s => s.sessionId === sessionId)
|
|
801
843
|
? this.state.sessions.map(s => (s.sessionId === sessionId ? session : s))
|
|
@@ -1233,12 +1275,21 @@ export class AcpStore {
|
|
|
1233
1275
|
this.flashError(error);
|
|
1234
1276
|
}
|
|
1235
1277
|
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Answer one ask (`optionId` absent = cancel it). A failure is reported
|
|
1280
|
+
* through the store's notice — the settings panel renders that — AND
|
|
1281
|
+
* rethrown, so the conversation dock can pin the message next to the card
|
|
1282
|
+
* that failed instead of leaving it on a settings page the user may not
|
|
1283
|
+
* have open. Callers that cannot show it (the settings panel) swallow it
|
|
1284
|
+
* after the notice.
|
|
1285
|
+
*/
|
|
1236
1286
|
async respondPermission(requestId, optionId) {
|
|
1237
1287
|
try {
|
|
1238
1288
|
await this.call('permission/respond', { requestId, ...(optionId === undefined ? {} : { optionId }) });
|
|
1239
1289
|
}
|
|
1240
1290
|
catch (error) {
|
|
1241
1291
|
this.flashError(error);
|
|
1292
|
+
throw error;
|
|
1242
1293
|
}
|
|
1243
1294
|
}
|
|
1244
1295
|
async stopAgent(agentId) {
|
|
@@ -1282,7 +1333,7 @@ export class AcpStore {
|
|
|
1282
1333
|
}
|
|
1283
1334
|
}
|
|
1284
1335
|
/**
|
|
1285
|
-
* Write
|
|
1336
|
+
* Write the approval deadline (settings card), or drop the persisted override
|
|
1286
1337
|
* with `reset` so the `cordis.patch.yml` baseline applies again.
|
|
1287
1338
|
*
|
|
1288
1339
|
* The host answer — the same route returns the deadlines now in force — is the
|
|
@@ -1290,8 +1341,17 @@ export class AcpStore {
|
|
|
1290
1341
|
* merged with a value this browser did not know about) self-corrects instead
|
|
1291
1342
|
* of drifting from what runs.
|
|
1292
1343
|
*/
|
|
1293
|
-
async setApprovalDeadline(
|
|
1294
|
-
await this.writeApprovalDeadlines({
|
|
1344
|
+
async setApprovalDeadline(ms) {
|
|
1345
|
+
await this.writeApprovalDeadlines({ permissionTimeoutMs: ms });
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Write the mode: what an expired deadline DOES (`abort` refuses, `auto`
|
|
1349
|
+
* answers with the option the agent's own kinds imply, `wait` never
|
|
1350
|
+
* expires). Same write path as the window, so the host answer stays
|
|
1351
|
+
* the authority.
|
|
1352
|
+
*/
|
|
1353
|
+
async setApprovalTimeoutMode(mode) {
|
|
1354
|
+
await this.writeApprovalDeadlines({ mode });
|
|
1295
1355
|
}
|
|
1296
1356
|
/** Drop the persisted override: the config file (or the built-in default) decides again. */
|
|
1297
1357
|
async resetApprovalDeadlines() {
|
|
@@ -1299,15 +1359,15 @@ export class AcpStore {
|
|
|
1299
1359
|
}
|
|
1300
1360
|
async writeApprovalDeadlines(payload) {
|
|
1301
1361
|
const previous = this.state.approvalDeadlines;
|
|
1302
|
-
// Optimistic only where it is safe: the effective numbers
|
|
1303
|
-
// override facts (`configured`) stay as the host last reported
|
|
1304
|
-
// failure restores the whole view.
|
|
1362
|
+
// Optimistic only where it is safe: the effective numbers and the mode move
|
|
1363
|
+
// now, and the override facts (`configured`) stay as the host last reported
|
|
1364
|
+
// them. A failure restores the whole view.
|
|
1305
1365
|
if (previous !== undefined) {
|
|
1306
1366
|
this.mutate({
|
|
1307
1367
|
approvalDeadlines: {
|
|
1308
1368
|
...previous,
|
|
1309
|
-
...(payload.
|
|
1310
|
-
...(payload.
|
|
1369
|
+
...(payload.permissionTimeoutMs === undefined ? {} : { permissionTimeoutMs: payload.permissionTimeoutMs }),
|
|
1370
|
+
...(payload.mode === undefined ? {} : { mode: payload.mode }),
|
|
1311
1371
|
},
|
|
1312
1372
|
});
|
|
1313
1373
|
}
|
|
@@ -1581,16 +1641,22 @@ export class AcpStore {
|
|
|
1581
1641
|
return this.loadAgentCatalog(event.agentId);
|
|
1582
1642
|
}
|
|
1583
1643
|
case 'permission/request': {
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1644
|
+
// Verbatim copy of the host's frame. The host decides what the ask is
|
|
1645
|
+
// called (`toolName` / `reason`) and which options exist; deriving a
|
|
1646
|
+
// title from `toolCall` here used to make the card say something the
|
|
1647
|
+
// host never said.
|
|
1648
|
+
const permission = {
|
|
1649
|
+
requestId: event.requestId,
|
|
1650
|
+
sessionId: event.sessionId,
|
|
1651
|
+
...(event.dshSessionId === undefined ? {} : { dshSessionId: event.dshSessionId }),
|
|
1652
|
+
toolName: event.toolName,
|
|
1653
|
+
...(event.reason === undefined ? {} : { reason: event.reason }),
|
|
1654
|
+
options: event.options,
|
|
1655
|
+
timeoutMode: event.timeoutMode,
|
|
1656
|
+
...(event.deadlineAt === undefined ? {} : { deadlineAt: event.deadlineAt }),
|
|
1657
|
+
};
|
|
1587
1658
|
this.mutate({
|
|
1588
|
-
permissions: [...this.state.permissions.filter(p => p.requestId !== event.requestId),
|
|
1589
|
-
requestId: event.requestId,
|
|
1590
|
-
sessionId: event.sessionId,
|
|
1591
|
-
title,
|
|
1592
|
-
options: event.options,
|
|
1593
|
-
}],
|
|
1659
|
+
permissions: [...this.state.permissions.filter(p => p.requestId !== event.requestId), permission],
|
|
1594
1660
|
});
|
|
1595
1661
|
return Promise.resolve();
|
|
1596
1662
|
}
|