opencode-cache-hit 0.2.1 → 0.2.2

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/AGENTS.md CHANGED
@@ -16,6 +16,7 @@ OpenCode TUI sidebar plugin: **cache hit rate**, **tokens**, **cost**, with **su
16
16
  | Architecture | [docs/en/design.md](docs/en/design.md) | [docs/zh-CN/design.md](docs/zh-CN/design.md) |
17
17
  | Timeline / JSONL | [docs/en/timeline.md](docs/en/timeline.md) | [docs/zh-CN/timeline.md](docs/zh-CN/timeline.md) |
18
18
  | TUI panel | [src/tui-panel/README.md](src/tui-panel/README.md) | [src/tui-panel/README.zh-CN.md](src/tui-panel/README.zh-CN.md) |
19
+ | Migration plan | [docs/en/frontend-migration-plan.md](docs/en/frontend-migration-plan.md) | [docs/zh-CN/frontend-migration-plan.md](docs/zh-CN/frontend-migration-plan.md) |
19
20
  | Contributing / npm | [CONTRIBUTING.md](CONTRIBUTING.md) | — |
20
21
  | Index | [docs/README.md](docs/README.md) | |
21
22
 
package/docs/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Documentation
2
2
 
3
- | | User guide | Design | Timeline | TUI panel |
4
- |--|------------|--------|----------|-----------|
5
- | English | [README.md](../README.md) | [en/design.md](./en/design.md) | [en/timeline.md](./en/timeline.md) | [../src/tui-panel/README.md](../src/tui-panel/README.md) |
6
- | 中文 | [README.zh-CN.md](../README.zh-CN.md) | [zh-CN/design.md](./zh-CN/design.md) | [zh-CN/timeline.md](./zh-CN/timeline.md) | [../src/tui-panel/README.zh-CN.md](../src/tui-panel/README.zh-CN.md) |
3
+ | | User guide | Design | Timeline | TUI panel | Migration |
4
+ |--|------------|--------|----------|-----------|-----------|
5
+ | English | [README.md](../README.md) | [en/design.md](./en/design.md) | [en/timeline.md](./en/timeline.md) | [../src/tui-panel/README.md](../src/tui-panel/README.md) | [en/frontend-migration-plan.md](./en/frontend-migration-plan.md) |
6
+ | 中文 | [README.zh-CN.md](../README.zh-CN.md) | [zh-CN/design.md](./zh-CN/design.md) | [zh-CN/timeline.md](./zh-CN/timeline.md) | [../src/tui-panel/README.zh-CN.md](../src/tui-panel/README.zh-CN.md) | [zh-CN/frontend-migration-plan.md](./zh-CN/frontend-migration-plan.md) |
7
7
 
8
8
  Contributing: [CONTRIBUTING.md](../CONTRIBUTING.md).
package/docs/en/design.md CHANGED
@@ -59,7 +59,8 @@ sequenceDiagram
59
59
  Host->>API: session.list → childIds
60
60
  API-->>Host: message.updated { info: Message }
61
61
  Host->>Host: refreshTick++, timeline.handleMessage(info)
62
- Host->>API: session.messages(sid / cid)
62
+ Host->>API: session.get(sid / cid) → aggregates
63
+ Host->>API: session.messages(sid / cid) → fallback / trend
63
64
  Host->>W: main, messages, subAgents
64
65
  W->>W: aggregate / format / TuiPanel
65
66
  ```
@@ -113,14 +114,14 @@ flowchart TD
113
114
  F -->|no| R
114
115
  C --> H[childSessionIdsForParent]
115
116
  C2 --> H
116
- H --> I[re-read messages + aggregate]
117
+ H --> I["session.get aggregate + messages fallback"]
117
118
  ```
118
119
 
119
120
  - **Single source of truth**: `childIds` always **replaced** from `session.list` (no `session.get` append).
120
121
  - **Races**: `listGen` increments on parent change; callbacks check generation and `parentId`.
