dsh-ntr 0.1.6 → 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.
@@ -59,11 +59,65 @@ export type Block =
59
59
  | { kind: 'event'; event: string; text: string; raw?: string; status?: string }
60
60
  | { kind: 'notice'; tone: 'info' | 'error'; text: string }
61
61
 
62
+ /**
63
+ * One permission ask waiting for a human. Every field is copied VERBATIM from
64
+ * the host's `permission/request` frame: the host owns what the ask is called
65
+ * (`toolName` / `reason`), which options the agent offered (order included),
66
+ * and whether the ask carries a deadline. The browser does not derive a title
67
+ * from `toolCall` and does not second-guess the agent's option labels.
68
+ */
62
69
  export interface PendingPermission {
63
70
  requestId: string
71
+ /** ACP session id. */
64
72
  sessionId: string
65
- title: string
73
+ /** DSH chat session id; absent for `/acp` RPC-only sessions (settings-only ask). */
74
+ dshSessionId?: string
75
+ toolName: string
76
+ reason?: string
66
77
  options: Array<{ optionId: string; name: string; kind: string }>
78
+ timeoutMode: PermissionTimeoutMode
79
+ /** Epoch ms; absent in `wait` mode. */
80
+ deadlineAt?: number
81
+ }
82
+
83
+ /** Returned by {@link deadlineRemainingLabel} once the deadline has passed. */
84
+ export const PERMISSION_DEADLINE_EXPIRED = 'expired'
85
+
86
+ /**
87
+ * The asks that belong to one DSH chat conversation. An ask without a
88
+ * `dshSessionId` belongs to no conversation (an `/acp` RPC-only session), so it
89
+ * is deliberately absent from every conversation's dock and stays in the
90
+ * settings-panel ledger.
91
+ */
92
+ export function permissionsForSession(
93
+ permissions: readonly PendingPermission[],
94
+ dshSessionId: string,
95
+ ): PendingPermission[] {
96
+ return permissions.filter(permission => permission.dshSessionId === dshSessionId)
97
+ }
98
+
99
+ /**
100
+ * Compact countdown for one ask, or `undefined` when there is nothing to count
101
+ * down (`wait` mode, or a frame without a deadline). Pure so the formatting is
102
+ * testable without a clock: the dock passes `Date.now()` on its tick.
103
+ *
104
+ * Past the deadline the answer is {@link PERMISSION_DEADLINE_EXPIRED} rather
105
+ * than a negative number — the host has already armed the expiry, and a card
106
+ * counting backwards would claim a deadline that no longer exists.
107
+ */
108
+ export function deadlineRemainingLabel(
109
+ deadlineAt: number | undefined,
110
+ now: number,
111
+ mode: PermissionTimeoutMode,
112
+ ): string | undefined {
113
+ if (mode === 'wait' || deadlineAt === undefined) return undefined
114
+ const remainingMs = deadlineAt - now
115
+ if (remainingMs <= 0) return PERMISSION_DEADLINE_EXPIRED
116
+ const seconds = Math.ceil(remainingMs / 1000)
117
+ if (seconds < 60) return `${String(seconds)}s`
118
+ const minutes = Math.floor(seconds / 60)
119
+ const rest = seconds % 60
120
+ return rest === 0 ? `${String(minutes)}m` : `${String(minutes)}m ${String(rest)}s`
67
121
  }
68
122
 
69
123
  /**
@@ -137,17 +191,6 @@ export interface UiState {
137
191
  * that never answers, …) reads as a dead button.
138
192
  */
139
193
  probeErrors: Record<string, string>
