dsh-token-total 1.1.5 → 1.1.6

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/README.md CHANGED
@@ -88,7 +88,9 @@ dsh plugin --profile <profile> add "file:D:\work\dsh-token-total"
88
88
  - **事件来源**:`sessionPersistence` 的会话日志。兼容 `assistant/message` 的顶层 `usage`,也读取消息及失败 `assistant/attempt` 的原始流式用量;同一次请求以最后一份用量为准,修正值按差额更新。插件订阅 `session/event` 实时增量累加,同时一次性回扫已有历史会话;`llm/retry-started` 会清空当前 `(turn, step)` 槽位,因此失败尝试和后续重试分别计入,不重复累加同一次请求的多份报告。
89
89
  - **本地持久化**:聚合结果写入 storage-domain 的 `token_total_history` 域(`$DSH_HOME/storages/` 下),纯本地、无外部依赖。回扫游标按会话记录已消费的事件序号,重启后从断点继续。
90
90
  - **上下文与花费估算**:两张卡都从该会话自身的日志重放「下一次请求会带上什么」——被 `compaction/prune` / `compaction/summary` 遮蔽的事件剔除、压缩摘要按注入记忆回放、工具目录取最近一次 `request/header`——再按字符类估算 token(ASCII 约 4 字符 / token,CJK 约 1 token / 字符),所以分桶上的数字都是近似值。左卡的真实占用取最近一次回答上报的 `totalTokens − outputTokens`;仅当两者相差超过 15%(说明上游有压缩或裁剪,日志里的历史比模型实际收到的多)时,才多出一行说明,并把「已用上下文」改用供应商实测值。右卡的分桶是「每次请求重放内容之和」,圆环中心的总量与缓存命中率直接取自 `usage` 记录,保持精确。
91
- - **单会话统计**:不走聚合存储(那里没有会话维度),而是按需重放该会话及全部子孙会话的自有日志;已加载会话优先使用实时事件快照,避免持久化缓冲造成漏算。结果缓存 4 秒,任意会话的新事件都会使缓存失效,包含此前尚未发现的子会话。前端从首次读取起每 4 秒刷新,显示时使用原生实时主会话用量、主会话压缩调用用量与子树用量之和,胶囊与弹窗共用同一口径;切换会话时立即清除旧会话读数。读取失败不会把部分子树当作成功结果覆盖已有读数。
91
+ - **单会话统计**:不走聚合存储(那里没有会话维度),而是按需重放该会话及全部子孙会话的自有日志;已加载会话优先使用实时事件快照,避免持久化缓冲造成漏算。日志结果返回后以主会话与全部子树的自有用量为准,压缩调用已包含其中,胶囊与弹窗共用同一口径;尚未获得日志结果时回退到原生主会话投影。
92
+ - **会话统计缓存**:完整结果使用最多 64 项的 LRU 缓存,60 秒到期且命中不续期。新事件只失效发生变化的会话及相关祖先,支持尚未出现在持久化目录中的子/孙会话;父链信息不足时保守失效,避免漏算。没有事件订阅接口的旧宿主仍每 4 秒重新校验。同一会话树、同一数据源的普通并发请求共用一次计算;显式取消信号保持独立。失败、取消或不完整的计算均不缓存,后续请求可以重试缺失的日志。
93
+ - **会话统计刷新**:前台首次读取,并在每次请求完成后等待 4 秒再轮询;会话签名变化时即时刷新,慢请求期间的变化合并为一次追刷。页面隐藏时暂停后续轮询,恢复可见时立即刷新;切换或卸载会取消浏览器请求,并丢弃迟到的旧响应。相同结果不重复写入 React 状态。根日志或目录读取失败保留上次读数;部分子树不可读时显示可读用量与不完整标记,不把漏读当作完整统计。浏览器取消不等于取消宿主端已经开始的统计计算。
92
94
  - **缓存命中率** = 缓存命中 token ÷ 输入 token 总量(命中 + 未命中)。
93
95
  - **失败数量**:以宿主记录的 `assistant/attempt` 事件为准。宿主只有在流以错误结束、或调用在提交任何内容之前被取消时才写 `assistant/attempt`,其余调用一律写 `assistant/message`,两者恰好划分全部调用,所以该事件就是「一次失败的 provider 调用」的精确边界(`interrupted: true` 但已有内容的 `assistant/message` 不计为失败)。失败数是请求数的子集,不额外计入请求;失败调用若在流中断前已上报用量,token 仍按用量计入、不会丢失。该计数晚于日 / 小时两份游标引入,因此老版本升级后会额外做一次只写失败数的增量回扫(`failuresReady` 标记完成,不重复累加 token)。
94
96
 