121
122
  - **Streaming**: foreign-session `message.updated` is debounced (`CHILD_LIST_DEBOUNCE_MS` = 200ms).
122
123
  - **Depth**: direct children only (`parentID === sid`); nested sub-agents are future work.
123
- - **Data**: `messages(cid)` → `aggregateSessionFromMessages`; entries without stats are filtered out.
124
+ - **Data**: `session.get(cid)` → `aggregateFromSessionObject` (primary); falls back to `messages(cid)` → `aggregateSessionFromMessages`. Model/provider metadata is patched from messages when missing from session aggregates (`withModelFallback`).
124
125
 
125
126
  ### Agents block semantics
126
127
 
@@ -154,12 +155,12 @@ Implementation: `agents-view.tsx` calls `formatSubAgentLabel` + `modelRowColor`;
154
155
 
155
156
  | Data | Trigger |
156
157
  |------|---------|
157
- | Main snapshot | `createMemo` reads `refreshTick` + `messages(sid)` |
158
- | Main messages (Hit trend) | Same |
159
- | Sub-agent content | `childIds` or `refreshTick` → re-read each `messages(cid)` |
158
+ | Main snapshot | `createMemo` reads `refreshTick` + `session.get(sid)` (aggregate); fallback `messages(sid)` |
159
+ | Main messages (Hit trend) | `createMemo` reads `refreshTick` + `messages(sid)` |
160
+ | Sub-agent content | `refreshTick` + `childIds` → `session.get(cid)` per child; fallback `messages(cid)` |
160
161
  | Sub-agent ids | `session.list` callback / debounced refresh on foreign activity |
161
162
 
162
- `sidebar-host` subscribes to `message.updated` and bumps `refreshTick` so streaming token updates always recompute.
163
+ `sidebar-host` subscribes to `message.updated` and bumps `refreshTick` so dependent memos recompute; session totals follow `session.get()` aggregates, while per-turn trend follows recent messages.
163
164
 
164
165
  ### Accumulation rules
165
166
 
