tktop-tui 0.3.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AlexKay28
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.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: tktop-tui
3
+ Version: 0.3.0
4
+ Summary: Live htop-style TUI dashboard for AI token consumption across coding agents (OpenCode, Claude Code, Codex, generic JSONL). Python wrapper that launches the tktop TUI via Bun.
5
+ Author: AlexKay28
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 AlexKay28
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/AlexKay28/tktop
29
+ Project-URL: Repository, https://github.com/AlexKay28/tktop
30
+ Keywords: cli,tui,ai,llm,tokens,monitoring,htop
31
+ Classifier: Environment :: Console
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Programming Language :: Python :: 3
36
+ Requires-Python: >=3.10
37
+ Description-Content-Type: text/markdown
38
+ License-File: LICENSE
39
+ Dynamic: license-file
40
+
41
+ # tktop-tui
42
+
43
+ Python launcher for **tktop** — the live htop-style TUI dashboard for AI token
44
+ consumption across coding agents (OpenCode, Claude Code, Codex, generic JSONL).
45
+
46
+ ```
47
+ pip install tktop-tui
48
+ tktop
49
+ ```
50
+
51
+ The wheel bundles the TypeScript TUI. On first run it stages the app into
52
+ `~/.local/share/tktop/<version>/`, runs `bun install` there (so read-only
53
+ site-packages are never touched), and execs the TUI under
54
+ [Bun](https://bun.sh) — install it first if you don't have it:
55
+
56
+ ```
57
+ curl -fsSL https://bun.sh/install | bash
58
+ ```
59
+
60
+ Requirements:
61
+
62
+ - Python ≥ 3.10
63
+ - Bun ≥ 1.1 (for the TUI runtime)
64
+ - network on the very first launch only (to fetch `@opentui/core`)
65
+
66
+ Features: per-session token/burn-rate table with mouse-clickable column
67
+ sorting, live log-scale scatterplot (colored markers + trails), grouping,
68
+ filtering, sparklines — all read-only, tktop never touches your harnesses.
69
+
70
+ GitHub: <https://github.com/AlexKay28/tktop>
@@ -0,0 +1,30 @@
1
+ # tktop-tui
2
+
3
+ Python launcher for **tktop** — the live htop-style TUI dashboard for AI token
4
+ consumption across coding agents (OpenCode, Claude Code, Codex, generic JSONL).
5
+
6
+ ```
7
+ pip install tktop-tui
8
+ tktop
9
+ ```
10
+
11
+ The wheel bundles the TypeScript TUI. On first run it stages the app into
12
+ `~/.local/share/tktop/<version>/`, runs `bun install` there (so read-only
13
+ site-packages are never touched), and execs the TUI under
14
+ [Bun](https://bun.sh) — install it first if you don't have it:
15
+
16
+ ```
17
+ curl -fsSL https://bun.sh/install | bash
18
+ ```
19
+
20
+ Requirements:
21
+
22
+ - Python ≥ 3.10
23
+ - Bun ≥ 1.1 (for the TUI runtime)
24
+ - network on the very first launch only (to fetch `@opentui/core`)
25
+
26
+ Features: per-session token/burn-rate table with mouse-clickable column
27
+ sorting, live log-scale scatterplot (colored markers + trails), grouping,
28
+ filtering, sparklines — all read-only, tktop never touches your harnesses.
29
+
30
+ GitHub: <https://github.com/AlexKay28/tktop>
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tktop-tui"
7
+ version = "0.3.0"
8
+ description = "Live htop-style TUI dashboard for AI token consumption across coding agents (OpenCode, Claude Code, Codex, generic JSONL). Python wrapper that launches the tktop TUI via Bun."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "AlexKay28" }]
13
+ keywords = ["cli", "tui", "ai", "llm", "tokens", "monitoring", "htop"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/AlexKay28/tktop"
25
+ Repository = "https://github.com/AlexKay28/tktop"
26
+
27
+ [project.scripts]
28
+ tktop = "tktop_tui:main"
29
+
30
+ [tool.setuptools]
31
+ packages = ["tktop_tui"]
32
+
33
+ [tool.setuptools.package-data]
34
+ # the bundled TUI (staged here by python/sync.sh at build time)
35
+ tktop_tui = ["tui/**/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,72 @@
1
+ """tktop-tui: Python launcher for the tktop TUI.
2
+
3
+ `pip install tktop-tui` gives you the `tktop` command. The wheel bundles the
4
+ TypeScript TUI (src/ + bin/); on first run it is staged into a user cache dir
5
+ (~/.local/share/tktop/<version>) where `bun install` fetches its JavaScript
6
+ dependencies, then the TUI is exec'd under Bun. Read-only site-packages are
7
+ never touched.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from importlib.resources import as_file, files
16
+ from pathlib import Path
17
+
18
+ VERSION = "0.3.0"
19
+ HOMEPAGE = "https://github.com/AlexKay28/tktop"
20
+
21
+
22
+ def find_bun() -> str | None:
23
+ """Locate bun on PATH or in its default install location."""
24
+ found = shutil.which("bun")
25
+ if found:
26
+ return found
27
+ default = Path.home() / ".bun" / "bin" / "bun"
28
+ return str(default) if default.is_file() and os.access(default, os.X_OK) else None
29
+
30
+
31
+ def stage_tui() -> Path:
32
+ """Copy the bundled TUI into a per-version cache dir (once per version)."""
33
+ base = Path.home() / ".local" / "share" / "tktop" / VERSION
34
+ marker = base / ".installed"
35
+ if marker.is_file():
36
+ return base
37
+ if base.exists():
38
+ shutil.rmtree(base)
39
+ base.parent.mkdir(parents=True, exist_ok=True)
40
+ src = files("tktop_tui").joinpath("tui")
41
+ with as_file(src) as src_path:
42
+ shutil.copytree(src_path, base)
43
+ marker.write_text("staged\n")
44
+ return base
45
+
46
+
47
+ def main() -> None:
48
+ bun = find_bun()
49
+ if not bun:
50
+ print("tktop: bun is required but not installed.", file=sys.stderr)
51
+ print(" install it with: curl -fsSL https://bun.sh/install | bash", file=sys.stderr)
52
+ print(f" project: {HOMEPAGE}", file=sys.stderr)
53
+ raise SystemExit(1)
54
+
55
+ tui = stage_tui()
56
+
57
+ # fetch JS dependencies once per staged version (needs network on first run)
58
+ if not (tui / "node_modules").is_dir():
59
+ pkg = json.loads((tui / "package.json").read_text())
60
+ pkg.pop("devDependencies", None) # @types/bun is not needed at runtime
61
+ pkg.get("scripts", {}).pop("prepublishOnly", None)
62
+ (tui / "package.json").write_text(json.dumps(pkg))
63
+ r = subprocess.run([bun, "install", "--production"], cwd=tui)
64
+ if r.returncode != 0:
65
+ print("tktop: bun install failed (network reachable?).", file=sys.stderr)
66
+ raise SystemExit(1)
67
+
68
+ os.execv(bun, [bun, "run", str(tui / "src" / "index.ts"), *sys.argv[1:]])
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AlexKay28
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.
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ # tktop launcher — runs the TUI via Bun.
3
+ # Resolves the repo root from this script's own location, so the shim works
4
+ # from ~/.local/bin (symlinked anywhere) and survives repo moves.
5
+ set -eu
6
+
7
+ REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
8
+ export PATH="$HOME/.bun/bin:$PATH"
9
+
10
+ if ! command -v bun >/dev/null 2>&1; then
11
+ echo "tktop: bun is required but not installed." >&2
12
+ echo " install it with: curl -fsSL https://bun.sh/install | bash" >&2
13
+ exit 1
14
+ fi
15
+
16
+ exec bun run "$REPO_ROOT/src/index.ts" "$@"
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "tktop",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "description": "Live htop-style TUI dashboard for AI token consumption across harnesses (OpenCode, Claude Code, Codex, generic JSONL)",
6
+ "keywords": [
7
+ "cli",
8
+ "tui",
9
+ "tokens",
10
+ "llm",
11
+ "ai",
12
+ "monitoring",
13
+ "opentui",
14
+ "htop"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+ssh://git@github.com/AlexKay28/tktop.git"
20
+ },
21
+ "bin": {
22
+ "tktop": "bin/tktop"
23
+ },
24
+ "files": [
25
+ "bin/",
26
+ "src/",
27
+ "docs/",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "bun": ">=1.1.0"
33
+ },
34
+ "scripts": {
35
+ "start": "bun run src/index.ts",
36
+ "tktop": "bun run src/index.ts",
37
+ "dump": "bun run src/dump.ts",
38
+ "typecheck": "bun check",
39
+ "prepublishOnly": "bun run typecheck"
40
+ },
41
+ "dependencies": {
42
+ "@opentui/core": "^0.8.1"
43
+ },
44
+ "devDependencies": {
45
+ "@types/bun": "latest"
46
+ }
47
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Claude Code adapter — additive parsing of JSONL files under ~/.claude/projects.
3
+ * Auto-activates when ~/.claude exists. See docs/ADR-003 for caveats.
4
+ */
5
+ import { existsSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import type { Adapter, SessionInfo, TokenCounts } from "../types"
8
+ import { JsonlTail } from "./jsonl-tail"
9
+
10
+ interface Acc {
11
+ id: string
12
+ title: string
13
+ directory: string
14
+ model: string
15
+ tokens: TokenCounts
16
+ cost: number
17
+ timeCreated: number
18
+ timeUpdated: number
19
+ }
20
+
21
+ function newAcc(id: string): Acc {
22
+ return {
23
+ id,
24
+ title: "",
25
+ directory: "",
26
+ model: "-",
27
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
28
+ cost: 0,
29
+ timeCreated: 0,
30
+ timeUpdated: 0,
31
+ }
32
+ }
33
+
34
+ export class ClaudeCodeAdapter implements Adapter {
35
+ readonly harness = "claude-code"
36
+ lastError: string | null = null
37
+
38
+ private root: string
39
+ private tail: JsonlTail
40
+ private accs = new Map<string, Acc>()
41
+ private started = false
42
+
43
+ constructor(root?: string) {
44
+ this.root = root ?? `${homedir()}/.claude/projects`
45
+ this.tail = new JsonlTail(`${this.root}/**/*.jsonl`)
46
+ }
47
+
48
+ available(): boolean {
49
+ return existsSync(this.root)
50
+ }
51
+
52
+ private sid(obj: any, path: string): string {
53
+ if (obj.sessionId) return String(obj.sessionId)
54
+ const m = path.match(/([0-9a-f-]{36})(?:\.jsonl)?$/i)
55
+ if (m?.[1]) return m[1]
56
+ return path
57
+ }
58
+
59
+ poll(): SessionInfo[] {
60
+ try {
61
+ if (!this.started) {
62
+ this.tail.refreshFiles()
63
+ this.started = true
64
+ } else if (this.accs.size === 0 || Math.random() < 0.05) {
65
+ // occasionally re-scan for new project files
66
+ this.tail.refreshFiles()
67
+ }
68
+
69
+ this.tail.read((obj, path) => this.onLine(obj, path))
70
+
71
+ const out: SessionInfo[] = []
72
+ for (const [sid, a] of this.accs) {
73
+ out.push({
74
+ key: `${this.harness}:${sid}`,
75
+ harness: this.harness,
76
+ id: sid,
77
+ title: a.title || (a.directory ? a.directory.split("/").filter(Boolean).pop() || sid.slice(0, 12) : sid.slice(0, 12)),
78
+ directory: a.directory,
79
+ model: a.model || "-",
80
+ archived: false,
81
+ tokens: a.tokens,
82
+ cost: a.cost,
83
+ timeCreated: a.timeCreated || a.timeUpdated,
84
+ timeUpdated: a.timeUpdated,
85
+ })
86
+ }
87
+ this.lastError = null
88
+ return out
89
+ } catch (e) {
90
+ this.lastError = (e as Error).message
91
+ return []
92
+ }
93
+ }
94
+
95
+ private onLine(obj: any, path: string): void {
96
+ if (!obj || typeof obj !== "object") return
97
+ const sid = this.sid(obj, path)
98
+ let acc = this.accs.get(sid)
99
+ if (!acc) {
100
+ acc = newAcc(sid)
101
+ this.accs.set(sid, acc)
102
+ }
103
+
104
+ const ts = obj.timestamp ? (typeof obj.timestamp === "number" ? obj.timestamp : Date.parse(obj.timestamp)) : 0
105
+ if (ts > acc.timeUpdated) acc.timeUpdated = ts
106
+ if (acc.timeCreated === 0 || (ts > 0 && ts < acc.timeCreated)) acc.timeCreated = ts
107
+ if (obj.cwd) acc.directory = String(obj.cwd)
108
+
109
+ if (obj.type === "summary" && obj.summary) {
110
+ if (!acc.title) acc.title = String(obj.summary)
111
+ return
112
+ }
113
+
114
+ const msg = obj.message
115
+ if (!msg || typeof msg !== "object") return
116
+
117
+ if (obj.type === "user" && !acc.title) {
118
+ const c = msg.content
119
+ const text =
120
+ typeof c === "string"
121
+ ? c
122
+ : Array.isArray(c)
123
+ ? (c.find((p: any) => p?.type === "text")?.text ?? "")
124
+ : ""
125
+ if (text) acc.title = text.replace(/\s+/g, " ").slice(0, 80)
126
+ return
127
+ }
128
+
129
+ if (obj.type === "assistant") {
130
+ if (msg.model) acc.model = String(msg.model)
131
+ const u = msg.usage
132
+ if (u && typeof u === "object") {
133
+ acc.tokens.input += u.input_tokens ?? 0
134
+ acc.tokens.output += u.output_tokens ?? 0
135
+ acc.tokens.cacheRead += u.cache_read_input_tokens ?? 0
136
+ acc.tokens.cacheWrite += u.cache_creation_input_tokens ?? 0
137
+ // some history formats fold reasoning into output; record if present
138
+ acc.tokens.reasoning += u.reasoning_tokens ?? 0
139
+ }
140
+ if (typeof obj.costUSD === "number") acc.cost += obj.costUSD
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Codex CLI adapter — cumulative token_count events in
3
+ * ~/.codex/sessions JSONL files ("latest" semantics). See docs/ADR-003.
4
+ */
5
+ import { existsSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import type { Adapter, SessionInfo, TokenCounts } from "../types"
8
+ import { JsonlTail } from "./jsonl-tail"
9
+
10
+ interface Acc {
11
+ id: string
12
+ title: string
13
+ directory: string
14
+ model: string
15
+ tokens: TokenCounts
16
+ cost: number
17
+ timeCreated: number
18
+ timeUpdated: number
19
+ }
20
+
21
+ function newAcc(id: string): Acc {
22
+ return {
23
+ id,
24
+ title: "",
25
+ directory: "",
26
+ model: "-",
27
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
28
+ cost: 0,
29
+ timeCreated: 0,
30
+ timeUpdated: 0,
31
+ }
32
+ }
33
+
34
+ export class CodexAdapter implements Adapter {
35
+ readonly harness = "codex"
36
+ lastError: string | null = null
37
+
38
+ private root: string
39
+ private tail: JsonlTail
40
+ private accs = new Map<string, Acc>()
41
+ private started = false
42
+
43
+ constructor(root?: string) {
44
+ this.root = root ?? `${homedir()}/.codex/sessions`
45
+ this.tail = new JsonlTail(`${this.root}/**/*.jsonl`)
46
+ }
47
+
48
+ available(): boolean {
49
+ return existsSync(this.root)
50
+ }
51
+
52
+ poll(): SessionInfo[] {
53
+ try {
54
+ if (!this.started) {
55
+ this.tail.refreshFiles()
56
+ this.started = true
57
+ } else if (Math.random() < 0.05) {
58
+ this.tail.refreshFiles()
59
+ }
60
+ this.tail.read((obj, path) => this.onLine(obj, path))
61
+
62
+ const out: SessionInfo[] = []
63
+ for (const [sid, a] of this.accs) {
64
+ out.push({
65
+ key: `${this.harness}:${sid}`,
66
+ harness: this.harness,
67
+ id: sid,
68
+ title: a.title || sid.slice(0, 12),
69
+ directory: a.directory,
70
+ model: a.model || "-",
71
+ archived: false,
72
+ tokens: { ...a.tokens },
73
+ cost: a.cost,
74
+ timeCreated: a.timeCreated || a.timeUpdated,
75
+ timeUpdated: a.timeUpdated,
76
+ })
77
+ }
78
+ this.lastError = null
79
+ return out
80
+ } catch (e) {
81
+ this.lastError = (e as Error).message
82
+ return []
83
+ }
84
+ }
85
+
86
+ private onLine(obj: any, path: string): void {
87
+ if (!obj || typeof obj !== "object") return
88
+ const payload = obj.payload
89
+
90
+ // session id: prefer payload.session_meta.id, else file stem
91
+ let sid: string | null = null
92
+ if (payload?.type === "session_meta" && payload.id) sid = String(payload.id)
93
+ if (!sid) {
94
+ const m = path.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i)
95
+ if (m?.[1]) sid = m[1]
96
+ }
97
+ if (!sid) return
98
+
99
+ let acc = this.accs.get(sid)
100
+ if (!acc) {
101
+ acc = newAcc(sid)
102
+ this.accs.set(sid, acc)
103
+ }
104
+
105
+ const ts = obj.timestamp ? (typeof obj.timestamp === "number" ? obj.timestamp : Date.parse(obj.timestamp)) : 0
106
+ if (ts > acc.timeUpdated) acc.timeUpdated = ts
107
+ if (acc.timeCreated === 0 || (ts > 0 && ts < acc.timeCreated)) acc.timeCreated = ts
108
+
109
+ const p = payload ?? {}
110
+ if (p.cwd) acc.directory = String(p.cwd)
111
+ if (p.model) acc.model = String(p.model)
112
+
113
+ if (p.type === "token_count") {
114
+ const info = p.info ?? p
115
+ // cumulative totals: overwrite, never sum
116
+ const total = info.total_token_usage ?? info.totalTokenUsage ?? info.usage
117
+ if (total && typeof total === "object") {
118
+ const pick = (...names: string[]): number => {
119
+ for (const n of names) if (typeof total[n] === "number") return total[n]
120
+ return 0
121
+ }
122
+ acc.tokens.input = pick("input_tokens", "inputTokens", "input")
123
+ acc.tokens.cacheRead = pick("cached_input_tokens", "cachedInputTokens", "cache_read", "cached")
124
+ acc.tokens.output = pick("output_tokens", "outputTokens", "output")
125
+ acc.tokens.reasoning = pick("reasoning_output_tokens", "reasoning_tokens", "reasoningOutputTokens", "reasoning")
126
+ }
127
+ } else if (p.type === "turn_context" && p.model) {
128
+ acc.model = String(p.model)
129
+ }
130
+ }
131
+ }