package/lib/client.js CHANGED
@@ -29423,8 +29423,8 @@ window.__ModuleLoader__.load({
29423
29423
  * every subagent session delegated from it. The native `tokenUsage` projection
29424
29424
  * covers only the main agent, so this is what makes a delegating session's
29425
29425
  * cost visible. */
29426
- async function fetchSessionUsage(sessionId) {
29427
- return post("session", { sessionId });
29426
+ async function fetchSessionUsage(sessionId, signal) {
29427
+ return post("session", { sessionId }, signal);
29428
29428
  }
29429
29429
  /** Log-owned requests and live route states for the per-session Usage view. */
29430
29430
  async function fetchSessionDetail(sessionId, signal) {
@@ -31550,8 +31550,9 @@ window.__ModuleLoader__.load({
31550
31550
  //#region src/client/use-session-usage.ts
31551
31551
  /** The composer row's window onto the whole session tree. The native usage
31552
31552
  * projection only moves for main-agent calls, so poll the displayed session
31553
- * even before its first child appears. Requests are serialized and late answers
31554
- * from a previous session are discarded. Failures keep the last good reading. */
31553
+ * even before its first child appears. Hidden pages pause; slow responses do
31554
+ * not queue timer retries. Disposed requests are aborted and late answers are
31555
+ * discarded. Failures keep the last good reading. */
31555
31556
  const SESSION_POLL_MS = 4e3;
31556
31557
  function useSessionUsage(sessionId, signature) {
31557
31558
  const [reading, setReading] = (0, react.useState)(null);
@@ -31559,34 +31560,61 @@ window.__ModuleLoader__.load({
31559
31560
  (0, react.useEffect)(() => {
31560
31561
  if (typeof sessionId !== "string" || sessionId === "") return;
31561
31562
  let active = true;
31562
- let pending = false;
31563
31563
  let again = false;
31564
+ let lastResult;
31565
+ let timer;
31566
+ let controller;
31567
+ const visible = () => typeof document === "undefined" || document.visibilityState !== "hidden";
31568
+ const clearTimer = () => {
31569
+ if (timer !== void 0) clearTimeout(timer);
31570
+ timer = void 0;
31571
+ };
31564
31572
  const request = () => {
31565
- if (!active) return;
31566
- if (pending) {
31573
+ if (!active || !visible()) return;
31574
+ if (controller !== void 0) {
31567
31575
  again = true;
31568
31576
  return;
31569
31577
  }
31570
- pending = true;
31571
- fetchSessionUsage(sessionId).then((tree) => {
31572
- if (active) setReading({
31573
- sessionId,
31574
- tree
31575
- });
31578
+ clearTimer();
31579
+ const current = new AbortController();
31580
+ controller = current;
31581
+ fetchSessionUsage(sessionId, current.signal).then((tree) => {
31582
+ if (!active || controller !== current || current.signal.aborted) return;
31583
+ const result = JSON.stringify(tree);
31584
+ if (result !== lastResult) {
31585
+ lastResult = result;
31586
+ setReading({
31587
+ sessionId,
31588
+ tree
31589
+ });
31590
+ }
31576
31591
  }).catch(() => {}).finally(() => {
31577
- pending = false;
31578
- if (active && again) {
31592
+ if (controller !== current) return;
31593
+ controller = void 0;
31594
+ if (!active || !visible()) return;
31595
+ if (again) {
31579
31596
  again = false;
31580
31597
  request();
31581
- }
31598
+ } else timer = setTimeout(request, SESSION_POLL_MS);
31582
31599
  });
31583
31600
  };
31601
+ const onVisibility = () => {
31602
+ clearTimer();
31603
+ if (!visible()) {
31604
+ again = false;
31605
+ controller?.abort();
31606
+ controller = void 0;
31607
+ } else request();
31608
+ };
31584
31609
  refresh.current = request;
31585
- const handle = setInterval(request, SESSION_POLL_MS);
31610
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
31586
31611
  return () => {
31587
31612
  active = false;
31588
31613
  refresh.current = null;
31589
- clearInterval(handle);
31614
+ clearTimer();
31615
+ controller?.abort();
31616
+ controller = void 0;
31617
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
31590
31618
  };
31591
31619
  }, [sessionId]);
31592
31620
  (0, react.useEffect)(() => {
package/lib/index.js CHANGED
@@ -1593,7 +1593,7 @@ async function readSession(persistence, id, header, live, signal) {
1593
1593
  }
1594
1594
  throw new Error("no readable persistence seam (need open or inspect)");
1595
1595
  }
1596
- async function readUsageTree(persistence, sessions, rootId, signal) {
1596
+ async function readUsageTree(persistence, sessions, rootId, signal, onCatalog) {
1597
1597
  signal?.throwIfAborted();
1598
1598
  const rows = await persistence.list(typeof persistence.open === "function" ? signal === void 0 ? void 0 : { signal } : signal);
1599
1599
  const headers = /* @__PURE__ */ new Map();
@@ -1603,11 +1603,14 @@ async function readUsageTree(persistence, sessions, rootId, signal) {
1603
1603
  }
1604
1604
  const live = new Map((sessions?.list() ?? []).map((handle) => [handle.id, handle]));
1605
1605
  const children = /* @__PURE__ */ new Map();
1606
+ const parents = /* @__PURE__ */ new Map();
1607
+ for (const id of [...headers.keys(), ...live.keys()]) parents.set(id, /* @__PURE__ */ new Set());
1606
1608
  const link = (id, parent) => {
1607
1609
  if (typeof parent !== "string" || parent === "" || parent === id) return;
1608
1610
  const bucket = children.get(parent) ?? /* @__PURE__ */ new Set();
1609
1611
  bucket.add(id);
1610
1612
  children.set(parent, bucket);
1613
+ parents.get(id)?.add(parent);
1611
1614
  };
1612
1615
  for (const header of headers.values()) link(header.id, header.parentSession);
1613
1616
  for (const handle of live.values()) link(handle.id, handle.header?.parentSession ?? handle.parentSession);
@@ -1620,6 +1623,10 @@ async function readUsageTree(persistence, sessions, rootId, signal) {
1620
1623
  ids.push(id);
1621
1624
  queue.push(id);
1622
1625
  }
1626
+ onCatalog?.({
1627
+ parents,
1628
+ members: seen
1629
+ });
1623
1630
  const root = await readSession(persistence, rootId, headers.get(rootId), live.get(rootId), signal);
1624
1631
  const logs = new Array(ids.length);
1625
1632
  const issues = new Array(ids.length);
@@ -2937,25 +2944,99 @@ function sumOneSession(log) {
2937
2944
  compaction
2938
2945
  };
2939
2946
  }
2940
- const CACHE_TTL_MS = 4e3;
2947
+ const CACHE_TTL_MS = 6e4;
2941
2948
  const CACHE_MAX = 64;
2942
2949
  var SessionUsageService = class {
2950
+ cacheTtlMs;
2943
2951
  cache = /* @__PURE__ */ new Map();
2944
- generation = 0;
2945
- /** New events can introduce descendants of any cached root. */
2946
- invalidate(_sessionId) {
2947
- this.generation++;
2948
- this.cache.clear();
2949
- }
2950
- /** A bad descendant leaves the readable totals available with coverage.
2951
- * Never cache incomplete results: a repaired log must recover on the next read.
2952
- * Root, catalog and cancellation failures still reject without publishing zero. */
2952
+ parents = /* @__PURE__ */ new Map();
2953
+ catalogVersion = 0;
2954
+ catalogOrder = 0;
2955
+ appliedCatalogOrder = 0;
2956
+ reads = /* @__PURE__ */ new Set();
2957
+ inFlight = /* @__PURE__ */ new Map();
2958
+ /** Events keep live trees fresh; the TTL bounds out-of-band catalog changes.
2959
+ * Hosts without a live event hook can select the old four-second fallback. */
2960
+ constructor(cacheTtlMs = CACHE_TTL_MS) {
2961
+ this.cacheTtlMs = cacheTtlMs;
2962
+ }
2963
+ /** Invalidate the changed session and every old/new ancestor, not other trees.
2964
+ * null is a known root; undefined means parent metadata was not supplied.
2965
+ * An unknown parent chain conservatively falls back instead of missing a new
2966
+ * child that appeared before the persistence catalog caught up. */
2967
+ invalidate(sessionId, parentSession) {
2968
+ this.catalogVersion++;
2969
+ const previous = this.parents.get(sessionId);
2970
+ const queue = [...previous ?? []];
2971
+ let uncertain = previous === void 0 && parentSession === void 0;
2972
+ if (parentSession !== void 0) {
2973
+ const current = /* @__PURE__ */ new Set();
2974
+ if (parentSession !== null && parentSession !== "" && parentSession !== sessionId) current.add(parentSession);
2975
+ if (previous !== void 0) this.parents.set(sessionId, current);
2976
+ queue.push(...current);
2977
+ }
2978
+ const affected = /* @__PURE__ */ new Set([sessionId]);
2979
+ for (let index = 0; index < queue.length; index++) {
2980
+ const id = queue[index];
2981
+ if (affected.has(id)) continue;
2982
+ affected.add(id);
2983
+ const ancestors = this.parents.get(id);
2984
+ if (ancestors === void 0) uncertain = true;
2985
+ else queue.push(...ancestors);
2986
+ }
2987
+ const touches = (read) => {
2988
+ if (affected.has(read.rootId)) return true;
2989
+ for (const id of affected) if (read.members.has(id)) return true;
2990
+ return false;
2991
+ };
2992
+ for (const [rootId, entry] of this.cache) if (uncertain || touches(entry.read)) this.cache.delete(rootId);
2993
+ for (const read of this.reads) if (!read.catalogReady || uncertain || touches(read)) read.invalidated = true;
2994
+ }
2995
+ /** A bad descendant leaves readable totals available, but is never cached.
2996
+ * Signal-less browser requests share one cold read per root. Explicit signals
2997
+ * retain independent ownership and are passed through unchanged: aborting one
2998
+ * caller cannot cancel another caller's readers or publish its partial totals. */
2953
2999
  async tree(persistence, sessions, rootId, signal) {
2954
3000
  signal?.throwIfAborted();
2955
3001
  const hit = this.cache.get(rootId);
2956
- if (hit !== void 0 && Date.now() - hit.at < CACHE_TTL_MS) return hit.value;
2957
- const generation = this.generation;
2958
- const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId, signal);
3002
+ if (hit !== void 0 && hit.read.persistence === persistence && hit.read.sessions === sessions && Date.now() - hit.at < this.cacheTtlMs) {
3003
+ this.cache.delete(rootId);
3004
+ this.cache.set(rootId, hit);
3005
+ return hit.value;
3006
+ }
3007
+ this.cache.delete(rootId);
3008
+ const pending = this.inFlight.get(rootId);
3009
+ if (signal === void 0 && pending !== void 0 && !pending.read.invalidated && pending.read.persistence === persistence && pending.read.sessions === sessions) return pending.promise;
3010
+ const read = {
3011
+ rootId,
3012
+ persistence,
3013
+ sessions,
3014
+ members: /* @__PURE__ */ new Set([rootId]),
3015
+ invalidated: false,
3016
+ catalogReady: false,
3017
+ catalogVersion: this.catalogVersion,
3018
+ catalogOrder: ++this.catalogOrder
3019
+ };
3020
+ this.reads.add(read);
3021
+ const promise = this.compute(read, signal).finally(() => {
3022
+ this.reads.delete(read);
3023
+ if (this.inFlight.get(rootId)?.read === read) this.inFlight.delete(rootId);
3024
+ });
3025
+ if (signal === void 0) this.inFlight.set(rootId, {
3026
+ read,
3027
+ promise
3028
+ });
3029
+ return promise;
3030
+ }
3031
+ async compute(read, signal) {
3032
+ const { root, descendants, coverage } = await readUsageTree(read.persistence, read.sessions, read.rootId, signal, (catalog) => {
3033
+ read.members = catalog.members;
3034
+ read.catalogReady = true;
3035
+ if (read.catalogVersion === this.catalogVersion && read.catalogOrder > this.appliedCatalogOrder) {
3036
+ this.parents = new Map(catalog.parents);
3037
+ this.appliedCatalogOrder = read.catalogOrder;
3038
+ }
3039
+ });
2959
3040
  const own = sumOneSession(root);
2960
3041
  const subagents = zero();
2961
3042
  for (const log of descendants) add(subagents, sumOneSession(log).totals);
@@ -2970,11 +3051,18 @@ var SessionUsageService = class {
2970
3051
  coverage
2971
3052
  };
2972
3053
  signal?.throwIfAborted();
2973
- if (coverage.complete && generation === this.generation) {
2974
- if (this.cache.size >= CACHE_MAX) this.cache.clear();
2975
- this.cache.set(rootId, {
3054
+ const newer = this.cache.get(read.rootId);
3055
+ if (coverage.complete && !read.invalidated && (newer === void 0 || newer.read.catalogOrder < read.catalogOrder)) {
3056
+ for (const older of this.reads) if (older.rootId === read.rootId && older.persistence === read.persistence && older.sessions === read.sessions && older.catalogOrder < read.catalogOrder) older.invalidated = true;
3057
+ this.cache.delete(read.rootId);
3058
+ if (this.cache.size >= CACHE_MAX) {
3059
+ const oldest = this.cache.keys().next().value;
3060
+ if (oldest !== void 0) this.cache.delete(oldest);
3061
+ }
3062
+ this.cache.set(read.rootId, {
2976
3063
  at: Date.now(),
2977
- value
3064
+ value,
3065
+ read
2978
3066
  });
2979
3067
  }
2980
3068
  return value;
@@ -3026,7 +3114,8 @@ function apply(ctx, config = {}) {
3026
3114
  source: config.source,
3027
3115
  backfillConcurrency: config.backfillConcurrency
3028
3116
  });
3029
- const sessionUsage = new SessionUsageService();
3117
+ const sessionEvents = ctx;
3118
+ const sessionUsage = new SessionUsageService(typeof sessionEvents.on === "function" ? void 0 : 4e3);
3030
3119
  const trust = () => {
3031
3120
  const runtime = ctx.get("webRuntime");
3032
3121
  const startup = ctx.get("webStartup");
@@ -3128,9 +3217,13 @@ function apply(ctx, config = {}) {
3128
3217
  collector.start();
3129
3218
  const onEvent = (session) => {
3130
3219
  const id = typeof session?.id === "string" ? session.id : "";
3131
- if (id !== "") sessionUsage.invalidate(id);
3220
+ if (id === "") return;
3221
+ const owner = ctx.sessions.get?.(id) ?? session;
3222
+ const parent = owner.header?.parentSession ?? owner.parentSession;
3223
+ const knownParent = owner.header !== void 0 || "parentSession" in owner;
3224
+ sessionUsage.invalidate(id, knownParent ? parent ?? null : void 0);
3132
3225
  };
3133
- ctx.on?.("session/event", onEvent);
3226
+ sessionEvents.on?.("session/event", onEvent);
3134
3227
  (async () => {
3135
3228
  await rescan();
3136
3229
  await ensureHourHistory();
@@ -28,6 +28,6 @@ export declare function rebuildStats(): Promise<BackfillStatus>;
28
28
  * every subagent session delegated from it. The native `tokenUsage` projection
29
29
  * covers only the main agent, so this is what makes a delegating session's
30
30
  * cost visible. */
31
- export declare function fetchSessionUsage(sessionId: string): Promise<SessionUsageTree>;
31
+ export declare function fetchSessionUsage(sessionId: string, signal?: AbortSignal): Promise<SessionUsageTree>;
32
32
  /** Log-owned requests and live route states for the per-session Usage view. */
33
33
  export declare function fetchSessionDetail(sessionId: string, signal?: AbortSignal): Promise<SessionUsageDetail>;
@@ -9,7 +9,12 @@ export interface UsageSessionLog {
9
9
  events: readonly UsageSessionEvent[];
10
10
  inheritedCut: number;
11
11
  }
12
- export declare function readUsageTree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<{
12
+ /** Metadata only: includes unreadable descendants and both persisted/live links. */
13
+ export interface UsageTreeCatalog {
14
+ parents: ReadonlyMap<string, ReadonlySet<string>>;
15
+ members: ReadonlySet<string>;
16
+ }
17
+ export declare function readUsageTree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal, onCatalog?: (catalog: UsageTreeCatalog) => void): Promise<{
13
18
  root: UsageSessionLog;
14
19
  descendants: UsageSessionLog[];
15
20
  coverage: SessionUsageCoverage;
@@ -3,12 +3,26 @@ import type { UsageSessionPersistence, UsageSessionStore } from './context-types
3
3
  import type { SessionUsageTree } from './wire.ts';
4
4
  export type { SessionUsageTotals, SessionUsageTree } from './wire.ts';
5
5
  export declare class SessionUsageService {
6
+ private readonly cacheTtlMs;
6
7
  private cache;
7
- private generation;
8
- /** New events can introduce descendants of any cached root. */
9
- invalidate(_sessionId: string): void;
10
- /** A bad descendant leaves the readable totals available with coverage.
11
- * Never cache incomplete results: a repaired log must recover on the next read.
12
- * Root, catalog and cancellation failures still reject without publishing zero. */
8
+ private parents;
9
+ private catalogVersion;
10
+ private catalogOrder;
11
+ private appliedCatalogOrder;
12
+ private reads;
13
+ private inFlight;
14
+ /** Events keep live trees fresh; the TTL bounds out-of-band catalog changes.
15
+ * Hosts without a live event hook can select the old four-second fallback. */
16
+ constructor(cacheTtlMs?: number);
17
+ /** Invalidate the changed session and every old/new ancestor, not other trees.
18
+ * null is a known root; undefined means parent metadata was not supplied.
19
+ * An unknown parent chain conservatively falls back instead of missing a new
20
+ * child that appeared before the persistence catalog caught up. */
21
+ invalidate(sessionId: string, parentSession?: string | null): void;
22
+ /** A bad descendant leaves readable totals available, but is never cached.
23
+ * Signal-less browser requests share one cold read per root. Explicit signals
24
+ * retain independent ownership and are passed through unchanged: aborting one
25
+ * caller cannot cancel another caller's readers or publish its partial totals. */
13
26
  tree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<SessionUsageTree>;
27
+ private compute;
14
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
package/src/client/api.ts CHANGED
@@ -71,8 +71,8 @@ export async function rebuildStats(): Promise<BackfillStatus> {
71
71
  * every subagent session delegated from it. The native `tokenUsage` projection
72
72
  * covers only the main agent, so this is what makes a delegating session's
73
73
  * cost visible. */
74
- export async function fetchSessionUsage(sessionId: string): Promise<SessionUsageTree> {
75
- return post<SessionUsageTree>('session', { sessionId })
74
+ export async function fetchSessionUsage(sessionId: string, signal?: AbortSignal): Promise<SessionUsageTree> {
75
+ return post<SessionUsageTree>('session', { sessionId }, signal)
76
76
  }
77
77
 
78
78
  /** Log-owned requests and live route states for the per-session Usage view. */
@@ -1,7 +1,8 @@
1
1
  /** The composer row's window onto the whole session tree. The native usage
2
2
  * projection only moves for main-agent calls, so poll the displayed session
3
- * even before its first child appears. Requests are serialized and late answers
4
- * from a previous session are discarded. Failures keep the last good reading. */
3
+ * even before its first child appears. Hidden pages pause; slow responses do
4
+ * not queue timer retries. Disposed requests are aborted and late answers are
5
+ * discarded. Failures keep the last good reading. */
5
6
  import { useEffect, useRef, useState } from 'react'
6
7
  import { fetchSessionUsage } from './api.ts'
7
8
  import { combinedTotals } from './session-usage-core.ts'
@@ -23,33 +24,64 @@ export function useSessionUsage(sessionId: string | undefined, signature: string
23
24
  useEffect(() => {
24
25
  if (typeof sessionId !== 'string' || sessionId === '') return
25
26
  let active = true
26
- let pending = false
27
27
  let again = false
28
+ let lastResult: string | undefined
29
+ let timer: ReturnType<typeof setTimeout> | undefined
30
+ let controller: AbortController | undefined
31
+ const visible = (): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden'
32
+ const clearTimer = (): void => {
33
+ if (timer !== undefined) clearTimeout(timer)
34
+ timer = undefined
35
+ }
28
36
  const request = (): void => {
29
- if (!active) return
30
- if (pending) {
37
+ if (!active || !visible()) return
38
+ if (controller !== undefined) {
31
39
  again = true
32
40
  return
33
41
  }
34
- pending = true
35
- void fetchSessionUsage(sessionId).then((tree) => {
36
- if (active) setReading({ sessionId, tree })
42
+ clearTimer()
43
+ const current = new AbortController()
44
+ controller = current
45
+ void fetchSessionUsage(sessionId, current.signal).then((tree) => {
46
+ if (!active || controller !== current || current.signal.aborted) return
47
+ const result = JSON.stringify(tree)
48
+ if (result !== lastResult) {
49
+ lastResult = result
50
+ setReading({ sessionId, tree })
51
+ }
37
52
  }).catch(() => {
38
53
  // Retry on the next beat/signature; do not replace good data with zero.
39
54
  }).finally(() => {
40
- pending = false
41
- if (active && again) {
55
+ if (controller !== current) return
56
+ controller = undefined
57
+ if (!active || !visible()) return
58
+ if (again) {
42
59
  again = false
43
60
  request()
61
+ } else {
62
+ timer = setTimeout(request, SESSION_POLL_MS)
44
63
  }
45
64
  })
46
65
  }
66
+ const onVisibility = (): void => {
67
+ clearTimer()
68
+ if (!visible()) {
69
+ again = false
70
+ controller?.abort()
71
+ controller = undefined
72
+ } else {
73
+ request()
74
+ }
75
+ }
47
76
  refresh.current = request
48
- const handle = setInterval(request, SESSION_POLL_MS)
77
+ if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
49
78
  return () => {
50
79
  active = false
51
80
  refresh.current = null
52
- clearInterval(handle)
81
+ clearTimer()
82
+ controller?.abort()
83
+ controller = undefined
84
+ if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
53
85
  }
54
86
  }, [sessionId])
55
87
  useEffect(() => {
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * the browser half over the fenced route.
13
13
  */
14
14
 
15
- import type { Context, UsageWebRuntime, UsageWebStartup } from './context-types.ts'
15
+ import type { Context, UsageSessionHandle, UsageWebRuntime, UsageWebStartup } from './context-types.ts'
16
16
  import type { TrustInputs } from './trust-fence.ts'
17
17
  import { UsageStore } from './store.ts'
18
18
  import { UsageCollector } from './collector.ts'
@@ -72,10 +72,11 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
72
72
  source: config.source,
73
73
  backfillConcurrency: config.backfillConcurrency,
74
74
  })
75
- // Per-session (main + subagent) usage for the composer stats row. Its cache
76
- // is invalidated on every live event of the touched session (see the
77
- // collector subscription below), so the pill never shows a stale tree.
78
- const sessionUsage = new SessionUsageService()
75
+ // Committed events invalidate only the touched tree and its ancestors. With
76
+ // that hook, unchanged histories can stay cached longer; older hosts without
77
+ // it retain four-second catalog revalidation rather than stale live totals.
78
+ const sessionEvents = ctx as unknown as { on?: (name: string, fn: (s: Partial<UsageSessionHandle>) => void) => void }
79
+ const sessionUsage = new SessionUsageService(typeof sessionEvents.on === 'function' ? undefined : 4_000)
79
80
 
80
81
  // The /token-total/api trust inputs, resolved live so the fence reads the same sources
81
82
  // the /api gateway derives them from. DSH 0.2.1-alpha.2 moved the explicit
@@ -242,15 +243,17 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
242
243
 
243
244
  ctx.effect(() => {
244
245
  collector.start()
245
- // Keep the per-session tree cache honest: every committed event of any
246
- // session (main or subagent) changes at least one cached tree, so drop the
247
- // ones that counted it. Observational only — a missing hook is harmless.
248
- const onEvent = (session: { id?: unknown }): void => {
246
+ // Include live ancestry: a new child may not be in the persisted catalog
247
+ // yet. Missing metadata takes the service's conservative fallback path.
248
+ const onEvent = (session: Partial<UsageSessionHandle>): void => {
249
249
  const id = typeof session?.id === 'string' ? session.id : ''
250
- if (id !== '') sessionUsage.invalidate(id)
250
+ if (id === '') return
251
+ const owner = ctx.sessions.get?.(id) ?? session
252
+ const parent = owner.header?.parentSession ?? owner.parentSession
253
+ const knownParent = owner.header !== undefined || 'parentSession' in owner
254
+ sessionUsage.invalidate(id, knownParent ? parent ?? null : undefined)
251
255
  }
252
- ;(ctx as unknown as { on?: (name: string, fn: (s: { id?: unknown }) => void) => void })
253
- .on?.('session/event', onEvent)
256
+ sessionEvents.on?.('session/event', onEvent)
254
257
  void (async () => {
255
258
  await rescan()
256
259
  await ensureHourHistory()
@@ -18,6 +18,12 @@ export interface UsageSessionLog {
18
18
  inheritedCut: number
19
19
  }
20
20
 
21
+ /** Metadata only: includes unreadable descendants and both persisted/live links. */
22
+ export interface UsageTreeCatalog {
23
+ parents: ReadonlyMap<string, ReadonlySet<string>>
24
+ members: ReadonlySet<string>
25
+ }
26
+
21
27
  function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
22
28
  if (!item || typeof item !== 'object') return null
23
29
  const header = 'header' in item ? item.header : item
@@ -84,6 +90,7 @@ export async function readUsageTree(
84
90
  sessions: UsageSessionStore | undefined,
85
91
  rootId: string,
86
92
  signal?: AbortSignal,
93
+ onCatalog?: (catalog: UsageTreeCatalog) => void,
87
94
  ): Promise<{ root: UsageSessionLog; descendants: UsageSessionLog[]; coverage: SessionUsageCoverage }> {
88
95
  signal?.throwIfAborted()
89
96
  const rows = await persistence.list(typeof persistence.open === 'function'
@@ -96,11 +103,14 @@ export async function readUsageTree(
96
103
  }
97
104
  const live = new Map((sessions?.list() ?? []).map(handle => [handle.id, handle]))
98
105
  const children = new Map<string, Set<string>>()
106
+ const parents = new Map<string, Set<string>>()
107
+ for (const id of [...headers.keys(), ...live.keys()]) parents.set(id, new Set())
99
108
  const link = (id: string, parent: unknown): void => {
100
109
  if (typeof parent !== 'string' || parent === '' || parent === id) return
101
110
  const bucket = children.get(parent) ?? new Set<string>()
102
111
  bucket.add(id)
103
112
  children.set(parent, bucket)
113
+ parents.get(id)?.add(parent)
104
114
  }
105
115
  for (const header of headers.values()) link(header.id, header.parentSession)
106
116
  for (const handle of live.values()) link(handle.id, handle.header?.parentSession ?? handle.parentSession)
@@ -115,6 +125,9 @@ export async function readUsageTree(
115
125
  queue.push(id)
116
126
  }
117
127
  }
128
+ // Publish membership before any asynchronous log read, so events arriving
129
+ // during that read can invalidate the right root, including bad descendants.
130
+ onCatalog?.({ parents, members: seen })
118
131
  const root = await readSession(persistence, rootId, headers.get(rootId), live.get(rootId), signal)
119
132
  const logs: Array<UsageSessionLog | undefined> = new Array(ids.length)
120
133
  const issues: Array<SessionUsageReadIssue | undefined> = new Array(ids.length)
@@ -34,22 +34,85 @@ function sumOneSession(log: UsageSessionLog): { totals: SessionUsageTotals; comp
34
34
  return { totals, compaction }
35
35
  }
36
36
 
37
- const CACHE_TTL_MS = 4_000
37
+ const CACHE_TTL_MS = 60_000
38
38
  const CACHE_MAX = 64
39
39
 
40
+ interface TreeRead {
41
+ rootId: string
42
+ persistence: UsageSessionPersistence
43
+ sessions: UsageSessionStore | undefined
44
+ members: ReadonlySet<string>
45
+ invalidated: boolean
46
+ catalogReady: boolean
47
+ catalogVersion: number
48
+ catalogOrder: number
49
+ }
50
+ interface CachedTree {
51
+ at: number
52
+ value: SessionUsageTree
53
+ read: TreeRead
54
+ }
55
+
40
56
  export class SessionUsageService {
41
- private cache = new Map<string, { at: number; value: SessionUsageTree }>()
42
- private generation = 0
57
+ private cache = new Map<string, CachedTree>()
58
+ private parents = new Map<string, ReadonlySet<string>>()
59
+ private catalogVersion = 0
60
+ private catalogOrder = 0
61
+ private appliedCatalogOrder = 0
62
+ private reads = new Set<TreeRead>()
63
+ private inFlight = new Map<string, { read: TreeRead; promise: Promise<SessionUsageTree> }>()
64
+
65
+ /** Events keep live trees fresh; the TTL bounds out-of-band catalog changes.
66
+ * Hosts without a live event hook can select the old four-second fallback. */
67
+ constructor(private readonly cacheTtlMs = CACHE_TTL_MS) {}
43
68
 
44
- /** New events can introduce descendants of any cached root. */
45
- invalidate(_sessionId: string): void {
46
- this.generation++
47
- this.cache.clear()
69
+ /** Invalidate the changed session and every old/new ancestor, not other trees.
70
+ * null is a known root; undefined means parent metadata was not supplied.
71
+ * An unknown parent chain conservatively falls back instead of missing a new
72
+ * child that appeared before the persistence catalog caught up. */
73
+ invalidate(sessionId: string, parentSession?: string | null): void {
74
+ this.catalogVersion++
75
+ const previous = this.parents.get(sessionId)
76
+ const queue = [...(previous ?? [])]
77
+ let uncertain = previous === undefined && parentSession === undefined
78
+ if (parentSession !== undefined) {
79
+ const current = new Set<string>()
80
+ if (parentSession !== null && parentSession !== '' && parentSession !== sessionId) current.add(parentSession)
81
+ // Do not accumulate unbounded event-only ids between catalog reads.
82
+ // Unknown hints still invalidate their ancestors immediately.
83
+ if (previous !== undefined) this.parents.set(sessionId, current)
84
+ queue.push(...current)
85
+ }
86
+ const affected = new Set([sessionId])
87
+ for (let index = 0; index < queue.length; index++) {
88
+ const id = queue[index]!
89
+ if (affected.has(id)) continue
90
+ affected.add(id)
91
+ const ancestors = this.parents.get(id)
92
+ if (ancestors === undefined) uncertain = true
93
+ else queue.push(...ancestors)
94
+ }
95
+ const touches = (read: TreeRead): boolean => {
96
+ if (affected.has(read.rootId)) return true
97
+ // A newer catalog may have moved an ancestor away from this older tree.
98
+ // Changes below that ancestor must still invalidate its old membership.
99
+ for (const id of affected) if (read.members.has(id)) return true
100
+ return false
101
+ }
102
+ for (const [rootId, entry] of this.cache) {
103
+ if (uncertain || touches(entry.read)) this.cache.delete(rootId)
104
+ }
105
+ // Detached older reads remain tracked until they settle. A new request must
106
+ // not join an invalidated read, and that read must never repopulate the cache.
107
+ for (const read of this.reads) {
108
+ if (!read.catalogReady || uncertain || touches(read)) read.invalidated = true
109
+ }
48
110
  }
49
111
 
50
- /** A bad descendant leaves the readable totals available with coverage.
51
- * Never cache incomplete results: a repaired log must recover on the next read.
52
- * Root, catalog and cancellation failures still reject without publishing zero. */
112
+ /** A bad descendant leaves readable totals available, but is never cached.
113
+ * Signal-less browser requests share one cold read per root. Explicit signals
114
+ * retain independent ownership and are passed through unchanged: aborting one
115
+ * caller cannot cancel another caller's readers or publish its partial totals. */
53
116
  async tree(
54
117
  persistence: UsageSessionPersistence,
55
118
  sessions: UsageSessionStore | undefined,
@@ -58,9 +121,43 @@ export class SessionUsageService {
58
121
  ): Promise<SessionUsageTree> {
59
122
  signal?.throwIfAborted()
60
123
  const hit = this.cache.get(rootId)
61
- if (hit !== undefined && Date.now() - hit.at < CACHE_TTL_MS) return hit.value
62
- const generation = this.generation
63
- const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId, signal)
124
+ if (hit !== undefined && hit.read.persistence === persistence && hit.read.sessions === sessions
125
+ && Date.now() - hit.at < this.cacheTtlMs) {
126
+ // Map insertion order is the LRU; a hit must protect a popular history.
127
+ this.cache.delete(rootId)
128
+ this.cache.set(rootId, hit)
129
+ return hit.value
130
+ }
131
+ this.cache.delete(rootId)
132
+ const pending = this.inFlight.get(rootId)
133
+ if (signal === undefined && pending !== undefined && !pending.read.invalidated
134
+ && pending.read.persistence === persistence && pending.read.sessions === sessions) return pending.promise
135
+
136
+ const read: TreeRead = {
137
+ rootId, persistence, sessions, members: new Set([rootId]), invalidated: false,
138
+ catalogReady: false, catalogVersion: this.catalogVersion, catalogOrder: ++this.catalogOrder,
139
+ }
140
+ this.reads.add(read)
141
+ const promise = this.compute(read, signal).finally(() => {
142
+ this.reads.delete(read)
143
+ if (this.inFlight.get(rootId)?.read === read) this.inFlight.delete(rootId)
144
+ })
145
+ if (signal === undefined) this.inFlight.set(rootId, { read, promise })
146
+ return promise
147
+ }
148
+
149
+ private async compute(read: TreeRead, signal?: AbortSignal): Promise<SessionUsageTree> {
150
+ const { root, descendants, coverage } = await readUsageTree(
151
+ read.persistence, read.sessions, read.rootId, signal, (catalog) => {
152
+ read.members = catalog.members
153
+ read.catalogReady = true
154
+ // A slow older listing must not undo newer catalogs or event hints.
155
+ if (read.catalogVersion === this.catalogVersion && read.catalogOrder > this.appliedCatalogOrder) {
156
+ this.parents = new Map(catalog.parents)
157
+ this.appliedCatalogOrder = read.catalogOrder
158
+ }
159
+ },
160
+ )
64
161
  const own = sumOneSession(root)
65
162
  const subagents = zero()
66
163
  for (const log of descendants) add(subagents, sumOneSession(log).totals)
@@ -75,9 +172,20 @@ export class SessionUsageService {
75
172
  coverage,
76
173
  }
77
174
  signal?.throwIfAborted()
78
- if (coverage.complete && generation === this.generation) {
79
- if (this.cache.size >= CACHE_MAX) this.cache.clear()
80
- this.cache.set(rootId, { at: Date.now(), value })
175
+ const newer = this.cache.get(read.rootId)
176
+ if (coverage.complete && !read.invalidated && (newer === undefined || newer.read.catalogOrder < read.catalogOrder)) {
177
+ // Retire older same-source snapshots before publishing. LRU eviction of
178
+ // this result must not let an older detached read be joined or cached.
179
+ for (const older of this.reads) {
180
+ if (older.rootId === read.rootId && older.persistence === read.persistence
181
+ && older.sessions === read.sessions && older.catalogOrder < read.catalogOrder) older.invalidated = true
182
+ }
183
+ this.cache.delete(read.rootId)
184
+ if (this.cache.size >= CACHE_MAX) {
185
+ const oldest = this.cache.keys().next().value
186
+ if (oldest !== undefined) this.cache.delete(oldest)
187
+ }
188
+ this.cache.set(read.rootId, { at: Date.now(), value, read })
81
189
  }
82
190
  return value
83
191
  }