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.
@@ -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) {
@@ -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, approvalRouted }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult] = await Promise.all([
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 one approval deadline (settings card), or drop the persisted override
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(key, ms) {
1294
- await this.writeApprovalDeadlines({ [key]: ms });
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 move now, and the
1303
- // override facts (`configured`) stay as the host last reported them. A
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.nativeApprovalTimeoutMs === undefined ? {} : { nativeApprovalTimeoutMs: payload.nativeApprovalTimeoutMs }),
1310
- ...(payload.permissionFallbackTimeoutMs === undefined ? {} : { permissionFallbackTimeoutMs: payload.permissionFallbackTimeoutMs }),
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
- const title = typeof event.toolCall?.title === 'string'
1585
- ? String(event.toolCall.title)
1586
- : this.locale('工具调用', 'tool call');
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
  }