140
- /**
141
- * Host reports that it HAS the native approval chain (permission asks are
142
- * answered in the chat composer). This is a capability report, not a
143
- * per-ask promise: an individual ask still falls back to the settings-panel
144
- * ledger when the service is unavailable this time, or when the session has
145
- * no chat identity behind it (`AgentBridge.askNativeApproval`). The panel
146
- * therefore answers ANY pending ask, whatever this flag says — see
147
- * `permissionLedgerCard` in panel.tsx. False on an older host or when the
148
- * approval service is missing, in which case the ledger is the only path.
149
- */
150
- approvalRouted: boolean
151
194
  /**
152
195
  * ACP agent sessions backing the chat session the composer control sits in.
153
196
  * Only meaningful together with {@link chatSessionsFor}: the control reads it
@@ -539,7 +582,6 @@ export class AcpStore {
539
582
  seatCatalogs: {},
540
583
  probingModels: {},
541
584
  probeErrors: {},
542
- approvalRouted: false,
543
585
  chatSessions: [],
544
586
  tagHalo: { ...DEFAULT_TAG_HALO },
545
587
  approvalDeadlines: undefined,
@@ -741,8 +783,8 @@ export class AcpStore {
741
783
  /** Snapshot refresh used on panel open and after a stream outage. */