@@ -0,0 +1,100 @@
1
+ # Frontend Migration: session.get() Aggregates
2
+
3
+ ## Overview
4
+
5
+ Cache-hit previously derived all cost/token statistics by iterating `api.state.session.messages()` and summing per-message fields. This path is capped at **100 messages** by the OpenCode TUI sync layer ([issue #31513]), silently truncating sessions with >100 messages.
6
+
7
+ **Impact**: For a session exceeding 100 messages, the cap causes significant data loss. In a real long-running session, the plugin showed only ~43% of actual cache-read tokens and ~39% of actual cost.
8
+
9
+ | Metric | Old (messages, 100-cap) | New (session.get, no cap) |
10
+ |--------|------------------------|--------------------------|
11
+ | Cache read | 59.6M tok | **139.0M tok** |
12
+ | Cache write | 89.8K tok | **1.4M tok** |
13
+ | Miss (input) | 118 tok | **329 tok** |
14
+ | Output | 32.7K tok | **91.9K tok** |
15
+ | Sub-agents visible | 3 | **10** |
16
+
17
+ The fix swaps the data source from per-message iteration to `api.state.session.get()`, which returns **database-level aggregates** computed from all messages by the session projector — not affected by the 100-message cap.
18
+
19
+ ## Root Cause
20
+
21
+ OpenCode's TUI sync (`packages/opencode/src/cli/cmd/tui/context/sync.tsx`) hardcodes `limit: 100` when fetching session messages:
22
+
23
+ ```typescript
24
+ // Line 559 — fetches at most 100 messages
25
+ sdk.client.session.messages({ sessionID, limit: 100 }),
26
+
27
+ // Lines 580-581 — TUI store also keeps only last 100
28
+ const visible = infos.slice(-100)
29
+ ```
30
+
31
+ `api.state.session.messages(sid)` reads from `sync.data.message[sid]`, which holds at most 100 entries. For sessions exceeding this, plugins see incomplete data with no indication of truncation.
32
+
33
+ Meanwhile, the `session` table in OpenCode's SQLite database stores **pre-aggregated** columns (`cost`, `tokens_input`, `tokens_output`, `tokens_cache_read`, `tokens_cache_write`) updated via SQL increments by the session projector — every message, no cap.
34
+
35
+ `api.state.session.get(sid)` returns the full `Session` object (SDK type `Session`), which includes `cost` and `tokens` fields populated from these database aggregates.
36
+
37
+ ## Solution
38
+
39
+ ### Design
40
+
41
+ ```
42
+ Before:
43
+ api.state.session.messages(sid) → iterate 100 msgs → sum ❌ capped
44
+
45
+ After:
46
+ api.state.session.get(sid) → read cost/tokens directly → done ✅ full
47
+ └─ session not available? → fallback to messages (compat)
48
+ ```
49
+
50
+ ### Changes
51
+
52
+ | File | Change |
53
+ |------|--------|
54
+ | `src/types.ts` | New `SessionObject` type (`model`, `cost`, `tokens`, `parentID`); updated `session.get` return type |
55
+ | `src/stats.ts` | New `aggregateFromSessionObject()` — O(1) extraction from `SessionObject` → `SessionSnapshot` |
56
+ | `src/sidebar-host.tsx` | `mainSnap` and `subAgentList` preferred `session.get()` with fallback to message iteration |
57
+ | `tests/stats.test.ts` | 3 test cases covering empty, full, and partial session objects |
58
+
59
+ ### Data Flow
60
+
61
+ 1. **`mainSnap`**: Calls `api.state.session.get(sid)` → `aggregateFromSessionObject()` → returns snapshot if stats present. If session unavailable or empty, falls back to `aggregateSessionFromMessages()`.
62
+
63
+ 2. **`subAgentList`**: Same pattern per child session ID.
64
+
65
+ 3. **Per-call trend** (`computePerCallHitTrend`): Still uses `api.state.session.messages()` — trend only needs the last few turns, so 100 messages is sufficient.
66
+
67
+ ### Why session.get() works for resumed sessions
68
+
69
+ When resuming a session, OpenCode's TUI `sync()` immediately calls `sdk.client.session.get({ sessionID })` which returns the full `Session` object with database-level aggregates. This object is stored in the TUI state store, and `api.state.session.get(sid)` reads it. The plugin sees accurate totals from the moment the session loads.
70
+
71
+ ### Fallback rationale
72
+
73
+ `session.get()` returns `undefined` for sessions that have never been synced to the TUI state (rare, but possible for child sessions loaded in a different context). The fallback to `api.state.session.messages()` preserves existing behavior as a safety net.
74
+
75
+ ## Performance
76
+
77
+ | Metric | Old (message iteration) | New (session.get) |
78
+ |--------|------------------------|-------------------|
79
+ | Time complexity | O(n) where n ≤ 100 | O(1) |
80
+ | Memory | Reads entire message array | Reads one small object |
81
+ | Reactivity trigger | `message.updated` → recompute | Same event, same timing |
82
+ | Data completeness | ≤ 100 messages | All messages (DB aggregate) |
83
+
84
+ ## Rollout
85
+
86
+ 1. **No config changes required** — the plugin automatically uses `session.get()` when available.
87
+ 2. **No migration step** for existing users — old config files and timeline logs are unaffected.
88
+ 3. **Restart OpenCode** to pick up the updated plugin code.
89
+ 4. **Verify**: Open a session with >100 messages; the sidebar should show totals matching database aggregates.
90
+
91
+ ## Related
92
+
93
+ - [anomalyco/opencode#31513] — v1 TUI sync hardcodes limit:100
94
+ - [anomalyco/opencode#26861] — PR adding cursor-based pagination (pending)
95
+ - [anomalyco/opencode#6548] — Paginated message loading feature request
96
+
97
+ [issue #31513]: https://github.com/anomalyco/opencode/issues/31513
98
+ [anomalyco/opencode#31513]: https://github.com/anomalyco/opencode/issues/31513
99
+ [anomalyco/opencode#26861]: https://github.com/anomalyco/opencode/pull/26861
100
+ [anomalyco/opencode#6548]: https://github.com/anomalyco/opencode/issues/6548
@@ -12,10 +12,10 @@ The OpenCode TUI plugin API has two data access patterns:
12
12
 
13
13
  | API | Behavior |
14
14
  |-----|----------|
15
- | `api.state.session.messages(sessionId)` | Returns **all** messages no cursor, `since`, or limit |
15
+ | `api.state.session.messages(sessionId)` | Returns up to 100 messages (capped by TUI sync); no cursor, `since`, or limit parameter on the plugin side |
16
16
  | `api.event.on("message.updated", handler)` | Fires per-message, carries the **full `Message` object** (cost, tokens, time, modelID, providerID) — see [`types.gen.ts` L1064](https://github.com/anomalyco/opencode/blob/dev/packages/sdk/js/src/v2/gen/types.gen.ts#L1064) |
17
17
 
18
- The original collector used the **polling path**: subscribe to `message.updated` → call `schedule()` (500ms debounce) → `collectNow()` → `getMessages()` (full history) → `buildCallRecords()` (all messages) → `shouldFlushToDisk()` (dedup filter). This required a `flushedKeys` Set to skip already-written records.
18
+ The original collector used the **polling path**: subscribe to `message.updated` → call `schedule()` (500ms debounce) → `collectNow()` → `getMessages()` (TUI-synced window, up to 100 messages) → `buildCallRecords()` (all returned messages) → `shouldFlushToDisk()` (dedup filter). This required a `flushedKeys` Set to skip already-written records.
19
19
 
20
20
  On restart, `flushedKeys` was lost (it lived in memory only). Every message in the session would be re-flushed to JSONL. A startup scan of all JSONL files was proposed to rebuild the set, adding complexity without addressing the core issue: **polling is the wrong pattern for a write-only log**.
21
21
 
@@ -59,7 +59,8 @@ sequenceDiagram
59
59
  Host->>API: session.list → childIds
60
60
  API-->>Host: message.updated { info: Message }
61
61
  Host->>Host: refreshTick++, timeline.handleMessage(info)
62
- Host->>API: session.messages(sid / cid)
62
+ Host->>API: session.get(sid / cid) → 聚合值
63
+ Host->>API: session.messages(sid / cid) → fallback / 趋势
63
64
  Host->>W: main, messages, subAgents
64
65
  W->>W: aggregate / format / TuiPanel
65
66
  ```
@@ -113,14 +114,14 @@ flowchart TD
113
114
  F -->|否| R
114
115
  C --> H[childSessionIdsForParent]
115
116
  C2 --> H
116
- H --> I[messages 重读 + 聚合]
117
+ H --> I["session.get 聚合 + messages 兜底"]
117
118
  ```
118
119
 
119
120
  - **唯一来源**:`childIds` 始终由 `session.list` 结果**覆盖**写入,不再 `session.get` + 追加(避免漏发现、僵尸 id)。
120
121
  - **竞态**:`listGen` 在 parent 切换时递增;回调校验 generation 与 `parentId` 未变。
121
122
  - **流式**:外国 session 的 `message.updated` 很密,用 `CHILD_LIST_DEBOUNCE_MS`(200ms)合并 list 请求。
122
123
  - **一层子 session**:`parentID === sid` 的直接子节点;嵌套子 agent 见「未来方向」。
123
- - **子 session 数据**:对每个 `cid` 调用 `messages(cid)` → `aggregateSessionFromMessages`;无统计的条目过滤掉。
124
+ - **子 session 数据**:`session.get(cid)` `aggregateFromSessionObject`(主路径);不可用时降级到 `messages(cid)` → `aggregateSessionFromMessages`。若 session 聚合缺少 model/provider 元数据,从 messages 末尾补齐(`withModelFallback`)。
124
125
 
125
126
  ### Agents 合计语义(实现正确,勿与「全场总账」混淆)
126
127
 
@@ -154,12 +155,12 @@ flowchart TD
154
155
 
155
156
  | 数据 | 触发方式 |
156
157
  |------|----------|
157
- | 主 session snapshot | `createMemo` 内读 `refreshTick` + `api.state.session.messages(sid)` |
158
- | 主 session 消息列表(Hit 趋势) | 同上 |
159
- | 子 agent 列表内容 | `childIds` 变化或 `refreshTick` 后各 `messages(cid)` 重读 |
158
+ | 主 session snapshot | `createMemo` 内读 `refreshTick` + `session.get(sid)`(聚合);fallback `messages(sid)` |
159
+ | 主 session 消息列表(Hit 趋势) | `createMemo` 内读 `refreshTick` + `messages(sid)` |
160
+ | 子 agent 列表内容 | `refreshTick` + `childIds` 每个 child 的 `session.get(cid)`;fallback `messages(cid)` |
160
161
  | 子 agent id 集合 | `session.list` 完成回调 / `message.updated` 发现新 child |
161
162
 
162
- 主 session **显式**订阅 `message.updated`(在 `sidebar-host`):每次事件 `refreshTick++`,保证流式过程中 `tokens` 更新会重算(不仅依赖 store 是否自动触发 Solid memo)。
163
+ 主 session **显式**订阅 `message.updated`(在 `sidebar-host`):每次事件 `refreshTick++`,触发相关 memo 重算;会话总量以 `session.get()` 聚合为准,逐轮趋势使用最近 messages。
163
164
 
164
165
  ### 累加规则
165
166
 
@@ -0,0 +1,100 @@
1
+ # 前端迁移方案:session.get() 聚合数据
2
+
3
+ ## 概述
4
+
5
+ cache-hit 插件此前通过遍历 `api.state.session.messages()` 逐条累加 per-message 字段来统计 cost/token。这个路径受 OpenCode TUI sync 的 **100 条消息上限** 限制([issue #31513]),超过 100 条消息的会话数据被静默截断。
6
+
7
+ **影响**:对于超过 100 条消息的会话,截断导致大量数据丢失。在一个实际的长会话中,插件仅显示了约 43% 的缓存读 token 和约 39% 的实际费用。
8
+
9
+ | 指标 | 旧(messages,100 条截断) | 新(session.get,无截断) |
10
+ |------|--------------------------|-------------------------|
11
+ | 缓存读 | 59.6M tok | **139.0M tok** |
12
+ | 缓存写 | 89.8K tok | **1.4M tok** |
13
+ | 未命中 | 118 tok | **329 tok** |
14
+ | 输出 | 32.7K tok | **91.9K tok** |
15
+ | 可见子 Agent | 3 | **10** |
16
+
17
+ 修复方案是将数据来源从逐条消息累加切换为 `api.state.session.get()`,该接口返回 **数据库层聚合值**,由 session projector 从全部消息累加得出 —— 不受 100 条上限影响。
18
+
19
+ ## 根因
20
+
21
+ OpenCode TUI sync(`packages/opencode/src/cli/cmd/tui/context/sync.tsx`)在获取会话消息时硬编码了 `limit: 100`:
22
+
23
+ ```typescript
24
+ // 第 559 行 — 最多获取 100 条消息
25
+ sdk.client.session.messages({ sessionID, limit: 100 }),
26
+
27
+ // 第 580-581 行 — TUI store 也只保留最后 100 条
28
+ const visible = infos.slice(-100)
29
+ ```
30
+
31
+ `api.state.session.messages(sid)` 读取的是 `sync.data.message[sid]`,最多 100 条。超过的会话,插件看到的是不完整数据,且没有任何截断提示。
32
+
33
+ 与此同时,OpenCode SQLite 数据库的 `session` 表存储了**预聚合**列(`cost`、`tokens_input`、`tokens_output`、`tokens_cache_read`、`tokens_cache_write`),由 session projector 通过 SQL 增量更新 —— 每条消息都会被计入,无上限。
34
+
35
+ `api.state.session.get(sid)` 返回完整 `Session` 对象(SDK 类型 `Session`),包含来自数据库聚合的 `cost` 和 `tokens` 字段。
36
+
37
+ ## 方案
38
+
39
+ ### 设计
40
+
41
+ ```
42
+ 之前:
43
+ api.state.session.messages(sid) → 遍历 100 条消息 → 累加 ❌ 截断
44
+
45
+ 现在:
46
+ api.state.session.get(sid) → 直接读 cost/tokens → 完成 ✅ 全量
47
+ └─ session 不可用时 → fallback 到消息累加 (兼容)
48
+ ```
49
+
50
+ ### 改动
51
+
52
+ | 文件 | 内容 |
53
+ |------|------|
54
+ | `src/types.ts` | 新增 `SessionObject` 类型(`model`、`cost`、`tokens`、`parentID`);更新 `session.get` 返回类型 |
55
+ | `src/stats.ts` | 新增 `aggregateFromSessionObject()` — O(1) 从 `SessionObject` 提取 `SessionSnapshot` |
56
+ | `src/sidebar-host.tsx` | `mainSnap` 和 `subAgentList` 优先使用 `session.get()`,不可用时 fallback 到消息累加 |
57
+ | `tests/stats.test.ts` | 3 个测试用例覆盖空对象、完整对象、部分字段对象 |
58
+
59
+ ### 数据流
60
+
61
+ 1. **`mainSnap`**:调用 `api.state.session.get(sid)` → `aggregateFromSessionObject()` → 有数据则返回。若 session 不可用或为空,fallback 到 `aggregateSessionFromMessages()`。
62
+
63
+ 2. **`subAgentList`**:每个子会话同上。
64
+
65
+ 3. **逐轮趋势**(`computePerCallHitTrend`):仍使用 `api.state.session.messages()` —— 趋势只看最近几轮,100 条足够。
66
+
67
+ ### 为什么 resume 会话立即可见
68
+
69
+ resume 会话时,OpenCode TUI `sync()` 立即调用 `sdk.client.session.get({ sessionID })` 获取完整 `Session` 对象(含数据库级聚合值)。该对象存入 TUI state store,`api.state.session.get(sid)` 读取之。插件从会话加载的瞬间就能看到准确总数。
70
+
71
+ ### Fallback 策略
72
+
73
+ `session.get()` 对从未被 sync 到 TUI state 的会话返回 `undefined`(极少见,多见于特殊上下文中加载的子会话)。fallback 到 `api.state.session.messages()` 作为安全网保留原有行为。
74
+
75
+ ## 性能
76
+
77
+ | 维度 | 旧(消息遍历) | 新(session.get) |
78
+ |------|---------------|-------------------|
79
+ | 时间复杂度 | O(n),n ≤ 100 | O(1) |
80
+ | 内存 | 读取整个消息数组 | 读取一个小对象 |
81
+ | 响应式触发 | `message.updated` → 重算 | 同一事件,同一时机 |
82
+ | 数据完整性 | ≤ 100 条消息 | 全部消息(数据库聚合) |
83
+
84
+ ## 上线
85
+
86
+ 1. **无需配置变更** —— 插件自动优先使用 `session.get()`。
87
+ 2. **存量用户无需迁移** —— 旧配置文件和 timeline 日志不受影响。
88
+ 3. **重启 OpenCode** 加载更新后的插件代码。
89
+ 4. **验证**:打开一个超过 100 条消息的会话,侧边栏数据应与数据库聚合值一致。
90
+
91
+ ## 相关链接
92
+
93
+ - [anomalyco/opencode#31513] — v1 TUI sync 硬编码 limit:100
94
+ - [anomalyco/opencode#26861] — 基于游标的分页 PR(待合入)
95
+ - [anomalyco/opencode#6548] — 分页消息加载功能请求
96
+
97
+ [issue #31513]: https://github.com/anomalyco/opencode/issues/31513
98
+ [anomalyco/opencode#31513]: https://github.com/anomalyco/opencode/issues/31513
99
+ [anomalyco/opencode#26861]: https://github.com/anomalyco/opencode/pull/26861
100
+ [anomalyco/opencode#6548]: https://github.com/anomalyco/opencode/issues/6548
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-cache-hit",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "OpenCode TUI sidebar: prompt cache hit rate, tokens & cost with sub-agent rollup. Works with opencode-visual-cache; optional per-call JSONL timeline.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -6,16 +6,20 @@ import { createTimelineCollector } from "./timeline/collector.ts"
6
6
  import type { AssistantMessage, OpenCodeTuiApi, SubAgentSummary } from "./types.ts"
7
7
  import {
8
8
  emptySessionSnapshot,
9
+ aggregateFromSessionObject,
9
10
  aggregateSessionFromMessages,
11
+ mainSessionHasStats,
10
12
  subAgentHasStats,
11
13
  toSubAgentSummary,
14
+ withModelFallback,
12
15
  } from "./stats.ts"
13
16
  import { createChildSessionSync } from "./child-session-sync.ts"
14
17
  import { loadPluginConfig } from "./load-config.ts"
15
18
 
16
19
  /**
17
- * Session-scoped sidebar host. Bumps `refreshTick` on message.updated (same as visual-cache)
18
- * so memos re-read api.state.session.messages.
20
+ * Session-scoped sidebar host. Bumps `refreshTick` on message.updated
21
+ * so memos re-compute. Prefers session.get() for aggregate cost/tokens
22
+ * (DB-level, not capped at 100 messages), falls back to session.messages().
19
23
  * Timeline writes are event-driven: message.updated → handleMessage → appendFile.
20
24
  */
21
25
  export function CacheHitSidebarHost(props: {
@@ -63,6 +67,14 @@ export function CacheHitSidebarHost(props: {
63
67
  void refreshTick()
64
68
  const sid = props.sessionId
65
69
  if (!sid) return emptySessionSnapshot()
70
+ const session = props.api.state.session.get(sid)
71
+ if (session) {
72
+ const snap = aggregateFromSessionObject(session)
73
+ if (mainSessionHasStats(snap)) {
74
+ const msgs = props.api.state.session.messages(sid) as AssistantMessage[] | undefined
75
+ return msgs ? withModelFallback(snap, msgs) : snap
76
+ }
77
+ }
66
78
  const msgs = props.api.state.session.messages(sid)
67
79
  return msgs?.length
68
80
  ? aggregateSessionFromMessages(msgs as AssistantMessage[])
@@ -76,17 +88,27 @@ export function CacheHitSidebarHost(props: {
76
88
  return (props.api.state.session.messages(sid) ?? []) as AssistantMessage[]
77
89
  })
78
90
 
79
- const subAgentList = createMemo(() =>
80
- childIds()
91
+ const subAgentList = createMemo(() => {
92
+ void refreshTick()
93
+ return childIds()
81
94
  .map((cid) => {
95
+ const session = props.api.state.session.get(cid)
96
+ if (session) {
97
+ const snap = aggregateFromSessionObject(session)
98
+ if (subAgentHasStats(snap)) {
99
+ const msgs = props.api.state.session.messages(cid) as AssistantMessage[] | undefined
100
+ const merged = msgs ? withModelFallback(snap, msgs) : snap
101
+ return toSubAgentSummary(cid, merged)
102
+ }
103
+ }
82
104
  const msgs = props.api.state.session.messages(cid)
83
105
  if (!msgs?.length) return null
84
106
  const snap = aggregateSessionFromMessages(msgs as AssistantMessage[])
85
107
  if (!subAgentHasStats(snap)) return null
86
108
  return toSubAgentSummary(cid, snap)
87
109
  })
88
- .filter(Boolean) as SubAgentSummary[],
89
- )
110
+ .filter(Boolean) as SubAgentSummary[]
111
+ })
90
112
 
91
113
  createEffect(() => {
92
114
  const sid = props.sessionId
package/src/stats.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AssistantMessage, SessionSnapshot, SubAgentSummary } from "./types.ts"
1
+ import type { AssistantMessage, SessionObject, SessionSnapshot, SubAgentSummary } from "./types.ts"
2
2
 
3
3
  export function mainSessionHasStats(main: SessionSnapshot): boolean {
4
4
  return (
@@ -14,6 +14,21 @@ export function emptySessionSnapshot(): SessionSnapshot {
14
14
  return { model: "", providerID: "", input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
15
15
  }
16
16
 
17
+ export function aggregateFromSessionObject(session: SessionObject): SessionSnapshot {
18
+ const t = session.tokens
19
+ const c = t?.cache
20
+ return {
21
+ model: session.model?.id ?? "",
22
+ providerID: session.model?.providerID ?? "",
23
+ input: t?.input ?? 0,
24
+ output: t?.output ?? 0,
25
+ reasoning: t?.reasoning ?? 0,
26
+ cacheRead: c?.read ?? 0,
27
+ cacheWrite: c?.write ?? 0,
28
+ cost: session.cost ?? 0,
29
+ }
30
+ }
31
+
17
32
  export function aggregateSessionFromMessages(messages: readonly AssistantMessage[]): SessionSnapshot {
18
33
  let model = "",
19
34
  providerID = "",
@@ -81,6 +96,31 @@ export function subAgentHasStats(snap: SessionSnapshot): boolean {
81
96
  )
82
97
  }
83
98
 
99
+ /**
100
+ * Fill missing model / providerID from the last assistant message.
101
+ * Session aggregates may have cost/tokens but lack model metadata;
102
+ * this avoids losing pricing/display when session.get() is used.
103
+ */
104
+ export function withModelFallback(
105
+ snap: SessionSnapshot,
106
+ messages: readonly AssistantMessage[],
107
+ ): SessionSnapshot {
108
+ if (snap.model && snap.providerID) return snap
109
+
110
+ let model = snap.model
111
+ let providerID = snap.providerID
112
+ for (let i = messages.length - 1; i >= 0; i--) {
113
+ const m = messages[i]
114
+ if (m.role !== "assistant") continue
115
+ if (!model && m.modelID) model = m.modelID
116
+ if (!providerID && m.providerID) providerID = m.providerID
117
+ if (model && providerID) break
118
+ }
119
+ return model === snap.model && providerID === snap.providerID
120
+ ? snap
121
+ : { ...snap, model, providerID }
122
+ }
123
+
84
124
  export function sidebarShouldShow(
85
125
  main: SessionSnapshot,
86
126
  subs: readonly SubAgentSummary[],
package/src/types.ts CHANGED
@@ -53,13 +53,26 @@ export type ProviderInfo = {
53
53
  models: { [key: string]: { cost: ModelCost } }
54
54
  }
55
55
 
56
+ /** Session aggregate from `api.state.session.get()` — DB-level totals, not capped by message limit. */
57
+ export type SessionObject = {
58
+ model?: { id: string; providerID: string }
59
+ cost?: number
60
+ tokens?: {
61
+ input?: number
62
+ output?: number
63
+ reasoning?: number
64
+ cache?: { read?: number; write?: number }
65
+ }
66
+ parentID?: string
67
+ }
68
+
56
69
  export type OpenCodeTuiApi = {
57
70
  state: {
58
71
  path: { directory: string }
59
72
  provider: ReadonlyArray<ProviderInfo>
60
73
  session: {
61
74
  messages: (id: string) => unknown[] | undefined
62
- get: (id: string) => { parentID?: string } | undefined
75
+ get: (id: string) => SessionObject | undefined
63
76
  }
64
77
  }
65
78
  client: {