dsh-ntr 0.3.2 → 0.3.3

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,156 @@
1
+ /**
2
+ * The composer dock's `x轮x步 · xx tok/s` pill on an ACP conversation.
3
+ *
4
+ * WHY SHADOW INSTEAD OF A SECOND PILL: the host's `ActivityPill` already states
5
+ * the two things this conversation has — turn/step counts and a whole-session
6
+ * decode speed — in the one place a speed belongs. Only its speed is wrong for
7
+ * ACP: the host divides the tokens a step emitted by `firstTokenTime →
8
+ * completedTime`, and that window contains every tool wait. Rendering a second
9
+ * speed beside it would leave two numbers on screen and no way to tell which
10
+ * is the honest one. So this module takes the host pill's own cell (`list` slot
11
+ * `conversation.composer.dock`, id `activity`) at a lower priority, renders the
12
+ * HOST's component, and hands it a patched `sessionStats` whose decode fields
13
+ * are the plugin's burst aggregate. Style, counts, dialog, aria and locale stay
14
+ * the host's; only the number changes.
15
+ *
16
+ * NON-ACP CONVERSATIONS PASS THROUGH. The shadow is installed only while the
17
+ * gate confirms the staged conversation runs the ACP preset, but a list entry
18
+ * is projected into EVERY session's composer. The wrapper therefore renders the
19
+ * host component with the ORIGINAL projection whenever `acpMode` is not
20
+ * affirmatively true, making the shadow visually indistinguishable from the
21
+ * shipped entry there.
22
+ *
23
+ * FAIL-OPEN IS STRUCTURAL: if the host entry, its inject face, its locale or
24
+ * its `performanceUsage` source is missing, no claim is created and the native
25
+ * pill stays exactly as shipped. If the host component rejects the patched
26
+ * projection, the slot's error boundary retires this entry and the native
27
+ * `activity` entry wins again. No CSS hiding and no DOM patching are involved.
28
+ */
29
+ import { createElement, useMemo } from 'react';
30
+ import { useUi } from "./acp-ui.js";
31
+ import { createSeatClaim } from "./model-seat.js";
32
+ export const DOCK_SLOT = 'conversation.composer.dock';
33
+ export const ACTIVITY_ENTRY_ID = 'activity';
34
+ /**
35
+ * The aggregate the dock should show: the sum of every ACP session backing this
36
+ * chat (the host pill's counts are whole-chat too), with a live `turn/metrics`
37
+ * entry preferred per session over its persisted row. `undefined` means "no
38
+ * measurable burst yet" — the wrapper then hides the speed instead of showing
39
+ * the host's tool-inclusive number.
40
+ */
41
+ export function activityBurst(ui, dshSessionId) {
42
+ if (ui.chatSessionsFor !== dshSessionId)
43
+ return undefined;
44
+ let outputTokens = 0;
45
+ let burstMs = 0;
46
+ for (const row of ui.chatSessions) {
47
+ const measured = ui.burstStats[row.sessionId] ?? row.burst;
48
+ if (measured === undefined)
49
+ continue;
50
+ outputTokens += measured.outputTokens;
51
+ burstMs += measured.burstMs;
52
+ }
53
+ return outputTokens > 0 && burstMs > 0 ? { outputTokens, burstMs } : undefined;
54
+ }
55
+ /**
56
+ * Replace ONLY the decode half of the host's session stats.
57
+ *
58
+ * `decodeTokens` and `decodeMs` are the two fields the host's `decodeSpeed`
59
+ * reads (`decodeTokens / (decodeMs / 1000)`), so writing the plugin's aggregate
60
+ * there makes the host's own formatter, dialog row and aria label display the
61
+ * plugin's burst rate. Every other field — turns, steps, LLM/tool/TTFT — stays
62
+ * host-native. With no measurable burst the two decode fields are zeroed: the
63
+ * host then hides the speed (`decodeMs > 0` guard) while keeping the counts.
64
+ */
65
+ export function patchedActivityStats(native, burst, acp) {
66
+ if (!acp || native === undefined)
67
+ return native;
68
+ if (burst !== undefined && burst.outputTokens > 0 && burst.burstMs > 0) {
69
+ return { ...native, decodeTokens: burst.outputTokens, decodeMs: burst.burstMs };
70
+ }
71
+ return { ...native, decodeTokens: 0, decodeMs: 0 };
72
+ }
73
+ function ActivityPillOccupant({ host: Host, ...props }) {
74
+ const ui = useUi(props.acp);
75
+ const dshSessionId = String(props.sessionId);
76
+ const acpMode = ui.acpMode[dshSessionId] === true;
77
+ const native = props.useProjection('sessionStats');
78
+ const burst = acpMode ? activityBurst(ui, dshSessionId) : undefined;
79
+ const stats = useMemo(() => patchedActivityStats(native, burst, acpMode), [native, burst?.outputTokens, burst?.burstMs, acpMode]);
80
+ // The current host pill reads exactly one projection. Returning `undefined`
81
+ // for another key keeps the wrapper from claiming support it has not been
82
+ // taught; a future host that asks for more will abdicate to the native entry.
83
+ const projection = useMemo(() => (key, selector) => {
84
+ if (key !== 'sessionStats')
85
+ return undefined;
86
+ return selector === undefined ? stats : selector(stats);
87
+ }, [stats]);
88
+ return createElement(Host, {
89
+ useChat: props.useChat,
90
+ useProjection: acpMode ? projection : props.useProjection,
91
+ usePerformanceUsage: props.usePerformanceUsage,
92
+ t: props.t,
93
+ });
94
+ }
95
+ function findHostActivity(slots) {
96
+ try {
97
+ if (slots.spec?.(DOCK_SLOT)?.kind !== 'list')
98
+ return undefined;
99
+ const heads = slots.entriesOfSlot?.(DOCK_SLOT) ?? [];
100
+ return heads.find(entry => entry.options?.id === ACTIVITY_ENTRY_ID);
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ /**
107
+ * Take the dock's `activity` cell for one ACP-mode spell.
108
+ *
109
+ * Returns `undefined` on any host shape this bundle cannot serve — an older
110
+ * host without the `activity` entry (0.1.5 ships `stats`), a slot kind change,
111
+ * a missing inject/locale, or no `performanceUsage` source. The native pill is
112
+ * then left untouched; that is the honest failure mode, not a hidden control.
113
+ */
114
+ export function createActivityPillClaim(options) {
115
+ const host = findHostActivity(options.slots);
116
+ if (host === undefined
117
+ || host.component === undefined
118
+ || typeof host.inject !== 'function'
119
+ || typeof host.locale !== 'string'
120
+ || host.locale === '') {
121
+ options.onDiag?.('activity pill: host entry not available');
122
+ return undefined;
123
+ }
124
+ let performanceUsage;
125
+ try {
126
+ const face = host.inject();
127
+ performanceUsage = face?.hooks?.performanceUsage;
128
+ }
129
+ catch {
130
+ performanceUsage = undefined;
131
+ }
132
+ if (performanceUsage === undefined) {
133
+ options.onDiag?.('activity pill: host performance hook missing');
134
+ return undefined;
135
+ }
136
+ const Host = host.component;
137
+ return createSeatClaim({
138
+ slots: options.slots,
139
+ slotKey: DOCK_SLOT,
140
+ entryId: ACTIVITY_ENTRY_ID,
141
+ order: host.options?.order ?? 0,
142
+ locale: host.locale,
143
+ basePriority: -1,
144
+ occupant: () => (props) => createElement(ActivityPillOccupant, {
145
+ ...props,
146
+ host: Host,
147
+ }),
148
+ inject: (sessionId, actions) => ({
149
+ ...options.inject(sessionId, actions),
150
+ hooks: { performanceUsage },
151
+ }),
152
+ label: 'activity pill',
153
+ onDiag: options.onDiag,
154
+ maxClaims: options.maxClaims ?? 1,
155
+ });
156
+ }
@@ -26,6 +26,22 @@ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives';
26
26
  import * as primitives from '@deepseek-ai/dsh-client-ui-primitives';
27
27
  import { useUi } from "./acp-ui.js";
28
28
  import { forkStateOf } from "./store.js";
29
+ /**
30
+ * Resolves the runtime's `sessions.fork` primitive, binding it to the service
31
+ * instance so that internal `this` accesses (such as `this.list.getSnapshot()`)
32
+ * resolve correctly when called through `face.fork(...)`.
33
+ * Returns `undefined` if the service or `fork` method is absent or not a function.
34
+ */
35
+ export function resolveHostFork(sessionsService) {
36
+ try {
37
+ const sessions = sessionsService;
38
+ const fork = sessions?.fork;
39
+ return typeof fork === 'function' ? { fork: fork.bind(sessions) } : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
29
45
  /**
30
46
  * The host's own branch-button icon, picked per host generation. The icon
31
47
  * exports MOVED between shipped hosts — desktop 0.2.1-rc.2 has
@@ -14,7 +14,8 @@ import { AcpConversationView } from "./acp-view.js";
14
14
  import { AcpSettingsDialog } from "./settings-dialog.js";
15
15
  import { PermissionDock } from "./permission-dock.js";
16
16
  import { ReleasedBanner } from "./released-banner.js";
17
- import { AcpBranchAction } from "./branch-action.js";
17
+ import { AcpBranchAction, resolveHostFork } from "./branch-action.js";
18
+ import { createActivityPillClaim, DOCK_SLOT } from "./activity-pill.js";
18
19
  import { createModelSeatClaim, createPermissionSeatClaim, MODEL_SEAT_KEY, PERMISSION_SEAT_KEY, } from "./model-seat.js";
19
20
  import { acpModeFromPresetEvent, ACP_PRESET_ID, rowAgentPreset, stagedIsAcpMode, stagedSessionOf, } from "./acp-ui.js";
20
21
  import { createSlashIsolation, slashAcpVerdict } from "./slash-isolation.js";
@@ -146,8 +147,7 @@ export function apply(ctx) {
146
147
  */
147
148
  const hostFork = () => {
148
149
  try {
149
- const fork = service('sessions')?.fork;
150
- return typeof fork === 'function' ? { fork } : undefined;
150
+ return resolveHostFork(service('sessions'));
151
151
  }
152
152
  catch {
153
153
  return undefined;
@@ -375,6 +375,28 @@ export function apply(ctx) {
375
375
  * available yet.
376
376
  */
377
377
  let permissionClaim;
378
+ /**
379
+ * Claim on the dock's `activity` cell (`conversation.composer.dock`, id
380
+ * `activity`), held for the same ACP-mode spell as the two seats. The
381
+ * occupant renders the HOST's ActivityPill with the plugin's burst speed
382
+ * patched into its `sessionStats`; it is a list cell, so the claim is
383
+ * identified by id rather than by the slot's heads[0]. If the host shape is
384
+ * not one this bundle knows, no claim is created and the native pill stays.
385
+ */
386
+ let activityClaim;
387
+ let activityDiag = 'idle';
388
+ const noteActivityDiag = (line) => {
389
+ if (line === activityDiag)
390
+ return;
391
+ activityDiag = line;
392
+ // Success and the ordinary older-host "no activity entry" are silent; a
393
+ // shape mismatch or a lost registration must leave a breadcrumb. The ACP
394
+ // view has no line for this surface, and a missing speed fix is otherwise
395
+ // invisible.
396
+ if (!/failed|missing|taken|gave up|still held/.test(line))
397
+ return;
398
+ console.warn(`[dsh-ntr] ${line}`);
399
+ };
378
400
  /**
379
401
  * ACP-only auxiliary entries: the NTR view tab and the composer's context
380
402
  * ring. Unlike the two seats these are `list` slots with no visibility
@@ -474,6 +496,10 @@ export function apply(ctx) {
474
496
  permissionClaim.release();
475
497
  permissionClaim = undefined;
476
498
  }
499
+ if (activityClaim !== undefined) {
500
+ activityClaim.release();
501
+ activityClaim = undefined;
502
+ }
477
503
  };
478
504
  /**
479
505
  * Re-ask timer for the staged session, armed while the row is silent and a
@@ -616,6 +642,16 @@ export function apply(ctx) {
616
642
  onDiag: (line) => { permissionSeatDiag = `staged ${staged.sessionId} · ${line}`; },
617
643
  });
618
644
  permissionClaim.sync();
645
+ // The dock's activity pill is a list CELL, not a whole slot: this claim
646
+ // shadows the shipped `activity` entry by id and renders it back with the
647
+ // plugin's burst speed. No host entry / inject / locale -> undefined and
648
+ // the native pill is untouched; the next evaluation retries.
649
+ activityClaim ??= createActivityPillClaim({
650
+ slots: ctx.slots,
651
+ inject: (sessionId) => chatFace(sessionId),
652
+ onDiag: noteActivityDiag,
653
+ });
654
+ activityClaim?.sync();
619
655
  // The tab and the occupancy ring have the same lifetime as the claims:
620
656
  // they exist only while this staged conversation is an ACP one, and a
621
657
  // stand-down disposes them (a standard conversation must not see an NTR
@@ -743,7 +779,7 @@ export function apply(ctx) {
743
779
  // retakes the cell only when it is actually holding something that is not
744
780
  // ours — so this is evidence-driven, not a poll.
745
781
  const stopCell = ctx.on('slots/changed', (...args) => {
746
- if (args[0] !== MODEL_SEAT_KEY && args[0] !== PERMISSION_SEAT_KEY)
782
+ if (args[0] !== MODEL_SEAT_KEY && args[0] !== PERMISSION_SEAT_KEY && args[0] !== DOCK_SLOT)
747
783
  return;
748
784
  runGate();
749
785
  });
@@ -771,6 +807,10 @@ export function apply(ctx) {
771
807
  permissionClaim.release();
772
808
  permissionClaim = undefined;
773
809
  }
810
+ if (activityClaim !== undefined) {
811
+ activityClaim.release();
812
+ activityClaim = undefined;
813
+ }
774
814
  store.noteSeatDiag('gate disposed');
775
815
  store.notePermissionSeatDiag('gate disposed');
776
816
  };
@@ -123,7 +123,7 @@ export const MAX_SEAT_CLAIMS = 4;
123
123
  * `entriesOfSlot` and performing bounded retakes if displaced by later registrations.
124
124
  */
125
125
  export function createSeatClaim(options) {
126
- const { slots, slotKey, basePriority, occupant, inject } = options;
126
+ const { slots, slotKey, basePriority, occupant, inject, entryId } = options;
127
127
  const label = options.label ?? slotKey;
128
128
  const onDiag = options.onDiag;
129
129
  const maxClaims = options.maxClaims ?? MAX_SEAT_CLAIMS;
@@ -156,7 +156,10 @@ export function createSeatClaim(options) {
156
156
  return { declared: isDeclared, verified: false };
157
157
  try {
158
158
  const heads = slots.entriesOfSlot(slotKey) ?? [];
159
- return { declared: isDeclared, verified: true, ...heads[0] === undefined ? {} : { entry: heads[0] } };
159
+ // A list slot projects one winner per id, so the cell this claim owns is
160
+ // the head WITH that id — not heads[0], which may be a sibling cell.
161
+ const entry = entryId === undefined ? heads[0] : heads.find(head => head.options?.id === entryId);
162
+ return { declared: isDeclared, verified: true, ...entry === undefined ? {} : { entry } };
160
163
  }
161
164
  catch {
162
165
  return { declared: isDeclared, verified: false };
@@ -176,6 +179,9 @@ export function createSeatClaim(options) {
176
179
  try {
177
180
  installed.push(slots.inject(slotKey, () => slots.register({
178
181
  name: slotKey,
182
+ ...(entryId === undefined ? {} : { id: entryId }),
183
+ ...(options.order === undefined ? {} : { order: options.order }),
184
+ ...(options.locale === undefined ? {} : { locale: options.locale }),
179
185
  priority: rank,
180
186
  ...(inject ? { inject } : {}),
181
187
  }, component)));
@@ -379,6 +379,7 @@ export class AcpStore {
379
379
  probingModels: {},
380
380
  probeErrors: {},
381
381
  chatSessions: [],
382
+ burstStats: {},
382
383
  tagHalo: { ...DEFAULT_TAG_HALO },
383
384
  approvalDeadlines: undefined,
384
385
  idleRelease: undefined,
@@ -1920,11 +1921,20 @@ export class AcpStore {
1920
1921
  // entry would keep reading as a live session (the agent tag's halo
1921
1922
  // breathes on `!released`) until some future turn re-reads the list.
1922
1923
  const chatSessions = this.state.chatSessions.filter(s => s.sessionId !== event.sessionId);
1924
+ // The burst aggregate belongs to this ACP session only; another
1925
+ // session's entry must survive. Only patch when there was an entry.
1926
+ const hadBurst = this.state.burstStats[event.sessionId] !== undefined;
1927
+ let burstStats = this.state.burstStats;
1928
+ if (hadBurst) {
1929
+ burstStats = { ...burstStats };
1930
+ delete burstStats[event.sessionId];
1931
+ }
1923
1932
  this.mutate({
1924
1933
  sessions,
1925
1934
  activeSessionId: active,
1926
1935
  chatTranscripts: closedTranscripts,
1927
1936
  ...(chatSessions.length !== this.state.chatSessions.length ? { chatSessions } : {}),
1937
+ ...(hadBurst ? { burstStats } : {}),
1928
1938
  permissions: this.state.permissions.filter(p => p.sessionId !== event.sessionId),
1929
1939
  ...(event.reason === 'agent-died'
1930
1940
  ? this.logPatch('error', this.locale('会话因 agent 进程退出而终止', 'session ended: agent process died'), closingSession?.agentId)
@@ -2101,6 +2111,28 @@ export class AcpStore {
2101
2111
  });
2102
2112
  return Promise.resolve();
2103
2113
  }
2114
+ case 'turn/metrics': {
2115
+ // Plugin-side burst aggregate for one ACP session. A late LIVE frame
2116
+ // must not regress a turn this page already saw finalized (frames can
2117
+ // land out of order across a stream reconnect); any other frame writes
2118
+ // the entry. Blocks / sessions stay untouched — this is a metric.
2119
+ const prior = this.state.burstStats[event.sessionId];
2120
+ if (event.final === false && prior !== undefined && prior.final === true && prior.turnId === event.turnId) {
2121
+ return Promise.resolve();
2122
+ }
2123
+ this.mutate({
2124
+ burstStats: {
2125
+ ...this.state.burstStats,
2126
+ [event.sessionId]: {
2127
+ outputTokens: event.outputTokens,
2128
+ burstMs: event.burstMs,
2129
+ turnId: event.turnId,
2130
+ final: event.final,
2131
+ },
2132
+ },
2133
+ });
2134
+ return Promise.resolve();
2135
+ }
2104
2136
  case 'turn/start': {
2105
2137
  this.openTurns.add(event.sessionId);
2106
2138
  this.markWatchedRunning(event.sessionId, true);