742
784
  async resync(): Promise<void> {
743
785
  try {
744
- const [{ agents, approvalRouted }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult] = await Promise.all([
745
- this.call<{ agents: AgentView[]; approvalRouted?: boolean }>('agents/list'),
786
+ const [{ agents }, { sessions }, defaultAgentInfo, detectResult, presetResult, tagHaloResult, deadlinesResult] = await Promise.all([
787
+ this.call<{ agents: AgentView[] }>('agents/list'),
746
788
  this.call<{ sessions: SessionView[] }>('sessions/list'),
747
789
  // The plugin's OWN default ACP agent — a plugin-private id, not a DSH
748
790
  // model route (see `defaultAgent` on the host half).
@@ -766,7 +808,6 @@ export class AcpStore {
766
808
  sessions,
767
809
  defaultAgent: defaultAgentInfo?.agentId,
768
810
  detectedAgents,
769
- approvalRouted: approvalRouted === true,
770
811
  preset: presetResult?.preset ?? undefined,
771
812
  tagHalo: tagHaloResult?.halo ?? { ...DEFAULT_TAG_HALO },
772
813
  approvalDeadlines: deadlinesResult.deadlines,
@@ -1483,11 +1524,20 @@ export class AcpStore {
1483
1524
  }
1484
1525
  }
1485
1526
 
1527
+ /**
1528
+ * Answer one ask (`optionId` absent = cancel it). A failure is reported
1529
+ * through the store's notice — the settings panel renders that — AND
1530
+ * rethrown, so the conversation dock can pin the message next to the card
1531
+ * that failed instead of leaving it on a settings page the user may not
1532
+ * have open. Callers that cannot show it (the settings panel) swallow it
1533
+ * after the notice.
1534
+ */
1486
1535
  async respondPermission(requestId: string, optionId?: string): Promise<void> {
1487
1536
  try {
1488
1537
  await this.call('permission/respond', { requestId, ...(optionId === undefined ? {} : { optionId }) })
1489
1538
  } catch (error) {
1490
1539
  this.flashError(error)
1540
+ throw error
1491
1541
  }
1492
1542
  }
1493
1543
 
@@ -1851,16 +1901,22 @@ export class AcpStore {
1851
1901
  return this.loadAgentCatalog(event.agentId)
1852
1902
  }
1853
1903
  case 'permission/request': {
1854
- const title = typeof (event.toolCall as { title?: unknown } | undefined)?.title === 'string'
1855
- ? String((event.toolCall as { title: string }).title)
1856
- : this.locale('工具调用', 'tool call')
1904
+ // Verbatim copy of the host's frame. The host decides what the ask is
1905
+ // called (`toolName` / `reason`) and which options exist; deriving a
1906
+ // title from `toolCall` here used to make the card say something the
1907
+ // host never said.
1908
+ const permission: PendingPermission = {
1909
+ requestId: event.requestId,
1910
+ sessionId: event.sessionId,
1911
+ ...(event.dshSessionId === undefined ? {} : { dshSessionId: event.dshSessionId }),
1912
+ toolName: event.toolName,
1913
+ ...(event.reason === undefined ? {} : { reason: event.reason }),
1914
+ options: event.options,
1915
+ timeoutMode: event.timeoutMode,
1916
+ ...(event.deadlineAt === undefined ? {} : { deadlineAt: event.deadlineAt }),
1917
+ }
1857
1918
  this.mutate({
1858
- permissions: [...this.state.permissions.filter(p => p.requestId !== event.requestId), {
1859
- requestId: event.requestId,
1860
- sessionId: event.sessionId,
1861
- title,
1862
- options: event.options,
1863
- }],
1919
+ permissions: [...this.state.permissions.filter(p => p.requestId !== event.requestId), permission],
1864
1920
  })
1865
1921
  return Promise.resolve()
1866
1922
  }
package/src/impl.ts CHANGED
@@ -1579,6 +1579,22 @@ export class EventHub {
1579
1579
 
1580
1580
  // ---- Permission ledger ----
1581
1581
 
1582
+ /** Everything a `permission/request` frame carries besides the ids the ledger owns. */
1583
+ export interface PendingPermissionFields {
1584
+ /** DSH chat session id; absent for `/acp` RPC-only sessions. */
1585
+ dshSessionId?: string
1586
+ toolCall: unknown
1587
+ /** Host-derived human name (`permissionToolName`). */
1588
+ toolName: string
1589
+ /** One-line detail from the agent's declared input, when it has any. */
1590
+ reason?: string
1591
+ options: PermissionOptionView[]
1592
+ /** What an unanswered ask does when its deadline fires. */
1593
+ timeoutMode: PermissionTimeoutMode
1594
+ /** Epoch ms of the deadline; absent in `wait` mode (no deadline). */
1595
+ deadlineAt?: number
1596
+ }
1597
+
1582
1598
  export interface PendingPermission {
1583
1599
  requestId: string
1584
1600
  sessionId: string
@@ -1588,15 +1604,13 @@ export interface PendingPermission {
1588
1604
  * client receives (see `permissionReplayFrames`). Absent on entries created
1589
1605
  * without a view — those still answer, they just cannot be re-shown.
1590
1606
  */
1591
- view?: { toolCall: unknown; options: PermissionOptionView[] }
1607
+ view?: PendingPermissionFields
1592
1608
  }
1593
1609
 
1594
1610
  /** One outstanding ask as a reconnecting client must be re-shown it. */
1595
- export interface PendingPermissionView {
1611
+ export interface PendingPermissionView extends PendingPermissionFields {
1596
1612
  requestId: string
1597
1613
  sessionId: string
1598
- toolCall: unknown
1599
- options: PermissionOptionView[]
1600
1614
  }
1601
1615
 
1602
1616
  export class PermissionLedger {
@@ -1648,14 +1662,37 @@ export class PermissionLedger {
1648
1662
  views.push({
1649
1663
  requestId: entry.requestId,
1650
1664
  sessionId: entry.sessionId,
1651
- toolCall: entry.view.toolCall,
1652
- options: entry.view.options,
1665
+ ...entry.view,
1653
1666
  })
1654
1667
  }
1655
1668
  return views
1656
1669
  }
1657
1670
  }
1658
1671
 
1672
+ /**
1673
+ * The `permission/request` frame for one ask.
1674
+ *
1675
+ * ONE builder for both the live broadcast and the reconnect replay, so the two
1676
+ * can never drift: a client that reconnects must receive exactly the frame the
1677
+ * live one carried (its conversation card filters on `dshSessionId` and shows
1678
+ * `toolName` / `deadlineAt`, so a replay missing them would silently drop the
1679
+ * ask).
1680
+ */
1681
+ export function permissionRequestFrame(view: PendingPermissionView): AcpEvent {
1682
+ return {
1683
+ type: 'permission/request',
1684
+ requestId: view.requestId,
1685
+ sessionId: view.sessionId,
1686
+ ...(view.dshSessionId === undefined ? {} : { dshSessionId: view.dshSessionId }),
1687
+ toolCall: view.toolCall,
1688
+ toolName: view.toolName,
1689
+ ...(view.reason === undefined ? {} : { reason: view.reason }),
1690
+ options: view.options,
1691
+ timeoutMode: view.timeoutMode,
1692
+ ...(view.deadlineAt === undefined ? {} : { deadlineAt: view.deadlineAt }),
1693
+ }
1694
+ }
1695
+
1659
1696
  /**
1660
1697
  * Frames that re-establish a freshly connected client's view of the pending
1661
1698
  * permission asks: every outstanding ask re-sent as its original
@@ -1663,10 +1700,10 @@ export class PermissionLedger {
1663
1700
  *
1664
1701
  * Why a replay at all: a `permission/request` is broadcast ONCE, when the ask is
1665
1702
  * created. A client that connects later — page reload, second tab, stream
1666
- * outage — never receives it, and for a ledger ask (the `/acp` RPC path, or a
1667
- * native route that degraded) that card is the ONLY answer entry, so the ask is
1668
- * invisible to exactly the client that needs it. OmniTerm closes the same hole
1669
- * with `PermissionManager::pending_events()` + a `PermissionsSynced` marker on
1703
+ * outage — never receives it, and that card (conversation or settings ledger)
1704
+ * is the ONLY answer entry for the ask, so the ask is invisible to exactly the
1705
+ * client that needs it. OmniTerm closes the same hole with
1706
+ * `PermissionManager::pending_events()` + a `PermissionsSynced` marker on
1670
1707
  * every WS connect.
1671
1708
  *
1672
1709
  * Why the terminator is mandatory: the replay is a snapshot, so an ask resolved
@@ -1675,13 +1712,7 @@ export class PermissionLedger {
1675
1712
  * `respond` would answer `unknown_request`.
1676
1713
  */
1677
1714
  export function permissionReplayFrames(pending: readonly PendingPermissionView[]): AcpEvent[] {
1678
- const requests: AcpEvent[] = pending.map(p => ({
1679
- type: 'permission/request' as const,
1680
- requestId: p.requestId,
1681
- sessionId: p.sessionId,
1682
- toolCall: p.toolCall,
1683
- options: p.options,
1684
- }))
1715
+ const requests: AcpEvent[] = pending.map(permissionRequestFrame)
1685
1716
  return [...requests, { type: 'permission/synced', requestIds: pending.map(p => p.requestId) }]
1686
1717
  }
1687
1718
 
@@ -1761,12 +1792,6 @@ export interface AcpManager {
1761
1792
  * cache (newly published models appear, withdrawn ones drop out).
1762
1793
  */
1763
1794
  cachedAgentModels?(agentId: string): AgentProbeModelsResult | undefined
1764
- /**
1765
- * True when ACP permission asks are routed to the host's native approval
1766
- * service (answered in the chat composer) instead of the settings-panel
1767
- * ledger. Read-only capability report for `agents/list`.
1768
- */
1769
- approvalRouting?(): boolean
1770
1795
  /**
1771
1796
  * ACP agent sessions backing one DSH chat session (looked up by chat session
1772
1797
  * id), so the composer can offer the agent's OWN mode list. Empty when no
@@ -1881,12 +1906,7 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
1881
1906
  try {
1882
1907
  switch (endpoint) {
1883
1908
  case 'agents/list':
1884
- // `approvalRouted` is a capability report, not a command: the browser
1885
- // uses it to decide whether the settings panel still owns approvals.
1886
- return ok({
1887
- agents: manager.agentViews(),
1888
- approvalRouted: manager.approvalRouting?.() ?? false,
1889
- })
1909
+ return ok({ agents: manager.agentViews() })
1890
1910
  case 'agents/detect':
1891
1911
  return ok({ agents: await (manager.detectAgents?.() ?? Promise.resolve([])) })
1892
1912
  case 'agent/save': {
package/src/index.ts CHANGED
@@ -11,32 +11,15 @@ import type { ConnectionRpcHandler, HostConnectionHandle } from '@deepseek-ai/ds
11
11
  import { ACP_PRESET_ID, AcpLlmAdapter, BridgeManager } from './bridge.ts'
12
12
  import { EventHub, dispatchRpc, permissionReplayFrames, sessionAgentPreset, sessionAgentPresetFromLog, validateConfig } from './impl.ts'
13
13
  import type { AcpEvent, ApprovalDeadlineKey } from './protocol.ts'
14
- import type { ApprovalOutcome } from './bridge.ts'
15
14
  import type { RosterPresetRow, SessionLogLike } from './impl.ts'
16
15
 
17
16
  export const name = 'dsh-ntr'
18
- // `approval` / `agents` are deliberately NOT listed here: they are optional
19
- // collaborators probed structurally below. A hard dependency would make the
20
- // whole plugin fail to load on a deployment without them (see the LLM service
21
- // probe, which follows the same shape).
17
+ // Deliberately minimal: every other host service is probed structurally
18
+ // (`ctx.get` + a late `ctx.inject`) so a deployment without one still loads
19
+ // this plugin. ACP permission asks are answered through this plugin's own
20
+ // ledger, so the host approval service is not a collaborator at all.
22
21
  export const inject = ['webServer', 'connection']
23
22
 
24
- /** Structural view of the host approval service (`@deepseek-ai/dsh-user-approval`). */
25
- interface ApprovalServiceLike {
26
- request(req: { agent: unknown; toolName: string; reason?: string; callId?: string; signal?: AbortSignal }): Promise<ApprovalOutcome>
27
- /**
28
- * Present on the host service, but NEVER read by this plugin: the only call
29
- * made here is `request` (see `wireApproval`). A deployment-wide default
30
- * policy is the host's own business.
31
- */
32
- config?: { policy?: string }
33
- }
34
-
35
- /** Structural view of the host agent registry (`ctx.agents`), keyed by chat session id. */
36
- interface AgentsServiceLike {
37
- get(sessionId: string): unknown | undefined
38
- }
39
-
40
23
  /** Structural view of the host sandbox policy service (`@deepseek-ai/dsh-sandbox-policy`). */
41
24
  interface SandboxPolicyLike {
42
25
  resolve(request: { session?: unknown }): { mode?: unknown; workspaceRoot?: unknown }
@@ -166,40 +149,6 @@ export function apply(ctx: Context, config?: unknown): void {
166
149
  ctx.inject(['agentPresets'], () => { wirePresetRoster() })
167
150
  }
168
151
 
169
- /**
170
- * Native approval routing: answer ACP `session/request_permission` through
171
- * the host approval chain so the question takes over the chat composer
172
- * (`ApprovalPanel`) instead of the settings panel.
173
- *
174
- * `approval.request` needs the live agent object (its session carries the
175
- * audit pair, and the ask must land inside an open turn) and the chat
176
- * session id our adapter already knows. Any missing piece — service absent,
177
- * unknown session, a rejecting service — degrades to `'unavailable'`, which
178
- * sends the ask back to the ledger/panel path rather than losing it.
179
- */
180
- const wireApproval = (): void => {
181
- const approval = (typeof ctx.get === 'function' ? ctx.get('approval') : (ctx as unknown as { approval?: ApprovalServiceLike }).approval) as ApprovalServiceLike | undefined
182
- const agents = (typeof ctx.get === 'function' ? ctx.get('agents') : (ctx as unknown as { agents?: AgentsServiceLike }).agents) as AgentsServiceLike | undefined
183
- if (approval === undefined || agents === undefined) return
184
- manager.setApprovalRequester(async ({ dshSessionId, toolName, reason, callId, signal }) => {
185
- if (dshSessionId === undefined) return 'unavailable'
186
- const agent = agents.get(dshSessionId)
187
- if (agent === undefined) return 'unavailable'
188
- try {
189
- return await approval.request({
190
- agent,
191
- toolName,
192
- ...(reason === undefined ? {} : { reason }),
193
- ...(callId === undefined ? {} : { callId }),
194
- ...(signal === undefined ? {} : { signal }),
195
- })
196
- } catch {
197
- // Never propagate: a failed ask must fall back, not break the turn.
198
- return 'unavailable'
199
- }
200
- })
201
- }
202
-
203
152
  /**
204
153
  * Workspace routing: the sidebar's selected workspace IS the chat session's
205
154
  * workspace, so the agent's session and its process both run there (see
@@ -288,11 +237,10 @@ export function apply(ctx: Context, config?: unknown): void {
288
237
  }
289
238
  }
290
239
 
291
- wireApproval()
292
240
  wireWorkspace()
293
241
  if (typeof ctx.inject === 'function') {
294
- // Late arrival for the workspace collaborators; kept separate from the
295
- // approval probe so a deployment missing one service still wires the other.
242
+ // Late arrival for the workspace collaborators: a deployment missing one
243
+ // service must not fail this plugin's activation.
296
244
  ctx.inject(['sandboxPolicy', 'sessions'], () => {
297
245
  wireWorkspace()
298
246
  })
@@ -305,13 +253,6 @@ export function apply(ctx: Context, config?: unknown): void {
305
253
  wireAcpMode()
306
254
  })
307
255
  }
308
- if (typeof ctx.inject === 'function') {
309
- // Late arrival: both services may still be loading when this plugin
310
- // applies (profile order is not ours to control).
311
- ctx.inject(['approval', 'agents'], () => {
312
- wireApproval()
313
- })
314
- }
315
256
 
316
257
  /** Registration handle: callable disposer, plus `replace` to re-announce routes. */
317
258
  interface LlmAdapterHandle {
package/src/protocol.ts CHANGED
@@ -332,18 +332,44 @@ export type AcpEvent =
332
332
  * same as unset — no `agentId@choice` variants.
333
333
  */
334
334
  | { type: 'agent/exposure'; agentId: string; exposedModels: string[]; autoDiscoverModels: boolean }
335
- | { type: 'permission/request'; requestId: string; sessionId: string; toolCall: unknown; options: PermissionOptionView[] }
335
+ /**
336
+ * One outstanding ask, broadcast ONCE when it is created and replayed as-is
337
+ * to every freshly connected client (see `permission/synced`).
338
+ *
339
+ * Every ACP permission ask carries everything the conversation card needs:
340
+ * who owns it (`dshSessionId`), what it is about (`toolCall` + the derived
341
+ * `toolName` / `reason`), the options to render verbatim, and the deadline in
342
+ * force. The host never answers on its own outside an expired `abort` /
343
+ * `auto` deadline, so this frame (and its replay) is the answer entry.
344
+ */
345
+ | {
346
+ type: 'permission/request'
347
+ requestId: string
348
+ /** ACP session id the ask belongs to. */
349
+ sessionId: string
350
+ /** DSH chat session id; absent for `/acp` RPC-only sessions. */
351
+ dshSessionId?: string
352
+ /** Raw ACP `ToolCallUpdate` the agent sent — never summarized away. */
353
+ toolCall: unknown
354
+ /** Host-derived human name (`permissionToolName`, `_meta` fallback included). */
355
+ toolName: string
356
+ /** One-line detail from the agent's declared input, when it has any. */
357
+ reason?: string
358
+ options: PermissionOptionView[]
359
+ /** What an unanswered ask does when its deadline fires. */
360
+ timeoutMode: PermissionTimeoutMode
361
+ /** Epoch ms of the deadline; absent in `wait` mode (no deadline). */
362
+ deadlineAt?: number
363
+ }
336
364
  | { type: 'permission/resolved'; requestId: string; sessionId: string; outcome: PermissionOutcome }
337
365
  /**
338
366
  * An ask whose deadline fired and was settled by the plugin itself — refused
339
367
  * (`abort`), or answered with the option the agent's own kinds imply
340
368
  * (`auto`).
341
369
  *
342
- * A native ask (the composer takeover) never became a ledger entry, so this
343
- * frame is the ONLY trace of the expiry in the conversation: without it the
344
- * user would watch the composer card vanish with nothing said about it. The
345
- * ledger path does not need it — its card sits on the settings panel and the
346
- * chat line already pointed there.
370
+ * This is the conversation's record of the expiry: the card it was answering
371
+ * is gone, so without this frame the user would see it vanish with nothing
372
+ * said about it.
347
373
  */
348
374
  | {
349
375
  type: 'permission/expired'
@@ -429,12 +455,6 @@ export type RpcResult<T> = {
429
455
 
430
456
  export interface AgentsListResult {
431
457
  agents: AgentView[]
432
- /**
433
- * True when permission asks are routed to the host approval chain and the
434
- * browser must stop rendering the settings-panel permission card. False (or
435
- * absent on an older host) keeps the degraded ledger path visible.
436
- */
437
- approvalRouted?: boolean
438
458
  }
439
459
  export type SessionPromptResult = { accepted: true; turnId: string }
440
460
  export type TranscriptResult = { session: SessionView; entries: Array<{ seq: number; update: SessionUpdate }> }