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.
package/lib/client.js CHANGED
@@ -401,6 +401,7 @@ window.__ModuleLoader__.load({
401
401
  probingModels: {},
402
402
  probeErrors: {},
403
403
  chatSessions: [],
404
+ burstStats: {},
404
405
  tagHalo: { ...DEFAULT_TAG_HALO },
405
406
  approvalDeadlines: void 0,
406
407
  idleRelease: void 0,
@@ -1792,11 +1793,18 @@ window.__ModuleLoader__.load({
1792
1793
  const closedTranscripts = { ...this.state.chatTranscripts };
1793
1794
  delete closedTranscripts[event.sessionId];
1794
1795
  const chatSessions = this.state.chatSessions.filter((s) => s.sessionId !== event.sessionId);
1796
+ const hadBurst = this.state.burstStats[event.sessionId] !== void 0;
1797
+ let burstStats = this.state.burstStats;
1798
+ if (hadBurst) {
1799
+ burstStats = { ...burstStats };
1800
+ delete burstStats[event.sessionId];
1801
+ }
1795
1802
  this.mutate({
1796
1803
  sessions,
1797
1804
  activeSessionId: active,
1798
1805
  chatTranscripts: closedTranscripts,
1799
1806
  ...chatSessions.length !== this.state.chatSessions.length ? { chatSessions } : {},
1807
+ ...hadBurst ? { burstStats } : {},
1800
1808
  permissions: this.state.permissions.filter((p) => p.sessionId !== event.sessionId),
1801
1809
  ...event.reason === "agent-died" ? this.logPatch("error", this.locale("会话因 agent 进程退出而终止", "session ended: agent process died"), closingSession?.agentId) : {}
1802
1810
  });
@@ -1918,6 +1926,20 @@ window.__ModuleLoader__.load({
1918
1926
  });
1919
1927
  return Promise.resolve();
1920
1928
  }
1929
+ case "turn/metrics": {
1930
+ const prior = this.state.burstStats[event.sessionId];
1931
+ if (event.final === false && prior !== void 0 && prior.final === true && prior.turnId === event.turnId) return Promise.resolve();
1932
+ this.mutate({ burstStats: {
1933
+ ...this.state.burstStats,
1934
+ [event.sessionId]: {
1935
+ outputTokens: event.outputTokens,
1936
+ burstMs: event.burstMs,
1937
+ turnId: event.turnId,
1938
+ final: event.final
1939
+ }
1940
+ } });
1941
+ return Promise.resolve();
1942
+ }
1921
1943
  case "turn/start": {
1922
1944
  this.openTurns.add(event.sessionId);
1923
1945
  this.markWatchedRunning(event.sessionId, true);
@@ -8004,6 +8026,21 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
8004
8026
  * rather than risking a fork cut at a point the user is not looking at.
8005
8027
  */
8006
8028
  /**
8029
+ * Resolves the runtime's `sessions.fork` primitive, binding it to the service
8030
+ * instance so that internal `this` accesses (such as `this.list.getSnapshot()`)
8031
+ * resolve correctly when called through `face.fork(...)`.
8032
+ * Returns `undefined` if the service or `fork` method is absent or not a function.
8033
+ */
8034
+ function resolveHostFork(sessionsService) {
8035
+ try {
8036
+ const sessions = sessionsService;
8037
+ const fork = sessions?.fork;
8038
+ return typeof fork === "function" ? { fork: fork.bind(sessions) } : void 0;
8039
+ } catch {
8040
+ return;
8041
+ }
8042
+ }
8043
+ /**
8007
8044
  * The host's own branch-button icon, picked per host generation. The icon
8008
8045
  * exports MOVED between shipped hosts — desktop 0.2.1-rc.2 has
8009
8046
  * `IconBranchOutlineRegular` / `…Medium` but NOT `…16`, newer packages export
@@ -8132,7 +8169,7 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
8132
8169
  * `entriesOfSlot` and performing bounded retakes if displaced by later registrations.
8133
8170
  */
8134
8171
  function createSeatClaim(options) {
8135
- const { slots, slotKey, basePriority, occupant, inject } = options;
8172
+ const { slots, slotKey, basePriority, occupant, inject, entryId } = options;
8136
8173
  const label = options.label ?? slotKey;
8137
8174
  const onDiag = options.onDiag;
8138
8175
  const maxClaims = options.maxClaims ?? 4;
@@ -8162,10 +8199,11 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
8162
8199
  };
8163
8200
  try {
8164
8201
  const heads = slots.entriesOfSlot(slotKey) ?? [];
8202
+ const entry = entryId === void 0 ? heads[0] : heads.find((head) => head.options?.id === entryId);
8165
8203
  return {
8166
8204
  declared: isDeclared,
8167
8205
  verified: true,
8168
- ...heads[0] === void 0 ? {} : { entry: heads[0] }
8206
+ ...entry === void 0 ? {} : { entry }
8169
8207
  };
8170
8208
  } catch {
8171
8209
  return {
@@ -8186,6 +8224,9 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
8186
8224
  try {
8187
8225
  installed.push(slots.inject(slotKey, () => slots.register({
8188
8226
  name: slotKey,
8227
+ ...entryId === void 0 ? {} : { id: entryId },
8228
+ ...options.order === void 0 ? {} : { order: options.order },
8229
+ ...options.locale === void 0 ? {} : { locale: options.locale },
8189
8230
  priority: rank,
8190
8231
  ...inject ? { inject } : {}
8191
8232
  }, component)));
@@ -8297,6 +8338,159 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
8297
8338
  });
8298
8339
  }
8299
8340
  //#endregion
8341
+ //#region src/client/activity-pill.tsx
8342
+ /**
8343
+ * The composer dock's `x轮x步 · xx tok/s` pill on an ACP conversation.
8344
+ *
8345
+ * WHY SHADOW INSTEAD OF A SECOND PILL: the host's `ActivityPill` already states
8346
+ * the two things this conversation has — turn/step counts and a whole-session
8347
+ * decode speed — in the one place a speed belongs. Only its speed is wrong for
8348
+ * ACP: the host divides the tokens a step emitted by `firstTokenTime →
8349
+ * completedTime`, and that window contains every tool wait. Rendering a second
8350
+ * speed beside it would leave two numbers on screen and no way to tell which
8351
+ * is the honest one. So this module takes the host pill's own cell (`list` slot
8352
+ * `conversation.composer.dock`, id `activity`) at a lower priority, renders the
8353
+ * HOST's component, and hands it a patched `sessionStats` whose decode fields
8354
+ * are the plugin's burst aggregate. Style, counts, dialog, aria and locale stay
8355
+ * the host's; only the number changes.
8356
+ *
8357
+ * NON-ACP CONVERSATIONS PASS THROUGH. The shadow is installed only while the
8358
+ * gate confirms the staged conversation runs the ACP preset, but a list entry
8359
+ * is projected into EVERY session's composer. The wrapper therefore renders the
8360
+ * host component with the ORIGINAL projection whenever `acpMode` is not
8361
+ * affirmatively true, making the shadow visually indistinguishable from the
8362
+ * shipped entry there.
8363
+ *
8364
+ * FAIL-OPEN IS STRUCTURAL: if the host entry, its inject face, its locale or
8365
+ * its `performanceUsage` source is missing, no claim is created and the native
8366
+ * pill stays exactly as shipped. If the host component rejects the patched
8367
+ * projection, the slot's error boundary retires this entry and the native
8368
+ * `activity` entry wins again. No CSS hiding and no DOM patching are involved.
8369
+ */
8370
+ const DOCK_SLOT = "conversation.composer.dock";
8371
+ const ACTIVITY_ENTRY_ID = "activity";
8372
+ /**
8373
+ * The aggregate the dock should show: the sum of every ACP session backing this
8374
+ * chat (the host pill's counts are whole-chat too), with a live `turn/metrics`
8375
+ * entry preferred per session over its persisted row. `undefined` means "no
8376
+ * measurable burst yet" — the wrapper then hides the speed instead of showing
8377
+ * the host's tool-inclusive number.
8378
+ */
8379
+ function activityBurst(ui, dshSessionId) {
8380
+ if (ui.chatSessionsFor !== dshSessionId) return void 0;
8381
+ let outputTokens = 0;
8382
+ let burstMs = 0;
8383
+ for (const row of ui.chatSessions) {
8384
+ const measured = ui.burstStats[row.sessionId] ?? row.burst;
8385
+ if (measured === void 0) continue;
8386
+ outputTokens += measured.outputTokens;
8387
+ burstMs += measured.burstMs;
8388
+ }
8389
+ return outputTokens > 0 && burstMs > 0 ? {
8390
+ outputTokens,
8391
+ burstMs
8392
+ } : void 0;
8393
+ }
8394
+ /**
8395
+ * Replace ONLY the decode half of the host's session stats.
8396
+ *
8397
+ * `decodeTokens` and `decodeMs` are the two fields the host's `decodeSpeed`
8398
+ * reads (`decodeTokens / (decodeMs / 1000)`), so writing the plugin's aggregate
8399
+ * there makes the host's own formatter, dialog row and aria label display the
8400
+ * plugin's burst rate. Every other field — turns, steps, LLM/tool/TTFT — stays
8401
+ * host-native. With no measurable burst the two decode fields are zeroed: the
8402
+ * host then hides the speed (`decodeMs > 0` guard) while keeping the counts.
8403
+ */
8404
+ function patchedActivityStats(native, burst, acp) {
8405
+ if (!acp || native === void 0) return native;
8406
+ if (burst !== void 0 && burst.outputTokens > 0 && burst.burstMs > 0) return {
8407
+ ...native,
8408
+ decodeTokens: burst.outputTokens,
8409
+ decodeMs: burst.burstMs
8410
+ };
8411
+ return {
8412
+ ...native,
8413
+ decodeTokens: 0,
8414
+ decodeMs: 0
8415
+ };
8416
+ }
8417
+ function ActivityPillOccupant({ host: Host, ...props }) {
8418
+ const ui = useUi$2(props.acp);
8419
+ const dshSessionId = String(props.sessionId);
8420
+ const acpMode = ui.acpMode[dshSessionId] === true;
8421
+ const native = props.useProjection("sessionStats");
8422
+ const burst = acpMode ? activityBurst(ui, dshSessionId) : void 0;
8423
+ const stats = (0, react.useMemo)(() => patchedActivityStats(native, burst, acpMode), [
8424
+ native,
8425
+ burst?.outputTokens,
8426
+ burst?.burstMs,
8427
+ acpMode
8428
+ ]);
8429
+ const projection = (0, react.useMemo)(() => (key, selector) => {
8430
+ if (key !== "sessionStats") return void 0;
8431
+ return selector === void 0 ? stats : selector(stats);
8432
+ }, [stats]);
8433
+ return (0, react.createElement)(Host, {
8434
+ useChat: props.useChat,
8435
+ useProjection: acpMode ? projection : props.useProjection,
8436
+ usePerformanceUsage: props.usePerformanceUsage,
8437
+ t: props.t
8438
+ });
8439
+ }
8440
+ function findHostActivity(slots) {
8441
+ try {
8442
+ if (slots.spec?.("conversation.composer.dock")?.kind !== "list") return void 0;
8443
+ return (slots.entriesOfSlot?.("conversation.composer.dock") ?? []).find((entry) => entry.options?.id === ACTIVITY_ENTRY_ID);
8444
+ } catch {
8445
+ return;
8446
+ }
8447
+ }
8448
+ /**
8449
+ * Take the dock's `activity` cell for one ACP-mode spell.
8450
+ *
8451
+ * Returns `undefined` on any host shape this bundle cannot serve — an older
8452
+ * host without the `activity` entry (0.1.5 ships `stats`), a slot kind change,
8453
+ * a missing inject/locale, or no `performanceUsage` source. The native pill is
8454
+ * then left untouched; that is the honest failure mode, not a hidden control.
8455
+ */
8456
+ function createActivityPillClaim(options) {
8457
+ const host = findHostActivity(options.slots);
8458
+ if (host === void 0 || host.component === void 0 || typeof host.inject !== "function" || typeof host.locale !== "string" || host.locale === "") {
8459
+ options.onDiag?.("activity pill: host entry not available");
8460
+ return;
8461
+ }
8462
+ let performanceUsage;
8463
+ try {
8464
+ performanceUsage = host.inject()?.hooks?.performanceUsage;
8465
+ } catch {
8466
+ performanceUsage = void 0;
8467
+ }
8468
+ if (performanceUsage === void 0) {
8469
+ options.onDiag?.("activity pill: host performance hook missing");
8470
+ return;
8471
+ }
8472
+ const Host = host.component;
8473
+ return createSeatClaim({
8474
+ slots: options.slots,
8475
+ slotKey: DOCK_SLOT,
8476
+ entryId: ACTIVITY_ENTRY_ID,
8477
+ order: host.options?.order ?? 0,
8478
+ locale: host.locale,
8479
+ basePriority: -1,
8480
+ occupant: () => (props) => (0, react.createElement)(ActivityPillOccupant, {
8481
+ ...props,
8482
+ host: Host
8483
+ }),
8484
+ inject: (sessionId, actions) => ({
8485
+ ...options.inject(sessionId, actions),
8486
+ hooks: { performanceUsage }
8487
+ }),
8488
+ label: "activity pill",
8489
+ onDiag: options.onDiag,
8490
+ maxClaims: options.maxClaims ?? 1
8491
+ });
8492
+ }
8493
+ //#endregion
8300
8494
  //#region src/client/slash-isolation.ts
8301
8495
  /**
8302
8496
  * Cross-instance marker. `apply()` can run again over the same live sources (a
@@ -8771,8 +8965,7 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
8771
8965
  */
8772
8966
  const hostFork = () => {
8773
8967
  try {
8774
- const fork = service("sessions")?.fork;
8775
- return typeof fork === "function" ? { fork } : void 0;
8968
+ return resolveHostFork(service("sessions"));
8776
8969
  } catch {
8777
8970
  return;
8778
8971
  }
@@ -8935,6 +9128,22 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
8935
9128
  */
8936
9129
  let permissionClaim;
8937
9130
  /**
9131
+ * Claim on the dock's `activity` cell (`conversation.composer.dock`, id
9132
+ * `activity`), held for the same ACP-mode spell as the two seats. The
9133
+ * occupant renders the HOST's ActivityPill with the plugin's burst speed
9134
+ * patched into its `sessionStats`; it is a list cell, so the claim is
9135
+ * identified by id rather than by the slot's heads[0]. If the host shape is
9136
+ * not one this bundle knows, no claim is created and the native pill stays.
9137
+ */
9138
+ let activityClaim;
9139
+ let activityDiag = "idle";
9140
+ const noteActivityDiag = (line) => {
9141
+ if (line === activityDiag) return;
9142
+ activityDiag = line;
9143
+ if (!/failed|missing|taken|gave up|still held/.test(line)) return;
9144
+ console.warn(`[dsh-ntr] ${line}`);
9145
+ };
9146
+ /**
8938
9147
  * ACP-only auxiliary entries: the NTR view tab and the composer's context
8939
9148
  * ring. Unlike the two seats these are `list` slots with no visibility
8940
9149
  * predicate of their own, so "self-hide in the component" cannot remove a
@@ -9018,6 +9227,10 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
9018
9227
  permissionClaim.release();
9019
9228
  permissionClaim = void 0;
9020
9229
  }
9230
+ if (activityClaim !== void 0) {
9231
+ activityClaim.release();
9232
+ activityClaim = void 0;
9233
+ }
9021
9234
  };
9022
9235
  /**
9023
9236
  * Re-ask timer for the staged session, armed while the row is silent and a
@@ -9095,6 +9308,12 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
9095
9308
  }
9096
9309
  });
9097
9310
  permissionClaim.sync();
9311
+ activityClaim ??= createActivityPillClaim({
9312
+ slots: ctx.slots,
9313
+ inject: (sessionId) => chatFace(sessionId),
9314
+ onDiag: noteActivityDiag
9315
+ });
9316
+ activityClaim?.sync();
9098
9317
  holdAuxEntries();
9099
9318
  if (rowAgentPreset(staged.row) === void 0) armVerdictReask();
9100
9319
  };
@@ -9165,7 +9384,7 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
9165
9384
  attach();
9166
9385
  const stopStore = store.subscribe(runGate);
9167
9386
  const stopCell = ctx.on("slots/changed", (...args) => {
9168
- if (args[0] !== "conversation.input.model" && args[0] !== "conversation.input.permission") return;
9387
+ if (args[0] !== "conversation.input.model" && args[0] !== "conversation.input.permission" && args[0] !== "conversation.composer.dock") return;
9169
9388
  runGate();
9170
9389
  });
9171
9390
  const stopService = ctx.on("internal/service", attach);
@@ -9187,6 +9406,10 @@ html[data-ntr-acp] [data-turn-tail] button[aria-label="Remove rating"] {
9187
9406
  permissionClaim.release();
9188
9407
  permissionClaim = void 0;
9189
9408
  }
9409
+ if (activityClaim !== void 0) {
9410
+ activityClaim.release();
9411
+ activityClaim = void 0;
9412
+ }
9190
9413
  store.noteSeatDiag("gate disposed");
9191
9414
  store.notePermissionSeatDiag("gate disposed");
9192
9415
  };