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.
@@ -84,16 +84,49 @@ export type Block = {
84
84
  tone: 'info' | 'error';
85
85
  text: string;
86
86
  };
87
+ /**
88
+ * One permission ask waiting for a human. Every field is copied VERBATIM from
89
+ * the host's `permission/request` frame: the host owns what the ask is called
90
+ * (`toolName` / `reason`), which options the agent offered (order included),
91
+ * and whether the ask carries a deadline. The browser does not derive a title
92
+ * from `toolCall` and does not second-guess the agent's option labels.
93
+ */
87
94
  export interface PendingPermission {
88
95
  requestId: string;
96
+ /** ACP session id. */
89
97
  sessionId: string;
90
- title: string;
98
+ /** DSH chat session id; absent for `/acp` RPC-only sessions (settings-only ask). */
99
+ dshSessionId?: string;
100
+ toolName: string;
101
+ reason?: string;
91
102
  options: Array<{
92
103
  optionId: string;
93
104
  name: string;
94
105
  kind: string;
95
106
  }>;
107
+ timeoutMode: PermissionTimeoutMode;
108
+ /** Epoch ms; absent in `wait` mode. */
109
+ deadlineAt?: number;
96
110
  }
111
+ /** Returned by {@link deadlineRemainingLabel} once the deadline has passed. */
112
+ export declare const PERMISSION_DEADLINE_EXPIRED = "expired";
113
+ /**
114
+ * The asks that belong to one DSH chat conversation. An ask without a
115
+ * `dshSessionId` belongs to no conversation (an `/acp` RPC-only session), so it
116
+ * is deliberately absent from every conversation's dock and stays in the
117
+ * settings-panel ledger.
118
+ */
119
+ export declare function permissionsForSession(permissions: readonly PendingPermission[], dshSessionId: string): PendingPermission[];
120
+ /**
121
+ * Compact countdown for one ask, or `undefined` when there is nothing to count
122
+ * down (`wait` mode, or a frame without a deadline). Pure so the formatting is
123
+ * testable without a clock: the dock passes `Date.now()` on its tick.
124
+ *
125
+ * Past the deadline the answer is {@link PERMISSION_DEADLINE_EXPIRED} rather
126
+ * than a negative number — the host has already armed the expiry, and a card
127
+ * counting backwards would claim a deadline that no longer exists.
128
+ */
129
+ export declare function deadlineRemainingLabel(deadlineAt: number | undefined, now: number, mode: PermissionTimeoutMode): string | undefined;
97
130
  /**
98
131
  * One observed ACP transcript, keyed by ACP session id. This is the ACP view
99
132
  * tab's data source: the native chat plane can only render text/reasoning
@@ -164,17 +197,6 @@ export interface UiState {
164
197
  * that never answers, …) reads as a dead button.
165
198
  */
166
199
  probeErrors: Record<string, string>;
167
- /**
168
- * Host reports that it HAS the native approval chain (permission asks are
169
- * answered in the chat composer). This is a capability report, not a
170
- * per-ask promise: an individual ask still falls back to the settings-panel
171
- * ledger when the service is unavailable this time, or when the session has
172
- * no chat identity behind it (`AgentBridge.askNativeApproval`). The panel
173
- * therefore answers ANY pending ask, whatever this flag says — see
174
- * `permissionLedgerCard` in panel.tsx. False on an older host or when the
175
- * approval service is missing, in which case the ledger is the only path.
176
- */
177
- approvalRouted: boolean;
178
200
  /**
179
201
  * ACP agent sessions backing the chat session the composer control sits in.
180
202
  * Only meaningful together with {@link chatSessionsFor}: the control reads it
@@ -556,6 +578,14 @@ export declare class AcpStore {
556
578
  /** Mirror the running flag onto a transcript the ACP view is showing. */
557
579
  private markWatchedRunning;
558
580
  setConfigOption(configId: string, value: string): Promise<void>;
581
+ /**
582
+ * Answer one ask (`optionId` absent = cancel it). A failure is reported
583
+ * through the store's notice — the settings panel renders that — AND
584
+ * rethrown, so the conversation dock can pin the message next to the card
585
+ * that failed instead of leaving it on a settings page the user may not
586
+ * have open. Callers that cannot show it (the settings panel) swallow it
587
+ * after the notice.
588
+ */
559
589
  respondPermission(requestId: string, optionId?: string): Promise<void>;
