@chenlongapps/opencode-token-usage 0.2.2 → 0.2.4

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
@@ -1,40 +1,30 @@
1
- # opencode-token-usage
2
-
3
- [![npm version](https://img.shields.io/npm/v/%40chenlongapps%2Fopencode-token-usage?logo=npm)](https://www.npmjs.com/package/@chenlongapps/opencode-token-usage)
4
- [![OpenCode 2](https://img.shields.io/badge/OpenCode-2-5A67D8)](https://opencode.ai/)
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
-
7
- 面向 **OpenCode 2** 的 token 用量插件。在原生 TUI 中显示整棵会话树(含子代理)的 token、估算费用与性能指标,以及当前会话的上下文占用。
8
-
9
- ## 预览
10
-
11
- ```text
12
- Token Usage
13
- Context 9,810 / 128,000 (7.7%)
14
- Input 54,200
15
- Output 6,800
16
- Reasoning 4,100
17
- Cache Read 31,700
18
- Cache Write 1,300
19
- Cache Rate 36.4%
20
- Total 98,100
21
- Cost $0.21
22
-
23
- TPS 48.7 tok/s
24
- TTFT 2.7s
25
- ```
1
+ <h1 align="center">opencode-token-usage</h1>
2
+
3
+ <p align="center">A session-tree token usage monitor for OpenCode 2.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@chenlongapps/opencode-token-usage"><img alt="npm" src="https://img.shields.io/npm/v/%40chenlongapps%2Fopencode-token-usage?style=flat-square&logo=npm" /></a>
7
+ <a href="https://opencode.ai/"><img alt="OpenCode 2" src="https://img.shields.io/badge/OpenCode-2-5A67D8?style=flat-square" /></a>
8
+ <a href="https://github.com/chenlongapps/opencode-token-usage/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/chenlongapps/opencode-token-usage/ci.yml?style=flat-square&branch=main&label=ci" /></a>
9
+ <a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-yellow?style=flat-square" /></a>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <strong>English</strong> |
14
+ <a href="https://github.com/chenlongapps/opencode-token-usage/blob/main/README.zh-CN.md">简体中文</a>
15
+ </p>
26
16
 
27
- 实际价格与上下文上限取自当前会话的活动模型。
17
+ [![OpenCode Token Usage plugin preview](https://raw.githubusercontent.com/chenlongapps/opencode-token-usage/main/docs/assets/opencode-token-usage-preview.webp)](https://www.npmjs.com/package/@chenlongapps/opencode-token-usage)
28
18
 
29
- ## 安装
19
+ ---
30
20
 
31
- 推荐使用 OpenCode 安装:
21
+ ### Installation
32
22
 
33
23
  ```bash
34
24
  opencode plugin add @chenlongapps/opencode-token-usage
35
25
  ```
36
26
 
37
- 也可以在项目的 `opencode.json` 或 `opencode.jsonc` 中配置:
27
+ Alternatively, add the package to your project's `opencode.json` or `opencode.jsonc`:
38
28
 
39
29
  ```json
40
30
  {
@@ -42,33 +32,36 @@ opencode plugin add @chenlongapps/opencode-token-usage
42
32
  }
43
33
  ```
44
34
 
45
- 安装后重启 OpenCode。主会话中的面板位于原生侧边栏;终端需要足够宽,且 `session.sidebar` 应设为 `auto`。宿主在子代理视图中隐藏侧边栏,因此插件会在输入区上方显示同一面板。
35
+ > [!NOTE]
36
+ > Requires Node.js 22+. OpenCode 2.0.9, 2.0.10, and 2.0.11 have been verified across project releases. The current SDK 2.0.11 build has been reverified on OpenCode 2.0.11.
46
37
 
47
- 连接远程服务器时,可将包名加入本机 `~/.config/opencode/cli.json` 的 `plugins`,仅加载终端入口;配置路径遵循 `XDG_CONFIG_HOME`。
38
+ Restart OpenCode after installation. The panel appears in the native sidebar when `session.sidebar` is set to `auto` and the terminal is wide enough. OpenCode hides the sidebar in subagent views, so the plugin renders the same panel above the composer instead.
48
39
 
49
- ## 指标口径
40
+ For remote sessions, add the package name to `plugins` in your local `~/.config/opencode/cli.json` to load only the terminal entry point. The configuration path follows `XDG_CONFIG_HOME`.
50
41
 
51
- | 指标 | 定义 |
42
+ ### Metrics
43
+
44
+ | Metric | Definition |
52
45
  | --- | --- |
53
- | `Input` | 输入 token,不含缓存读取和写入 |
54
- | `Output` | 输出 token,不含推理 token |
55
- | `Reasoning` | 推理 token |
46
+ | `Input` | Input tokens, excluding cache reads and writes |
47
+ | `Output` | Output tokens, excluding reasoning tokens |
48
+ | `Reasoning` | Reasoning tokens |
56
49
  | `Cache Rate` | `Cache Read ÷ (Input + Cache Read + Cache Write)` |
57
- | `Total` | 五类 token 之和 |
58
- | `Context` | 当前会话最后一次已完成压缩后的最新上下文用量,不随子树累计 |
59
- | `Cost` | 按当前会话活动模型重新估算的全树费用,不代表提供商账单 |
60
- | `TPS` | 全树 `Output + Reasoning` 的生成速度;生成中显示 `~` 估算值,完成后显示精确值 |
61
- | `TTFT` | 全树中可测 assistant step 的平均首 token 时间 |
50
+ | `Total` | Sum of all five token categories |
51
+ | `Context` | Latest context usage after the most recent completed compaction in the viewed session; not aggregated across the subtree |
52
+ | `Cost` | Estimated cost for the entire tree, recalculated using the viewed session's active model; not a provider bill |
53
+ | `TPS` | Generation throughput for `Output + Reasoning` across the tree; live estimates are marked with `~` |
54
+ | `TTFT` | Average time to first token across measurable assistant steps in the tree |
62
55
 
63
- 补充说明:
56
+ #### Behavior
64
57
 
65
- - 用量覆盖当前会话树中的 assistant 与 compaction 消息,包括未打开的子代理;只使用服务端上报值,不按文本长度估算。
66
- - 分叉会话是独立会话树。继承消息副本只归原始来源,避免重复计费。
67
- - `Context` 仅搜索最后一次 `status === "completed"` 的 compaction 之后;没有可靠用量或模型上限时隐藏。
68
- - `Cost` 使用每条消息的传入 token 选择价格档位,`Output` 与 `Reasoning` 使用输出价格。缺失适用价格时按 OpenCode 官方行为回退为 0。
69
- - 首次读取失败显示 `Unavailable`;后续刷新失败保留上次完整快照、标注 `Not updated` 并自动重试。
58
+ - Usage includes server-reported assistant and compaction messages throughout the session tree, including unopened subagents. Token counts are never inferred from text length.
59
+ - A fork is a separate session tree. Inherited message copies are attributed only to their original source to prevent double counting.
60
+ - `Context` only searches messages after the most recent compaction with `status === "completed"` and is hidden when reliable usage or a model context limit is unavailable.
61
+ - `Cost` applies the viewed session's active model to the whole tree. Missing applicable prices fall back to 0, matching OpenCode's behavior.
62
+ - Initial read failures display `Unavailable`. Later failures retain the last complete snapshot, display `Not updated`, and retry automatically.
70
63
 
71
- ## 开发
64
+ ### Development
72
65
 
73
66
  ```bash
74
67
  npm ci
@@ -78,23 +71,24 @@ npm run build
78
71
  npm run test:smoke
79
72
  ```
80
73
 
81
- `test:smoke` 会打包真实产物,并在隔离的 OpenCode 与本地模拟提供商中验证加载、刷新、子代理累计、模型切换、TPS 和 TTFT。它需要 Python 3、可用的本地端口和 npm 网络访问,不会修改现有 OpenCode 配置或调用付费模型。
74
+ `test:smoke` packages the real artifact and validates loading, refreshes, subagent aggregation, model switching, TPS, and TTFT against an isolated OpenCode instance and a local mock provider. It requires Python 3, an available local port, and npm network access. It never modifies your existing OpenCode configuration or calls paid models.
75
+
76
+ To load the plugin from source, build the project and add the repository's absolute path to `plugins` in the target project.
77
+
78
+ ### Documentation
82
79
 
83
- 从源码加载时,先构建项目,再将仓库绝对路径加入目标项目的 `plugins`。根目录入口会转发到已编译的 ESM 产物。
80
+ - [Roadmap](ROADMAP.md)
81
+ - [Verification record](docs/verification.md)
82
+ - [Release guide](docs/releasing.md)
83
+ - [OpenCode 2 plugin documentation](https://opencode.ai/v2/docs/build/plugins/)
84
+ - [OpenCode 2 CLI plugin API](https://opencode.ai/v2/docs/build/plugins/cli/)
84
85
 
85
- - [路线图](ROADMAP.md)
86
- - [验证记录](docs/verification.md)
87
- - [发布说明](docs/releasing.md)
86
+ The implementation follows OpenCode's [TokenUsage schema](https://github.com/anomalyco/opencode/blob/v2/packages/schema/src/token-usage.ts), [cost calculation](https://github.com/anomalyco/opencode/blob/v2/packages/core/src/session/usage.ts), and [fork history projection](https://github.com/anomalyco/opencode/blob/v2/packages/core/src/session/projector.ts).
88
87
 
89
- ## 接口依据
88
+ ### Disclaimer
90
89
 
91
- - [OpenCode 2 插件开发](https://opencode.ai/v2/docs/build/plugins/)
92
- - [OpenCode 2 CLI 插件接口](https://opencode.ai/v2/docs/build/plugins/cli/)
93
- - [CLI 设置](https://opencode.ai/v2/docs/cli/config/)
94
- - [TokenUsage 定义](https://github.com/anomalyco/opencode/blob/v2/packages/schema/src/token-usage.ts)
95
- - [官方计费实现](https://github.com/anomalyco/opencode/blob/v2/packages/core/src/session/usage.ts)
96
- - [分叉历史投影](https://github.com/anomalyco/opencode/blob/v2/packages/core/src/session/projector.ts)
90
+ This is an independent community plugin. It is not built, maintained, or endorsed by the OpenCode team.
97
91
 
98
- ## 许可证
92
+ ### License
99
93
 
100
94
  [MIT](LICENSE)
@@ -0,0 +1,94 @@
1
+ <h1 align="center">opencode-token-usage</h1>
2
+
3
+ <p align="center">面向 OpenCode 2 的会话树 token 用量监视器。</p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/@chenlongapps/opencode-token-usage"><img alt="npm" src="https://img.shields.io/npm/v/%40chenlongapps%2Fopencode-token-usage?style=flat-square&logo=npm" /></a>
7
+ <a href="https://opencode.ai/"><img alt="OpenCode 2" src="https://img.shields.io/badge/OpenCode-2-5A67D8?style=flat-square" /></a>
8
+ <a href="https://github.com/chenlongapps/opencode-token-usage/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/chenlongapps/opencode-token-usage/ci.yml?style=flat-square&branch=main&label=ci" /></a>
9
+ <a href="LICENSE"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-yellow?style=flat-square" /></a>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="README.md">English</a> |
14
+ <strong>简体中文</strong>
15
+ </p>
16
+
17
+ [![OpenCode Token Usage 插件预览](https://raw.githubusercontent.com/chenlongapps/opencode-token-usage/main/docs/assets/opencode-token-usage-preview.webp)](https://www.npmjs.com/package/@chenlongapps/opencode-token-usage)
18
+
19
+ ---
20
+
21
+ ### 安装
22
+
23
+ ```bash
24
+ opencode plugin add @chenlongapps/opencode-token-usage
25
+ ```
26
+
27
+ 也可以在项目的 `opencode.json` 或 `opencode.jsonc` 中配置:
28
+
29
+ ```json
30
+ {
31
+ "plugins": ["@chenlongapps/opencode-token-usage"]
32
+ }
33
+ ```
34
+
35
+ > [!NOTE]
36
+ > 需要 Node.js 22+。项目历次发布已验证 OpenCode 2.0.9、2.0.10 和 2.0.11;当前 SDK 2.0.11 构建已在 OpenCode 2.0.11 上重新验证。
37
+
38
+ 安装后重启 OpenCode。终端足够宽且 `session.sidebar` 设为 `auto` 时,面板会显示在原生侧边栏中。OpenCode 在子代理视图中隐藏侧边栏,因此插件会在输入区上方显示同一面板。
39
+
40
+ 连接远程服务器时,可将包名加入本机 `~/.config/opencode/cli.json` 的 `plugins`,仅加载终端入口;配置路径遵循 `XDG_CONFIG_HOME`。
41
+
42
+ ### 指标
43
+
44
+ | 指标 | 定义 |
45
+ | --- | --- |
46
+ | `Input` | 输入 token,不含缓存读取和写入 |
47
+ | `Output` | 输出 token,不含推理 token |
48
+ | `Reasoning` | 推理 token |
49
+ | `Cache Rate` | `Cache Read ÷ (Input + Cache Read + Cache Write)` |
50
+ | `Total` | 五类 token 之和 |
51
+ | `Context` | 当前会话最后一次已完成压缩后的最新上下文用量,不随子树累计 |
52
+ | `Cost` | 按当前会话活动模型重新估算的全树费用,不代表提供商账单 |
53
+ | `TPS` | 全树 `Output + Reasoning` 的生成速度;生成中的估算值带 `~` 标记 |
54
+ | `TTFT` | 全树中可测 assistant step 的平均首 token 时间 |
55
+
56
+ #### 行为
57
+
58
+ - 用量覆盖会话树中服务端上报的 assistant 与 compaction 消息,包括未打开的子代理;不按文本长度估算 token。
59
+ - 分叉会话是独立会话树。继承消息副本只归原始来源,避免重复计费。
60
+ - `Context` 仅搜索最后一次 `status === "completed"` 的 compaction 之后;没有可靠用量或模型上限时隐藏。
61
+ - `Cost` 使用当前查看会话的活动模型重算整棵树;缺失适用价格时按 OpenCode 行为回退为 0。
62
+ - 首次读取失败显示 `Unavailable`;后续失败保留上次完整快照、标注 `Not updated` 并自动重试。
63
+
64
+ ### 开发
65
+
66
+ ```bash
67
+ npm ci
68
+ npm run typecheck
69
+ npm test
70
+ npm run build
71
+ npm run test:smoke
72
+ ```
73
+
74
+ `test:smoke` 会打包真实产物,并在隔离的 OpenCode 与本地模拟提供商中验证加载、刷新、子代理累计、模型切换、TPS 和 TTFT。它需要 Python 3、可用的本地端口和 npm 网络访问,不会修改现有 OpenCode 配置或调用付费模型。
75
+
76
+ 从源码加载时,先构建项目,再将仓库绝对路径加入目标项目的 `plugins`。
77
+
78
+ ### 文档
79
+
80
+ - [路线图](ROADMAP.md)
81
+ - [验证记录](docs/verification.md)
82
+ - [发布说明](docs/releasing.md)
83
+ - [OpenCode 2 插件开发文档](https://opencode.ai/v2/docs/build/plugins/)
84
+ - [OpenCode 2 CLI 插件接口](https://opencode.ai/v2/docs/build/plugins/cli/)
85
+
86
+ 实现遵循 OpenCode 的 [TokenUsage 定义](https://github.com/anomalyco/opencode/blob/v2/packages/schema/src/token-usage.ts)、[官方计费实现](https://github.com/anomalyco/opencode/blob/v2/packages/core/src/session/usage.ts)和[分叉历史投影](https://github.com/anomalyco/opencode/blob/v2/packages/core/src/session/projector.ts)。
87
+
88
+ ### 声明
89
+
90
+ 这是一个独立的社区插件,并非由 OpenCode 团队构建、维护或认可。
91
+
92
+ ### 许可证
93
+
94
+ [MIT](LICENSE)
package/ROADMAP.md CHANGED
@@ -59,6 +59,7 @@ TTFT 2.7s
59
59
  目标:提供可查询的价格目录,便于用户了解不同模型的计费档位。
60
60
 
61
61
  * [ ] 构建公开价格目录,包含模型、价格档位及阈值
62
+ * [ ] 只收录半年内发布的模型
62
63
  <!-- TODO -->
63
64
 
64
65
  ## v0.4.0 — 用量命令
@@ -1,4 +1,5 @@
1
1
  import type { UsageSource } from "./source.js";
2
+ import { PerformanceMonitor } from "./performance.js";
2
3
  import type { PerformanceSummary } from "./performance.js";
3
4
  import type { ContextUsage, Summary } from "./usage.js";
4
5
  export interface UsageState {
@@ -30,11 +31,14 @@ export declare class UsageController {
30
31
  private timer;
31
32
  private retry;
32
33
  private performanceTimer;
34
+ private sessions;
33
35
  private pending;
34
36
  private disposed;
35
37
  private unsubscribe;
38
+ private readonly unsubscribePerformance;
36
39
  private readonly performance;
37
- constructor(source: UsageSource, subscribe: Subscribe, publish: (state: UsageState) => void, delay?: number, retryDelay?: number, performanceDelay?: number);
40
+ private readonly ownsPerformance;
41
+ constructor(source: UsageSource, subscribe: Subscribe, publish: (state: UsageState) => void, delay?: number, retryDelay?: number, performanceDelay?: number, performance?: PerformanceMonitor);
38
42
  select(sessionID: string): void;
39
43
  refresh(): void;
40
44
  dispose(): void;
@@ -1,10 +1,6 @@
1
1
  import { loadSnapshot, uniqueMessages, viewedMessages } from "./source.js";
2
- import { PerformanceTracker } from "./performance.js";
2
+ import { PerformanceMonitor } from "./performance.js";
3
3
  import { contextUsage, summarize } from "./usage.js";
4
- const performanceEvents = new Set([
5
- "session.step.started", "session.step.streamed", "session.step.ended", "session.step.failed",
6
- "session.text.delta", "session.reasoning.delta", "session.tool.input.delta",
7
- ]);
8
4
  const sessionEvents = new Set([
9
5
  "session.created", "session.deleted", "session.forked", "session.moved",
10
6
  "session.model.selected", "session.agent.selected", "session.step.ended", "session.step.failed",
@@ -31,22 +27,32 @@ export class UsageController {
31
27
  timer;
32
28
  retry;
33
29
  performanceTimer;
30
+ sessions = new Set();
34
31
  pending = false;
35
32
  disposed = false;
36
33
  unsubscribe;
37
- performance = new PerformanceTracker();
38
- constructor(source, subscribe, publish, delay = 80, retryDelay = 3_000, performanceDelay = 100) {
34
+ unsubscribePerformance;
35
+ performance;
36
+ ownsPerformance;
37
+ constructor(source, subscribe, publish, delay = 80, retryDelay = 3_000, performanceDelay = 100, performance) {
39
38
  this.source = source;
40
39
  this.publish = publish;
41
40
  this.delay = delay;
42
41
  this.retryDelay = retryDelay;
43
42
  this.performanceDelay = performanceDelay;
43
+ this.ownsPerformance = performance === undefined;
44
+ this.performance = performance ?? new PerformanceMonitor(subscribe);
45
+ this.unsubscribePerformance = this.performance.listen(sessionID => {
46
+ if (this.sessions.has(sessionID))
47
+ this.refreshPerformance();
48
+ });
44
49
  this.unsubscribe = subscribe(event => {
45
50
  const data = (event.data && typeof event.data === "object" ? event.data : {});
46
- if (event.type === "session.created")
47
- this.performance.addSession(data.sessionID, data.parentID);
48
- if (performanceEvents.has(event.type) && this.performance.handle(event))
49
- this.refreshPerformance();
51
+ if (event.type === "session.created" && data.sessionID && data.parentID && this.sessions.has(data.parentID)) {
52
+ this.sessions.add(data.sessionID);
53
+ }
54
+ if (event.type === "session.deleted" && data.sessionID)
55
+ this.sessions.delete(data.sessionID);
50
56
  if (globalEvents.has(event.type))
51
57
  return this.refresh();
52
58
  if (!sessionEvents.has(event.type))
@@ -69,6 +75,7 @@ export class UsageController {
69
75
  this.performanceTimer = undefined;
70
76
  this.pending = false;
71
77
  this.snapshot = undefined;
78
+ this.sessions.clear();
72
79
  this.sessionID = sessionID;
73
80
  this.update({ status: "loading" });
74
81
  void this.run();
@@ -95,7 +102,10 @@ export class UsageController {
95
102
  clearTimeout(this.timer);
96
103
  clearTimeout(this.retry);
97
104
  clearTimeout(this.performanceTimer);
105
+ this.unsubscribePerformance();
98
106
  this.unsubscribe();
107
+ if (this.ownsPerformance)
108
+ this.performance.dispose();
99
109
  }
100
110
  update(state) { this.state = state; this.publish(state); }
101
111
  refreshPerformance() {
@@ -105,7 +115,7 @@ export class UsageController {
105
115
  this.performanceTimer = undefined;
106
116
  if (this.disposed || !this.snapshot || !this.state.summary)
107
117
  return;
108
- this.update({ ...this.state, performance: this.performance.summary(uniqueMessages(this.snapshot)) });
118
+ this.update({ ...this.state, performance: this.performance.summary(uniqueMessages(this.snapshot), this.sessions) });
109
119
  }, this.performanceDelay);
110
120
  }
111
121
  async run() {
@@ -121,15 +131,15 @@ export class UsageController {
121
131
  return;
122
132
  this.snapshot = snapshot;
123
133
  const messages = [...uniqueMessages(snapshot)];
124
- this.performance.setTree(snapshot.rootID, snapshot.sessions.keys());
125
- this.performance.reconcile(messages);
134
+ this.sessions = new Set(snapshot.sessions.keys());
135
+ this.performance.reconcile(messages, this.sessions);
126
136
  clearTimeout(this.performanceTimer);
127
137
  this.performanceTimer = undefined;
128
138
  this.update({
129
139
  status: "ready",
130
140
  summary: summarize(messages, snapshot.model.prices),
131
141
  context: contextUsage(viewedMessages(snapshot), snapshot.model.context),
132
- performance: this.performance.summary(messages),
142
+ performance: this.performance.summary(messages, this.sessions),
133
143
  model: snapshot.model.label,
134
144
  });
135
145
  }
@@ -17,17 +17,29 @@ export interface PerformanceEvent {
17
17
  id?: string;
18
18
  created?: number;
19
19
  }
20
+ export type PerformanceSubscribe = (listener: (event: PerformanceEvent) => void) => () => void;
21
+ export type PerformanceListener = (sessionID: string) => void;
20
22
  /** Exact completed-run TPS and the average of only measurable TTFT samples. */
21
23
  export declare function historicalPerformance(messages: Iterable<UsageMessage>, observations?: Iterable<FirstTokenObservation>): PerformanceSummary;
22
- /** Tracks ephemeral streaming events without fetching the full session tree. */
23
- export declare class PerformanceTracker {
24
- private rootID;
25
- private sessions;
24
+ /** Captures streaming performance for every session for one plugin lifetime. */
25
+ export declare class PerformanceMonitor {
26
+ private readonly retention;
27
+ private readonly now;
26
28
  private active;
27
29
  private observed;
28
- setTree(rootID: string, sessionIDs: Iterable<string>): void;
29
- addSession(sessionID: string | undefined, parentID: string | undefined): void;
30
+ private listeners;
31
+ private cleanupTimer;
32
+ private disposed;
33
+ private readonly unsubscribe;
34
+ constructor(subscribe?: PerformanceSubscribe, retention?: number, now?: () => number);
35
+ listen(listener: PerformanceListener): () => void;
36
+ dispose(): void;
37
+ private key;
38
+ private publish;
39
+ private deleteSession;
40
+ private prune;
41
+ private scheduleCleanup;
30
42
  handle(event: PerformanceEvent): boolean;
31
- reconcile(messages: Iterable<UsageMessage>): void;
32
- summary(messages: Iterable<UsageMessage>): PerformanceSummary;
43
+ reconcile(messages: Iterable<UsageMessage>, sessionIDs: Iterable<string>): void;
44
+ summary(messages: Iterable<UsageMessage>, sessionIDs: Iterable<string>): PerformanceSummary;
33
45
  }
@@ -68,56 +68,134 @@ export function historicalPerformance(messages, observations = []) {
68
68
  result.ttft = ttft / ttftSamples;
69
69
  return result;
70
70
  }
71
- /** Tracks ephemeral streaming events without fetching the full session tree. */
72
- export class PerformanceTracker {
73
- rootID;
74
- sessions = new Set();
71
+ /** Captures streaming performance for every session for one plugin lifetime. */
72
+ export class PerformanceMonitor {
73
+ retention;
74
+ now;
75
75
  active = new Map();
76
76
  observed = new Map();
77
- setTree(rootID, sessionIDs) {
78
- if (this.rootID !== rootID) {
79
- this.active.clear();
80
- this.observed.clear();
81
- this.rootID = rootID;
82
- }
83
- this.sessions = new Set(sessionIDs);
84
- for (const [id, step] of this.active)
85
- if (!this.sessions.has(step.sessionID))
86
- this.active.delete(id);
87
- for (const [id, sample] of this.observed)
88
- if (!this.sessions.has(sample.sessionID))
89
- this.observed.delete(id);
77
+ listeners = new Set();
78
+ cleanupTimer;
79
+ disposed = false;
80
+ unsubscribe;
81
+ constructor(subscribe, retention = 60_000, now = Date.now) {
82
+ this.retention = retention;
83
+ this.now = now;
84
+ this.unsubscribe = subscribe?.(event => this.handle(event));
90
85
  }
91
- addSession(sessionID, parentID) {
92
- if (sessionID && parentID && this.sessions.has(parentID))
93
- this.sessions.add(sessionID);
86
+ listen(listener) {
87
+ if (this.disposed)
88
+ return () => { };
89
+ this.listeners.add(listener);
90
+ return () => { this.listeners.delete(listener); };
91
+ }
92
+ dispose() {
93
+ if (this.disposed)
94
+ return;
95
+ this.disposed = true;
96
+ this.unsubscribe?.();
97
+ clearTimeout(this.cleanupTimer);
98
+ this.cleanupTimer = undefined;
99
+ this.listeners.clear();
100
+ this.active.clear();
101
+ this.observed.clear();
102
+ }
103
+ key(sessionID, messageID) { return `${sessionID}\0${messageID}`; }
104
+ publish(sessionID) {
105
+ for (const listener of this.listeners)
106
+ listener(sessionID);
107
+ }
108
+ deleteSession(sessionID) {
109
+ for (const [key, step] of this.active)
110
+ if (step.sessionID === sessionID)
111
+ this.active.delete(key);
112
+ for (const [key, sample] of this.observed)
113
+ if (sample.sessionID === sessionID)
114
+ this.observed.delete(key);
115
+ }
116
+ prune() {
117
+ const now = this.now();
118
+ const changed = new Set();
119
+ for (const [key, step] of this.active) {
120
+ if (step.finishedAt !== undefined && step.finishedAt + this.retention <= now) {
121
+ this.active.delete(key);
122
+ changed.add(step.sessionID);
123
+ }
124
+ }
125
+ for (const [key, sample] of this.observed) {
126
+ if (sample.expiresAt !== undefined && sample.expiresAt <= now) {
127
+ this.observed.delete(key);
128
+ changed.add(sample.sessionID);
129
+ }
130
+ }
131
+ return changed;
132
+ }
133
+ scheduleCleanup() {
134
+ clearTimeout(this.cleanupTimer);
135
+ if (this.disposed || !Number.isFinite(this.retention) || this.retention < 0)
136
+ return;
137
+ let next = Infinity;
138
+ for (const step of this.active.values()) {
139
+ if (step.finishedAt !== undefined)
140
+ next = Math.min(next, step.finishedAt + this.retention);
141
+ }
142
+ for (const sample of this.observed.values()) {
143
+ if (sample.expiresAt !== undefined)
144
+ next = Math.min(next, sample.expiresAt);
145
+ }
146
+ if (!Number.isFinite(next))
147
+ return;
148
+ this.cleanupTimer = setTimeout(() => {
149
+ this.cleanupTimer = undefined;
150
+ for (const sessionID of this.prune())
151
+ this.publish(sessionID);
152
+ this.scheduleCleanup();
153
+ }, Math.max(0, next - this.now()));
154
+ this.cleanupTimer.unref?.();
94
155
  }
95
156
  handle(event) {
157
+ if (this.disposed)
158
+ return false;
159
+ for (const sessionID of this.prune())
160
+ this.publish(sessionID);
96
161
  const data = (event.data && typeof event.data === "object" ? event.data : {});
97
162
  const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined;
163
+ if (event.type === "session.deleted") {
164
+ if (sessionID)
165
+ this.deleteSession(sessionID);
166
+ return false;
167
+ }
98
168
  const messageID = typeof data.assistantMessageID === "string" ? data.assistantMessageID : undefined;
99
- if (!sessionID || !messageID || !this.sessions.has(sessionID))
169
+ if (!sessionID || !messageID)
100
170
  return false;
171
+ const key = this.key(sessionID, messageID);
101
172
  if (event.type === "session.step.started") {
102
173
  if (!finite(data.started))
103
174
  return false;
104
- if (this.active.has(messageID))
175
+ if (this.active.has(key))
105
176
  return false;
106
- this.active.set(messageID, {
177
+ this.active.set(key, {
107
178
  messageID, sessionID, started: data.started, bytes: 0, events: new Set(),
108
179
  });
109
180
  return false;
110
181
  }
111
- const step = this.active.get(messageID);
182
+ const step = this.active.get(key);
112
183
  if (!step)
113
184
  return false;
114
185
  if (event.type === "session.step.streamed") {
115
186
  if (!finite(event.created) || event.created < step.started)
116
187
  return false;
117
188
  step.streamed = event.created;
189
+ if (step.bytes > 0)
190
+ this.publish(sessionID);
118
191
  return step.bytes > 0;
119
192
  }
120
193
  if (event.type === "session.step.ended" || event.type === "session.step.failed") {
194
+ step.finishedAt = this.now();
195
+ const sample = this.observed.get(key);
196
+ if (sample)
197
+ sample.expiresAt = step.finishedAt + this.retention;
198
+ this.scheduleCleanup();
121
199
  return false;
122
200
  }
123
201
  if (event.type !== "session.text.delta" && event.type !== "session.reasoning.delta"
@@ -133,29 +211,33 @@ export class PerformanceTracker {
133
211
  step.last = Math.max(step.last ?? event.created, event.created);
134
212
  if (step.first === undefined) {
135
213
  step.first = event.created;
136
- this.observed.set(messageID, { messageID, sessionID, started: step.started, first: event.created });
214
+ this.observed.set(key, { messageID, sessionID, started: step.started, first: event.created });
137
215
  }
216
+ this.publish(sessionID);
138
217
  return true;
139
218
  }
140
- reconcile(messages) {
219
+ reconcile(messages, sessionIDs) {
220
+ const sessions = new Set(sessionIDs);
141
221
  const represented = new Set();
142
222
  for (const message of messages) {
143
223
  if (message.type === "assistant" && (message.tokens || message.time?.streamed !== undefined
144
224
  || message.time?.completed !== undefined))
145
225
  represented.add(message.id);
146
226
  }
147
- for (const [id, step] of this.active) {
148
- if (represented.has(id))
149
- this.active.delete(id);
227
+ for (const [key, step] of this.active) {
228
+ if (sessions.has(step.sessionID) && represented.has(step.messageID)
229
+ && (step.finishedAt !== undefined || step.streamed !== undefined))
230
+ this.active.delete(key);
150
231
  }
151
232
  }
152
- summary(messages) {
153
- const samples = [...this.observed.values()].filter(sample => this.sessions.has(sample.sessionID));
233
+ summary(messages, sessionIDs) {
234
+ const sessions = new Set(sessionIDs);
235
+ const samples = [...this.observed.values()].filter(sample => sessions.has(sample.sessionID));
154
236
  const result = historicalPerformance(messages, samples);
155
237
  let bytes = 0;
156
238
  let duration = 0;
157
239
  for (const step of this.active.values()) {
158
- if (!this.sessions.has(step.sessionID) || step.bytes <= 0)
240
+ if (!sessions.has(step.sessionID) || step.bytes <= 0)
159
241
  continue;
160
242
  const last = step.streamed ?? step.last;
161
243
  if (!finite(last) || last <= step.started)
package/dist/tui.js CHANGED
@@ -8,6 +8,7 @@ import { createElement as _$createElement } from "@opentui/solid";
8
8
  import { Plugin } from "@opencode/plugin/tui";
9
9
  import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
10
10
  import { UsageController } from "./controller.js";
11
+ import { PerformanceMonitor } from "./performance.js";
11
12
  import { createSource } from "./source.js";
12
13
  import { usageRows } from "./usage.js";
13
14
  function UsagePanel(props) {
@@ -16,7 +17,7 @@ function UsagePanel(props) {
16
17
  });
17
18
  const controller = new UsageController(createSource(props.context.client), listener => props.context.data.listen(({
18
19
  details
19
- }) => listener(details)), setState);
20
+ }) => listener(details)), setState, 80, 3_000, 100, props.performance);
20
21
  const unregister = props.register(controller);
21
22
  createEffect(() => controller.select(props.sessionID));
22
23
  onCleanup(() => {
@@ -89,6 +90,9 @@ function UsagePanel(props) {
89
90
  export default Plugin.define({
90
91
  id: "opencode-token-usage.tui",
91
92
  setup(context) {
93
+ const performance = new PerformanceMonitor(listener => context.data.listen(({
94
+ details
95
+ }) => listener(details)));
92
96
  const controllers = new Set();
93
97
  const register = controller => {
94
98
  controllers.add(controller);
@@ -103,6 +107,7 @@ export default Plugin.define({
103
107
  get sessionID() {
104
108
  return props.sessionID;
105
109
  },
110
+ performance: performance,
106
111
  register: register
107
112
  })
108
113
  });
@@ -130,6 +135,7 @@ export default Plugin.define({
130
135
  get sessionID() {
131
136
  return props.sessionID;
132
137
  },
138
+ performance: performance,
133
139
  register: register
134
140
  }));
135
141
  return _el$9;
@@ -141,6 +147,7 @@ export default Plugin.define({
141
147
  controllers.clear();
142
148
  remove();
143
149
  removeChild();
150
+ performance.dispose();
144
151
  };
145
152
  }
146
153
  });
@@ -1,5 +1,30 @@
1
1
  # 验证记录
2
2
 
3
+ ## 未发布(SDK 2.0.11)
4
+
5
+ 验证日期:2026-09-22。环境:macOS、Node.js v22.23.2、npm 10.9.8、OpenCode v2.0.11;`@opencode/plugin`、`@opencode/client`、`@opencode/schema` 和 `@opencode/theme` 均精确锁定为 2.0.11。
6
+
7
+ ### 自动化检查
8
+
9
+ | 检查 | 结果 |
10
+ | --- | --- |
11
+ | `npm run typecheck` | 通过 |
12
+ | `npm test` | 31 项通过 |
13
+ | `npm run build` | 通过,输出 ESM 与类型声明 |
14
+ | `npm run test:smoke` | 通过,使用实际 `.tgz` 安装产物和真实 OpenCode 2.0.11 |
15
+
16
+ SDK 从 2.0.9 升级到 2.0.11 后,四个顶层 OpenCode 包及锁文件中的传递依赖均已统一到 2.0.11。`@opencode/plugin` 两版的导出结构一致;包内容差异除依赖版本外,仅为 `ToastOptions` 新增可选的 `sessionID` 类型字段。
17
+
18
+ 新增共享性能监视器测试覆盖:查看会话 A 时捕获独立会话 B 的流,切换到 B 后立即恢复实时 TPS/TTFT;生成中切走、继续接收 delta 再切回;控制器销毁重建后恢复共享流;不同会话树隔离、新子代理即时纳入,以及删除会话清理运行期状态。既有重复事件去重与完成后收敛为精确 TPS 的断言继续通过。
19
+
20
+ ### 真实 OpenCode 集成
21
+
22
+ 隔离 smoke 测试确认打包插件可加载;空会话隐藏无数据行;在独立会话已经开始慢速流后,同一 TUI 切换过去会立即显示 `TPS ~…` 与 TTFT,完成后变为无 `~` 的精确 TPS;既有 token、上下文与费用刷新、真实子代理全树累计、会话局部上下文及模型切换验证均继续通过。测试产物保存在 `/var/folders/rw/bmx6c8hd737brl55m0_ff_b80000gn/T/token-usage-smoke-cgsCK2`。
23
+
24
+ ### 验证边界
25
+
26
+ 当前 SDK 2.0.11 与宿主 2.0.11 的组合已完整验证。验证时机器默认宿主已是 2.0.12,因此 smoke 使用校验完整性的官方 `@opencode/cli-darwin-arm64@2.0.11` 隔离二进制,没有放宽宿主版本断言。2.0.9 和 2.0.10 宿主的历史验证记录保留在下方,但升级 SDK 后未重新执行这两个旧宿主的打包矩阵。后续升级 SDK 或宿主时,仍需重新核对分叉副本、compaction 排序及 step/delta 事件语义。
27
+
3
28
  ## v0.2.1(TPS 与 TTFT)
4
29
 
5
30
  验证日期:2026-09-20。环境:macOS、Node.js v22.23.2、npm 10.9.8、OpenCode v2.0.11;插件 SDK 依赖为 2.0.9。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenlongapps/opencode-token-usage",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Session-tree token usage, live performance, context window and estimated cost for OpenCode 2",
5
5
  "keywords": [
6
6
  "opencode",
@@ -57,7 +57,7 @@
57
57
  "registry": "https://registry.npmjs.org/"
58
58
  },
59
59
  "dependencies": {
60
- "@opencode/plugin": "2.0.9"
60
+ "@opencode/plugin": "2.0.11"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "@opentui/core": "0.5.10",
@@ -66,9 +66,9 @@
66
66
  },
67
67
  "devDependencies": {
68
68
  "@babel/core": "^7.28.0",
69
- "@opencode/client": "2.0.9",
70
- "@opencode/schema": "2.0.9",
71
- "@opencode/theme": "2.0.9",
69
+ "@opencode/client": "2.0.11",
70
+ "@opencode/schema": "2.0.11",
71
+ "@opencode/theme": "2.0.11",
72
72
  "@opentui/core": "0.5.10",
73
73
  "@opentui/solid": "0.5.10",
74
74
  "@types/node": "^22.0.0",