opencode-total-session-cost 1.0.1 → 1.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 crazybyte
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Opencode Total Session Cost Tracker 💰
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/opencode-total-session-cost.svg?logo=npm&color=CB3837)](https://www.npmjs.com/package/opencode-total-session-cost)
4
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-total-session-cost.svg?logo=npm&color=51a822)](https://www.npmjs.com/package/opencode-total-session-cost)
5
+ [![npm provenance](https://img.shields.io/badge/provenance-signed-blue?logo=sigstore&color=007ec6)](https://www.npmjs.com/package/opencode-total-session-cost)
6
+ [![CI Status](https://github.com/StayPirate/opencode-total-session-cost/actions/workflows/ci.yml/badge.svg)](https://github.com/StayPirate/opencode-total-session-cost/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/StayPirate/opencode-total-session-cost)
8
+ [![GitHub stars](https://img.shields.io/github/stars/StayPirate/opencode-total-session-cost.svg?style=flat&logo=github&color=007ec6)](https://github.com/StayPirate/opencode-total-session-cost/stargazers)
9
+
3
10
  A real-time, lightweight plugin for [Opencode](https://opencode.im) that tracks and displays the **actual cumulative cost** of your active session, including all background tasks, sub-agents, and child sessions.
4
11
 
5
12
  ## Why this plugin?
@@ -12,19 +19,54 @@ Without tracking these sub-sessions, you might see a main session cost of a few
12
19
  ## Features
13
20
  - **Sidebar-Independent**: The cost indicator stays visible even when the Opencode sidebar is closed.
14
21
  - **Recursive Sub-Agent Tracking**: Automatically detects and sums up the costs of all child tasks spawned during your session.
15
- - **Detailed Cost Breakdown**: Offers a `/total_cost` slash command to show a detailed popup separating your active session cost from child sub-agent costs.
22
+ - **Detailed Cost Breakdown**: Offers a `/total_cost` slash command to show a detailed popup separating your active session cost from child task and sub-agent costs.
23
+ - **Provider & Model Breakdown**: Displays the exact costs accumulated per model across all session hierarchy levels.
24
+ - **Mouse Click Interaction**: Left-clicking on the cost bar in the prompt header right panel triggers the same detailed breakdown popup.
16
25
 
17
26
  ## Installation
18
- Once published, you can install it globally with:
27
+ Install it globally with:
19
28
  ```bash
20
29
  opencode plugin opencode-total-session-cost -g
21
30
  ```
22
31
 
32
+ ## Cost categories
33
+ The breakdown separates costs into three buckets:
34
+ - **Session**: the cost of the active/root session.
35
+ - **Task**: child sessions spawned through the Task tool by the `explore` and `general` agents.
36
+ - **Sub-agent**: any other child session.
37
+
38
+ Costs that are not present in the session messages (for example archived or compacted
39
+ messages) are still attributed to the session's configured provider/model, so the totals
40
+ always match the real spending reported by Opencode.
41
+
42
+ Example `/total_cost` output:
43
+ ```
44
+ By session
45
+ Session: $0.12
46
+ Task: $0.34
47
+ Sub-agent: $0.08
48
+ ---------------
49
+ Total: $0.54
50
+
51
+ By provider/model
52
+ anthropic/claude-sonnet-4: $0.40
53
+ openai/gpt-5: $0.14
54
+ ------------------------------
55
+ Total: $0.54
56
+ ```
57
+
23
58
  ## How it works
24
59
  The plugin runs inside Opencode's TUI framework, recursively fetching session data:
25
60
  1. It reads the core cost of your active session.
26
61
  2. It recursively queries all child sub-sessions spawned by sub-agents.
27
62
  3. It displays the combined sum dynamically in the TUI.
28
63
 
64
+ ## Development
65
+ ```bash
66
+ npm install
67
+ npm run typecheck
68
+ npm test
69
+ ```
70
+
29
71
  ## License
30
72
  MIT
package/cost.ts ADDED
@@ -0,0 +1,136 @@
1
+ export type SessionLike = {
2
+ id: string;
3
+ cost?: number;
4
+ agent?: string;
5
+ model?: {
6
+ providerID?: string;
7
+ id?: string;
8
+ };
9
+ };
10
+
11
+ export type MessageLike = {
12
+ role?: string;
13
+ cost?: number;
14
+ providerID?: string;
15
+ modelID?: string;
16
+ };
17
+
18
+ export type CostDeps = {
19
+ getSession: (sessionID: string) => SessionLike | undefined;
20
+ getChildren: (sessionID: string) => Promise<readonly SessionLike[]>;
21
+ getMessages: (sessionID: string) => readonly MessageLike[];
22
+ };
23
+
24
+ export type SessionCosts = {
25
+ parent: number;
26
+ task: number;
27
+ subagent: number;
28
+ };
29
+
30
+ export type CostBreakdown = {
31
+ sessions: SessionCosts;
32
+ models: Record<string, number>;
33
+ total: number;
34
+ };
35
+
36
+ /**
37
+ * Agents that OpenCode spawns through the Task tool. Their sessions are
38
+ * reported under the "Task" category in the breakdown.
39
+ */
40
+ export const TASK_AGENTS: ReadonlySet<string> = new Set(["explore", "general"]);
41
+
42
+ const EPSILON = 0.0001;
43
+
44
+ export async function collectCosts(rootSessionID: string, deps: CostDeps): Promise<CostBreakdown> {
45
+ const sessions: SessionCosts = { parent: 0, task: 0, subagent: 0 };
46
+ const models: Record<string, number> = {};
47
+ const visited = new Set<string>();
48
+
49
+ const addModelCost = (providerID: string, modelID: string, cost: number) => {
50
+ const key = `${providerID}/${modelID}`;
51
+ models[key] = (models[key] ?? 0) + cost;
52
+ };
53
+
54
+ const walk = async (sessionID: string, fallback?: SessionLike): Promise<void> => {
55
+ if (visited.has(sessionID)) return;
56
+ visited.add(sessionID);
57
+
58
+ const session = deps.getSession(sessionID) ?? fallback;
59
+ const sessionCost = session && typeof session.cost === "number" ? session.cost : 0;
60
+
61
+ if (sessionID === rootSessionID) {
62
+ sessions.parent += sessionCost;
63
+ } else if (session?.agent && TASK_AGENTS.has(session.agent)) {
64
+ sessions.task += sessionCost;
65
+ } else {
66
+ sessions.subagent += sessionCost;
67
+ }
68
+
69
+ let attributedCost = 0;
70
+ for (const message of deps.getMessages(sessionID)) {
71
+ if (message.role !== "assistant") continue;
72
+ const cost = message.cost;
73
+ if (typeof cost !== "number" || cost <= 0) continue;
74
+ addModelCost(
75
+ message.providerID ?? session?.model?.providerID ?? "unknown",
76
+ message.modelID ?? session?.model?.id ?? "unknown",
77
+ cost
78
+ );
79
+ attributedCost += cost;
80
+ }
81
+
82
+ const remainder = sessionCost - attributedCost;
83
+ if (remainder > EPSILON) {
84
+ addModelCost(
85
+ session?.model?.providerID ?? "unknown",
86
+ session?.model?.id ?? "unknown",
87
+ remainder
88
+ );
89
+ }
90
+
91
+ for (const child of await deps.getChildren(sessionID)) {
92
+ await walk(child.id, child);
93
+ }
94
+ };
95
+
96
+ await walk(rootSessionID);
97
+
98
+ const total = sessions.parent + sessions.task + sessions.subagent;
99
+ return { sessions, models, total };
100
+ }
101
+
102
+ export function formatSessionSection(sessions: SessionCosts): string {
103
+ const total = sessions.parent + sessions.task + sessions.subagent;
104
+ return [
105
+ "By session",
106
+ `Session: $${sessions.parent.toFixed(2)}`,
107
+ `Task: $${sessions.task.toFixed(2)}`,
108
+ `Sub-agent: $${sessions.subagent.toFixed(2)}`,
109
+ "---------------",
110
+ `Total: $${total.toFixed(2)}`,
111
+ ].join("\n");
112
+ }
113
+
114
+ export function formatModelSection(models: Record<string, number>, total: number): string {
115
+ const sorted = Object.entries(models).sort((a, b) => b[1] - a[1]);
116
+
117
+ let labelWidth = "Total:".length;
118
+ for (const [key] of sorted) {
119
+ labelWidth = Math.max(labelWidth, key.length + 1);
120
+ }
121
+
122
+ const lines = sorted.map(
123
+ ([key, cost]) => `${`${key}:`.padEnd(labelWidth + 1, " ")}$${cost.toFixed(2)}`
124
+ );
125
+ const separator = "-".repeat(labelWidth + 7);
126
+ const totalLine = `${"Total:".padEnd(labelWidth + 1, " ")}$${total.toFixed(2)}`;
127
+
128
+ return ["By provider/model", ...lines, separator, totalLine].join("\n");
129
+ }
130
+
131
+ export function formatBreakdown(breakdown: CostBreakdown): string {
132
+ return `${formatSessionSection(breakdown.sessions)}\n\n${formatModelSection(
133
+ breakdown.models,
134
+ breakdown.total
135
+ )}`;
136
+ }
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Plugin, PluginModule } from "@opencode-ai/plugin";
2
2
 
3
- export const id = "opencode-cost-bar";
3
+ export const id = "opencode-total-session-cost";
4
4
 
5
5
  export const server: Plugin = async (ctx) => {
6
6
  return {}; // No-op backend hooks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-total-session-cost",
3
- "version": "1.0.1",
3
+ "version": "1.1.2",
4
4
  "description": "Real-time cumulative cost tracker for Opencode sessions, aggregating costs from parent sessions, archived messages, and all child sub-agent tasks.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -8,6 +8,24 @@
8
8
  ".": "./index.ts",
9
9
  "./tui": "./tui.tsx"
10
10
  },
11
+ "files": [
12
+ "cost.ts",
13
+ "index.ts",
14
+ "tui.tsx",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "typecheck": "tsc --noEmit",
20
+ "test": "node --test",
21
+ "prepublishOnly": "npm run typecheck && npm test"
22
+ },
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "overrides": {
27
+ "@babel/core": "^7.29.7"
28
+ },
11
29
  "keywords": [
12
30
  "opencode-plugin",
13
31
  "opencode",
package/tui.tsx CHANGED
@@ -1,61 +1,102 @@
1
- import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
1
+ import type {
2
+ TuiHostSlotMap,
3
+ TuiPlugin,
4
+ TuiPluginApi,
5
+ TuiPluginModule,
6
+ TuiSlotContext
7
+ } from "@opencode-ai/plugin/tui";
8
+ import type { Message } from "@opencode-ai/sdk/v2";
2
9
  import { createSignal, onCleanup } from "solid-js";
10
+ import { collectCosts, formatBreakdown, type CostDeps, type MessageLike } from "./cost.ts";
11
+
12
+ export const id = "opencode-total-session-cost";
13
+
14
+ const toMessageLike = (message: Message): MessageLike => {
15
+ if (message.role === "assistant") {
16
+ return {
17
+ role: message.role,
18
+ cost: message.cost,
19
+ providerID: message.providerID,
20
+ modelID: message.modelID
21
+ };
22
+ }
23
+ return { role: message.role };
24
+ };
3
25
 
4
- export const id = "opencode-cost-bar";
26
+ const createDeps = (api: TuiPluginApi): CostDeps => ({
27
+ getSession: (sessionID) => api.state.session.get(sessionID),
28
+ getMessages: (sessionID) => api.state.session.messages(sessionID).map(toMessageLike),
29
+ getChildren: async (sessionID) => {
30
+ try {
31
+ const res = await api.client.session.children({ sessionID });
32
+ return res.data ?? [];
33
+ } catch (error) {
34
+ console.error(`[${id}] failed to load children of session ${sessionID}`, error);
35
+ return [];
36
+ }
37
+ }
38
+ });
5
39
 
6
40
  export const SessionCostPlugin: TuiPlugin = async (api) => {
7
- // Register TUI slots with reactive signals defined inside the renderers
41
+ const showCostBreakdown = async (sessionID: string) => {
42
+ const breakdown = await collectCosts(sessionID, createDeps(api));
43
+
44
+ api.ui.toast({
45
+ title: "Session Costs Breakdown",
46
+ message: formatBreakdown(breakdown),
47
+ variant: "success",
48
+ duration: 10000
49
+ });
50
+ };
51
+
8
52
  api.slots?.register({
9
53
  slots: {
10
- // session_prompt_right: Persistent cost tracker in the prompt header right panel next to active model info
11
- session_prompt_right: (ctx: any, props: any) => {
54
+ session_prompt_right: (
55
+ ctx: Readonly<TuiSlotContext>,
56
+ props: TuiHostSlotMap["session_prompt_right"]
57
+ ) => {
12
58
  const [total, setTotal] = createSignal<number>(0);
13
-
14
- const calculateCost = async (sessionID: string): Promise<number> => {
15
- let totalCost = 0;
16
-
17
- const sessionObj = api.state.session.get(sessionID);
18
- if (sessionObj && typeof sessionObj.cost === "number") {
19
- totalCost += sessionObj.cost;
20
- }
21
-
22
- try {
23
- const childrenRes = await api.client.session.children({ sessionID });
24
- if (childrenRes.data) {
25
- for (const child of childrenRes.data) {
26
- totalCost += await calculateCost(child.id);
27
- }
28
- }
29
- } catch (err) {}
30
-
31
- return totalCost;
32
- };
59
+ let requestId = 0;
33
60
 
34
61
  const update = async () => {
35
- const sessionID = props?.session_id;
36
- if (sessionID) {
37
- const res = await calculateCost(sessionID);
38
- setTotal(res);
39
- } else {
62
+ const currentRequest = ++requestId;
63
+ const sessionID = props.session_id;
64
+ if (!sessionID) {
40
65
  setTotal(0);
66
+ return;
41
67
  }
68
+
69
+ const breakdown = await collectCosts(sessionID, createDeps(api));
70
+ if (currentRequest !== requestId) return;
71
+
72
+ setTotal(breakdown.total);
42
73
  };
43
74
 
44
- const unsubMsgUpdated = api.event.on("message.updated", update);
45
- const unsubMsgRemoved = api.event.on("message.removed", update);
46
- const interval = setInterval(update, 3000);
75
+ const unsubMsgUpdated = api.event.on("message.updated", () => void update());
76
+ const unsubMsgRemoved = api.event.on("message.removed", () => void update());
77
+ const unsubSessionUpdated = api.event.on("session.updated", () => void update());
78
+ const unsubSessionIdle = api.event.on("session.idle", () => void update());
79
+ const interval = setInterval(() => void update(), 3000);
47
80
 
48
81
  onCleanup(() => {
49
82
  unsubMsgUpdated();
50
83
  unsubMsgRemoved();
84
+ unsubSessionUpdated();
85
+ unsubSessionIdle();
51
86
  clearInterval(interval);
52
87
  });
53
88
 
54
- // Trigger update immediately
55
- update();
89
+ void update();
56
90
 
57
91
  return (
58
- <text fg="gray">
92
+ <text
93
+ fg={ctx.theme.current.textMuted}
94
+ onMouseUp={(event) => {
95
+ if (event.button === 0) {
96
+ void showCostBreakdown(props.session_id);
97
+ }
98
+ }}
99
+ >
59
100
  {" "}[ ${total().toFixed(2)} ]
60
101
  </text>
61
102
  );
@@ -63,7 +104,6 @@ export const SessionCostPlugin: TuiPlugin = async (api) => {
63
104
  }
64
105
  });
65
106
 
66
- // Register slash command /total_cost (and alias /costs)
67
107
  api.command?.register(() => [
68
108
  {
69
109
  title: "Total Session Cost",
@@ -75,9 +115,12 @@ export const SessionCostPlugin: TuiPlugin = async (api) => {
75
115
  aliases: ["costs"]
76
116
  },
77
117
  onSelect: async () => {
78
- const currentSessionID = api.route.current && api.route.current.name === "session" && typeof api.route.current.params?.sessionID === "string"
79
- ? api.route.current.params.sessionID
80
- : null;
118
+ const currentSessionID =
119
+ api.route.current &&
120
+ api.route.current.name === "session" &&
121
+ typeof api.route.current.params?.sessionID === "string"
122
+ ? api.route.current.params.sessionID
123
+ : null;
81
124
 
82
125
  if (!currentSessionID) {
83
126
  api.ui.toast({
@@ -89,50 +132,17 @@ export const SessionCostPlugin: TuiPlugin = async (api) => {
89
132
  return;
90
133
  }
91
134
 
92
- let parentCost = 0;
93
- let childrenCost = 0;
94
-
95
- const calculateCostRecursive = async (sessionID: string): Promise<void> => {
96
- const sessionObj = api.state.session.get(sessionID);
97
- if (sessionObj && typeof sessionObj.cost === "number") {
98
- if (sessionID === currentSessionID) {
99
- parentCost += sessionObj.cost;
100
- } else {
101
- childrenCost += sessionObj.cost;
102
- }
103
- }
104
-
105
- try {
106
- const childrenRes = await api.client.session.children({ sessionID });
107
- if (childrenRes.data) {
108
- for (const child of childrenRes.data) {
109
- await calculateCostRecursive(child.id);
110
- }
111
- }
112
- } catch (err) {}
113
- };
114
-
115
- await calculateCostRecursive(currentSessionID);
116
- const total = parentCost + childrenCost;
117
-
118
- api.ui.toast({
119
- title: "Session Costs Breakdown",
120
- message: `Active: $${parentCost.toFixed(2)} | Children: $${childrenCost.toFixed(2)} | Total: $${total.toFixed(2)}`,
121
- variant: "success",
122
- duration: 6000
123
- });
135
+ await showCostBreakdown(currentSessionID);
124
136
  }
125
137
  }
126
138
  ]);
127
139
  };
128
140
 
129
- // Export named constant for loaders looking for `export const tui = ...`
130
141
  export const tui = SessionCostPlugin;
131
142
 
132
- // Export default module configuration
133
143
  const pluginModule: TuiPluginModule = {
134
144
  id,
135
145
  tui: SessionCostPlugin
136
146
  };
137
147
 
138
- export default pluginModule;
148
+ export default pluginModule;
@@ -1,27 +0,0 @@
1
- name: Continuous Integration
2
-
3
- on:
4
- push:
5
- branches: [ main ]
6
- pull_request:
7
- branches: [ main ]
8
-
9
- jobs:
10
- typecheck:
11
- name: TypeScript Typecheck
12
- runs-on: ubuntu-latest
13
-
14
- steps:
15
- - name: Checkout Code
16
- uses: actions/checkout@v4
17
-
18
- - name: Setup Bun
19
- uses: oven-sh/setup-bun@v2
20
- with:
21
- bun-version: latest
22
-
23
- - name: Install Dependencies
24
- run: bun install --frozen-lockfile
25
-
26
- - name: Run Typecheck
27
- run: bun x tsc --noEmit
@@ -1,30 +0,0 @@
1
- name: Publish to NPM
2
-
3
- on:
4
- release:
5
- types: [published]
6
- workflow_dispatch:
7
-
8
- jobs:
9
- publish:
10
- name: Publish Package
11
- runs-on: ubuntu-latest
12
- permissions:
13
- contents: read
14
- id-token: write
15
- steps:
16
- - name: Checkout Code
17
- uses: actions/checkout@v6
18
-
19
- - name: Setup Node.js
20
- uses: actions/setup-node@v6
21
- with:
22
- node-version: 24
23
- registry-url: 'https://registry.npmjs.org'
24
- package-manager-cache: false
25
-
26
- - name: Install Dependencies
27
- run: npm ci
28
-
29
- - name: Publish Package
30
- run: npm publish --access public
package/opencode.jsonc DELETED
@@ -1,6 +0,0 @@
1
- {
2
- "$schema": "https://opencode.ai/config.json",
3
- "plugin": [
4
- "./tui.tsx"
5
- ]
6
- }
package/tsconfig.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "jsx": "preserve",
7
- "jsxImportSource": "@opentui/solid",
8
- "strict": true,
9
- "declaration": true,
10
- "esModuleInterop": true,
11
- "skipLibCheck": true,
12
- "forceConsistentCasingInFileNames": true
13
- },
14
- "include": ["./index.ts", "./tui.tsx"]
15
- }