560
590
  stopAgent(agentId: string): Promise<void>;
561
591
  /**
@@ -561,6 +561,21 @@ export declare class EventHub {
561
561
  subscribe(sink: (event: AcpEvent) => void): () => void;
562
562
  broadcast(event: AcpEvent): void;
563
563
  }
564
+ /** Everything a `permission/request` frame carries besides the ids the ledger owns. */
565
+ export interface PendingPermissionFields {
566
+ /** DSH chat session id; absent for `/acp` RPC-only sessions. */
567
+ dshSessionId?: string;
568
+ toolCall: unknown;
569
+ /** Host-derived human name (`permissionToolName`). */
570
+ toolName: string;
571
+ /** One-line detail from the agent's declared input, when it has any. */
572
+ reason?: string;
573
+ options: PermissionOptionView[];
574
+ /** What an unanswered ask does when its deadline fires. */
575
+ timeoutMode: PermissionTimeoutMode;
576
+ /** Epoch ms of the deadline; absent in `wait` mode (no deadline). */
577
+ deadlineAt?: number;
578
+ }
564
579
  export interface PendingPermission {
565
580
  requestId: string;
566
581
  sessionId: string;
@@ -570,17 +585,12 @@ export interface PendingPermission {
570
585
  * client receives (see `permissionReplayFrames`). Absent on entries created
571
586
  * without a view — those still answer, they just cannot be re-shown.
572
587
  */
573
- view?: {
574
- toolCall: unknown;
575
- options: PermissionOptionView[];
576
- };
588
+ view?: PendingPermissionFields;
577
589
  }
578
590
  /** One outstanding ask as a reconnecting client must be re-shown it. */
579
- export interface PendingPermissionView {
591
+ export interface PendingPermissionView extends PendingPermissionFields {
580
592
  requestId: string;
581
593
  sessionId: string;
582
- toolCall: unknown;
583
- options: PermissionOptionView[];
584
594
  }
585
595
  export declare class PermissionLedger {
586
596
  private readonly pending;
@@ -603,6 +613,16 @@ export declare class PermissionLedger {
603
613
  */
604
614
  pendingViews(): PendingPermissionView[];
605
615
  }
616
+ /**
617
+ * The `permission/request` frame for one ask.
618
+ *
619
+ * ONE builder for both the live broadcast and the reconnect replay, so the two
620
+ * can never drift: a client that reconnects must receive exactly the frame the
621
+ * live one carried (its conversation card filters on `dshSessionId` and shows
622
+ * `toolName` / `deadlineAt`, so a replay missing them would silently drop the
623
+ * ask).
624
+ */
625
+ export declare function permissionRequestFrame(view: PendingPermissionView): AcpEvent;
606
626
  /**
607
627
  * Frames that re-establish a freshly connected client's view of the pending
608
628
  * permission asks: every outstanding ask re-sent as its original
@@ -610,10 +630,10 @@ export declare class PermissionLedger {
610
630
  *
611
631
  * Why a replay at all: a `permission/request` is broadcast ONCE, when the ask is
612
632
  * created. A client that connects later — page reload, second tab, stream
613
- * outage — never receives it, and for a ledger ask (the `/acp` RPC path, or a
614
- * native route that degraded) that card is the ONLY answer entry, so the ask is
615
- * invisible to exactly the client that needs it. OmniTerm closes the same hole
616
- * with `PermissionManager::pending_events()` + a `PermissionsSynced` marker on
633
+ * outage — never receives it, and that card (conversation or settings ledger)
634
+ * is the ONLY answer entry for the ask, so the ask is invisible to exactly the
635
+ * client that needs it. OmniTerm closes the same hole with
636
+ * `PermissionManager::pending_events()` + a `PermissionsSynced` marker on
617
637
  * every WS connect.
618
638
  *
619
639
  * Why the terminator is mandatory: the replay is a snapshot, so an ask resolved
@@ -706,12 +726,6 @@ export interface AcpManager {
706
726
  * cache (newly published models appear, withdrawn ones drop out).
707
727
  */
708
728
  cachedAgentModels?(agentId: string): AgentProbeModelsResult | undefined;
709
- /**
710
- * True when ACP permission asks are routed to the host's native approval
711
- * service (answered in the chat composer) instead of the settings-panel
712
- * ledger. Read-only capability report for `agents/list`.
713
- */
714
- approvalRouting?(): boolean;
715
729
  /**
716
730
  * ACP agent sessions backing one DSH chat session (looked up by chat session
717
731
  * id), so the composer can offer the agent's OWN mode list. Empty when no
@@ -349,12 +349,35 @@ export type AcpEvent = {
349
349
  agentId: string;
350
350
  exposedModels: string[];
351
351
  autoDiscoverModels: boolean;
352
- } | {
352
+ }
353
+ /**
354
+ * One outstanding ask, broadcast ONCE when it is created and replayed as-is
355
+ * to every freshly connected client (see `permission/synced`).
356
+ *
357
+ * Every ACP permission ask carries everything the conversation card needs:
358
+ * who owns it (`dshSessionId`), what it is about (`toolCall` + the derived
359
+ * `toolName` / `reason`), the options to render verbatim, and the deadline in
360
+ * force. The host never answers on its own outside an expired `abort` /
361
+ * `auto` deadline, so this frame (and its replay) is the answer entry.
362
+ */
363
+ | {
353
364
  type: 'permission/request';
354
365
  requestId: string;
366
+ /** ACP session id the ask belongs to. */
355
367
  sessionId: string;
368
+ /** DSH chat session id; absent for `/acp` RPC-only sessions. */
369
+ dshSessionId?: string;
370
+ /** Raw ACP `ToolCallUpdate` the agent sent — never summarized away. */
356
371
  toolCall: unknown;
372
+ /** Host-derived human name (`permissionToolName`, `_meta` fallback included). */
373
+ toolName: string;
374
+ /** One-line detail from the agent's declared input, when it has any. */
375
+ reason?: string;
357
376
  options: PermissionOptionView[];
377
+ /** What an unanswered ask does when its deadline fires. */
378
+ timeoutMode: PermissionTimeoutMode;
379
+ /** Epoch ms of the deadline; absent in `wait` mode (no deadline). */
380
+ deadlineAt?: number;
358
381
  } | {
359
382
  type: 'permission/resolved';
360
383
  requestId: string;
@@ -366,11 +389,9 @@ export type AcpEvent = {
366
389
  * (`abort`), or answered with the option the agent's own kinds imply
367
390
  * (`auto`).
368
391
  *
369
- * A native ask (the composer takeover) never became a ledger entry, so this
370
- * frame is the ONLY trace of the expiry in the conversation: without it the
371
- * user would watch the composer card vanish with nothing said about it. The
372
- * ledger path does not need it — its card sits on the settings panel and the
373
- * chat line already pointed there.
392
+ * This is the conversation's record of the expiry: the card it was answering
393
+ * is gone, so without this frame the user would see it vanish with nothing
394
+ * said about it.
374
395
  */
375
396
  | {
376
397
  type: 'permission/expired';
@@ -495,12 +516,6 @@ export type RpcResult<T> = {
495
516
  };
496
517
  export interface AgentsListResult {
497
518
  agents: AgentView[];
498
- /**
499
- * True when permission asks are routed to the host approval chain and the
500
- * browser must stop rendering the settings-panel permission card. False (or
501
- * absent on an older host) keeps the degraded ledger path visible.
502
- */
503
- approvalRouted?: boolean;
504
519
  }
505
520
  export type SessionPromptResult = {
506
521
  accepted: true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ntr",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent and stream its session.",
5
5
  "repository": "https://github.com/GDWhisper/dsh-ntr.git",
6
6
  "type": "module",
@@ -48,9 +48,13 @@
48
48
  "@deepseek-ai/dsh-client-ui-sidebar": "0.1.5-rc.1",
49
49
  "@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.1",
50
50
  "@deepseek-ai/dsh-host-webserver": "0.1.5-rc.1",
51
+ "@testing-library/react": "^16.3.3",
51
52
  "@types/node": "^22.19.0",
52
53
  "@types/react": "^19.2.0",
54
+ "@types/react-dom": "^19.3.0",
55
+ "jsdom": "^27.4.0",
53
56
  "react": "^19.2.0",
57
+ "react-dom": "^19.3.1",
54
58
  "tsdown": "^0.23.0",
55
59
  "typescript": "^5.9.0",
56
60
  "vitest": "^3.2.0"