From 9780ec6a7f4286b4b5a87432720a3d00f7fa3c73 Mon Sep 17 00:00:00 2001
From: KunWei <2088220700@qq.com>
Date: Sun, 16 Aug 2026 14:02:14 +0800
Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20TRAE=20Work?=
=?UTF-8?q?=20CN=20Token=20=E7=BB=9F=E8=AE=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.de.md | 15 +-
README.ja.md | 13 +-
README.ko.md | 13 +-
README.md | 17 +-
README.zh-CN.md | 13 +-
.../tokentracker-account-devices.ts | 2 +-
.../tokentracker-leaderboard-profile.ts | 2 +-
dashboard/index.html | 22 +-
dashboard/public/llms.txt | 5 +-
dashboard/src/lib/provider-display.js | 1 +
dashboard/src/lib/provider-display.test.js | 7 +
.../ui/dashboard/components/ProviderIcon.jsx | 21 +
.../components/ProviderIcon.test.jsx | 14 +
dashboard/src/ui/marketing/agent-logos.js | 1 +
docs/PRIVACY.md | 7 +-
package.json | 2 +-
scripts/ops/account-usage-grouped-rpc.sql | 2 +-
scripts/ops/leaderboard-usage-grouped-rpc.sql | 2 +-
src/commands/init.js | 3 +-
src/commands/sync.js | 69 +++
src/lib/rollout.js | 304 ++++++++++
src/lib/source-metadata.js | 2 +-
src/lib/trae-cn-config.js | 433 +++++++++++++
test/discovery-metadata.test.js | 36 +-
test/source-metadata.test.js | 54 ++
test/sync-trae-cn.test.js | 260 ++++++++
test/trae-cn-config.test.js | 570 ++++++++++++++++++
test/trae-cn-parser.test.js | 504 ++++++++++++++++
28 files changed, 2324 insertions(+), 70 deletions(-)
create mode 100644 src/lib/trae-cn-config.js
create mode 100644 test/source-metadata.test.js
create mode 100644 test/sync-trae-cn.test.js
create mode 100644 test/trae-cn-config.test.js
create mode 100644 test/trae-cn-parser.test.js
diff --git a/README.de.md b/README.de.md
index d889aa706..0d7a76c32 100644
--- a/README.de.md
+++ b/README.de.md
@@ -6,7 +6,7 @@
### Sieh genau, was du für KI ausgibst – über jedes CLI hinweg
-Sammle automatisch Token-Zahlen von **31 KI-Coding-Tools**, aggregiere sie lokal und sieh echte Kostentrends in einem schönen Dashboard. Kein Cloud-Konto, keine API-Keys, kein Setup – nur ein Befehl.
+Sammle automatisch Token-Zahlen von **32 KI-Coding-Tools**, aggregiere sie lokal und sieh echte Kostentrends in einem schönen Dashboard. Kein TokenTracker-Konto, keine API-Keys, kein Setup – nur ein Befehl.
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -51,7 +51,7 @@ Das war's. Beim ersten Start werden Hooks installiert, deine Daten synchronisier
- 📊 Ein lokales Dashboard auf `localhost:7680` mit Nutzungstrends, Modellaufschlüsselung, Kostenanalyse
- 🔌 Auto-erkannte Hooks für jedes installierte KI-Tool
-- 🏠 100 % lokal – kein Konto, keine API-Keys, keine Netzwerkaufrufe (außer optionalem Leaderboard)
+- 🏠 Local-first – kein TokenTracker-Konto und keine API-Keys; optionale Netzwerkanfragen umfassen das Leaderboard und die schreibgeschützte TRAE Work CN-Nutzungsabfrage
- 🧩 *Optional:* Ein Skills-Tab zum Durchsuchen von 250+ öffentlichen Skills – synchronisiert über Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes
> **Möchtest du eine native Desktop-App?**
@@ -88,8 +88,8 @@ Aktualisieren mit `brew upgrade --cask xiufengsun/tokentracker/tokentracker`. De
## ✨ Features
-- 🔌 **31 KI-Tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness
-- 🏠 **100 % lokal** — Token-Daten verlassen nie deinen Rechner. Kein Konto, keine API-Keys.
+- 🔌 **32 KI-Tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN
+- 🏠 **Local-first** — Token-Daten bleiben auf deinem Rechner, außer bei der schreibgeschützten TRAE Work CN-Nutzungsabfrage. Kein TokenTracker-Konto, keine API-Keys; die Abfrage verwendet die Sitzung der lokal angemeldeten App und liest nur Nutzungsmetadaten.
- 🚀 **Zero Config** — Hooks installieren sich beim ersten Start automatisch. Von null zum Dashboard in 30 Sekunden.
- 📊 **Schönes Dashboard** — Nutzungstrends, Kostenaufschlüsselung nach Modell, GitHub-ähnliche Aktivitäts-Heatmap, Projektzuordnung
- 🖥️ **Native Desktop-App** — macOS Menüleiste (+ Widgets) und Windows System Tray, jeweils mit eingebautem Server und Dashboard in einer nativen WebView
@@ -194,6 +194,7 @@ Aktualisieren mit `brew upgrade --cask xiufengsun/tokentracker/tokentracker`. De
| **Qoder** | ✅ Auto | Passiver SQLite-Reader (`Qoder/SharedClientCache/cache/db/local.db`; liest nur assistant-`token_info`, trennt Cache-Eingaben und liest keine Prompts oder Antworten) sowie Plan Credits und Ultimate-Gratisaufrufe aus der lokalen Qoder-Sitzung |
| **AnythingLLM Desktop** | ✅ Auto | Passiver SQLite-Reader (`anythingllm-desktop/storage/anythingllm.db`, nur Token-Metriken pro Nachricht) |
| **Claude Science** | ✅ Auto | Passiver SQLite-Reader (`~/.claude-science/operon-cli.db`, nur die Token-Zähler der `frames`-Tabelle; keine Prompts, Artefakte oder Forschungsinhalte). Kein natives Windows-Build — unter Windows läuft die App in WSL und wird von dort gelesen. |
+| **TRAE Work CN** | ✅ Auto | Liest unter macOS die Session-Token-Nutzung der lokal angemeldeten App; die interne API kann sich ändern |
> **Muss ich Plugins oder Hooks manuell installieren?** Nein. `tokentracker` (oder `tokentracker init`) erledigt alles beim ersten Start:
> - **Hook-basiert** (Claude Code, Codex, Gemini, Every Code, CodeBuddy, WorkBuddy, Grok Build) — wir schreiben einen SessionEnd-Hook oder TOML-Notify-Eintrag in die Konfiguration des Tools.
@@ -210,11 +211,11 @@ Fehlt dein Tool? [Erstelle ein Issue](https://github.com/xiufengsun/TokenTracker
## 🆚 Warum TokenTracker?
-> **Suchst du eine ccusage-Alternative mit GUI?** TokenTracker unterstützt 31 Tools (nicht nur Claude Code), bietet native macOS- und Windows-Apps + Desktop-Widgets und dedupliziert Token-Datensätze korrekt über alle Provider hinweg – damit deine Zahlen mit dem Billing der Provider übereinstimmen.
+> **Suchst du eine ccusage-Alternative mit GUI?** TokenTracker unterstützt 32 Tools (nicht nur Claude Code), bietet native macOS- und Windows-Apps + Desktop-Widgets und dedupliziert Token-Datensätze korrekt über alle Provider hinweg – damit deine Zahlen mit dem Billing der Provider übereinstimmen.
| | **TokenTracker** | ccusage | Cursor Stats |
|---|---|---|---|
-| **Unterstützte KI-Tools** | **31** | 1 (Claude) | 1 (Cursor) |
+| **Unterstützte KI-Tools** | **32** | 1 (Claude) | 1 (Cursor) |
| **Lokal, kein Konto** | ✅ | ✅ | ❌ |
| **Native Desktop-App** | ✅ macOS + Windows | ❌ | ❌ |
| **Desktop-Widgets** | ✅ 4 Widgets | ❌ | ❌ |
@@ -229,7 +230,7 @@ Fehlt dein Tool? [Erstelle ein Issue](https://github.com/xiufengsun/TokenTracker
```mermaid
flowchart LR
- A["KI-Coding-Tools Claude · Codex · Cursor · Gemini · Kiro OpenCode · OpenClaw · Every Code · Hermes · Copilot Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM"]
+ A["KI-Coding-Tools Claude · Codex · Cursor · Gemini · Kiro OpenCode · OpenClaw · Every Code · Hermes · Copilot Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science · DeepSeek Harness · TRAE Work CN"]
A -->|Hooks lösen aus| B[Token Tracker]
B -->|Logs parsen 30-Min-UTC-Buckets| C[(Lokales SQLite)]
C --> D[Web-Dashboard]
diff --git a/README.ja.md b/README.ja.md
index 6b9481e2b..8bd89b2b8 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -6,7 +6,7 @@
### AI に使ったコストを正確に把握 — すべての CLI を横断して
-**31 種類の AI コーディングツール**からトークン数を自動収集し、ローカルで集計、美しいダッシュボードで本当のコスト推移を可視化します。クラウドアカウント不要、API キー不要、セットアップ不要 — コマンド 1 つで完了です。
+**32 種類の AI コーディングツール**からトークン数を自動収集し、ローカルで集計、美しいダッシュボードで本当のコスト推移を可視化します。TokenTracker アカウント不要、API キー不要、セットアップ不要 — コマンド 1 つで完了です。
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -50,7 +50,7 @@ npx tokentracker-cli
**30 秒で手に入るもの:**
- 📊 `localhost:7680` のローカルダッシュボードで、使用トレンド、モデル別内訳、コスト分析が見える
- 🔌 インストール済みの対応 AI ツールすべてに対する hook を自動検出
-- 🏠 100% ローカル — アカウント不要、API キー不要、ネットワーク通信なし(オプションのリーダーボードを除く)
+- 🏠 ローカルファースト — TokenTracker アカウント不要、API キー不要。オプションのネットワーク通信にはリーダーボードと TRAE Work CN の読み取り専用の使用量リクエストがあります
- 🧩 *オプション:* 250+ の公開 Skill を閲覧して Claude · Codex · Gemini · OpenCode · Hermes 間で同期できる Skills タブ
> **ネイティブのデスクトップアプリが欲しい?**
@@ -88,8 +88,8 @@ brew install xiufengsun/tokentracker/tokentracker
## ✨ 機能
-- 🔌 **31 種類の AI ツールを標準対応** — Claude Code、Codex CLI、Cursor、Gemini CLI、Antigravity、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness
-- 🏠 **100% ローカル** — トークンデータがマシンから外に出ることはありません。アカウント不要、API キー不要。
+- 🔌 **32 種類の AI ツールを標準対応** — Claude Code、Codex CLI、Cursor、Gemini CLI、Antigravity、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness、TRAE Work CN
+- 🏠 **ローカルファースト** — TRAE Work CN の読み取り専用の使用量リクエストを除き、トークンデータはマシンから外に出ません。TokenTracker アカウント不要、API キー不要。このリクエストはローカルでサインイン済みのアプリセッションを使い、使用量メタデータのみを読み取ります。
- 🚀 **ゼロコンフィグ** — Hook は初回実行で自動インストール。0 からダッシュボードまで 30 秒。
- 📊 **美しいダッシュボード** — 使用トレンド、モデル別コスト内訳、GitHub スタイルのアクティビティヒートマップ、プロジェクト別の帰属表示
- 🖥️ **ネイティブデスクトップアプリ** — macOS メニューバー(ウィジェット付き)と Windows システムトレイ。それぞれ組み込みサーバーとネイティブ WebView のダッシュボードを備えます
@@ -195,6 +195,7 @@ brew install xiufengsun/tokentracker/tokentracker
| **Qoder** | ✅ 自動 | パッシブ SQLite リーダー (`Qoder/SharedClientCache/cache/db/local.db`; assistant の `token_info` のみを読み、キャッシュ入力を分離。prompt/response は読みません) と、Qoder ローカルセッションからの Plan Credits / Ultimate 無料呼び出し上限 |
| **AnythingLLM Desktop** | ✅ 自動 | パッシブな SQLite リーダー (`anythingllm-desktop/storage/anythingllm.db`、メッセージごとの token 指標のみ) |
| **Claude Science** | ✅ 自動 | パッシブな SQLite リーダー (`~/.claude-science/operon-cli.db`、`frames` テーブルの token カウンタのみ。prompt・成果物・研究内容は読みません)。ネイティブ Windows 版はなく、Windows では WSL 内で動作するアプリを読み取ります。 |
+| **TRAE Work CN** | ✅ 自動 | macOS のローカルサインイン済みアプリから session-token 使用量を読み取ります。内部 API は変更される可能性があります |
> **プラグインや hook を手動でインストールする必要はありますか?** いいえ。`tokentracker`(または `tokentracker init`)が初回実行ですべて処理します:
> - **Hook ベース**のツール (Claude Code、Codex、Gemini、Every Code、**CodeBuddy**、**WorkBuddy**、**Grok Build**) — ツール自身の設定に SessionEnd hook または TOML notify エントリーを書き込みます。
@@ -214,7 +215,7 @@ brew install xiufengsun/tokentracker/tokentracker
| | **TokenTracker** | ccusage | Cursor stats |
|--------------------------|:---:|:---:|:---:|
-| **対応 AI ツール数** | **31** | 1 (Claude) | 1 (Cursor) |
+| **対応 AI ツール数** | **32** | 1 (Claude) | 1 (Cursor) |
| **ローカルファースト、アカウント不要** | ✅ | ✅ | ❌ |
| **ネイティブデスクトップアプリ** | ✅ macOS + Windows | ❌ | ❌ |
| **デスクトップウィジェット** | ✅ 4 種類 | ❌ | ❌ |
@@ -226,7 +227,7 @@ brew install xiufengsun/tokentracker/tokentracker
```mermaid
flowchart LR
- A["AI coding tools Claude Code · Codex · Cursor · Gemini · Kiro OpenCode · OpenClaw · Every Code · Hermes · Copilot Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM"]
+ A["AI coding tools Claude Code · Codex · Cursor · Gemini · Kiro OpenCode · OpenClaw · Every Code · Hermes · Copilot Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science · DeepSeek Harness · TRAE Work CN"]
A -->|hooks trigger| B[Token Tracker]
B -->|parse logs 30-min UTC buckets| C[(Local SQLite)]
C --> D[Web Dashboard]
diff --git a/README.ko.md b/README.ko.md
index 394c9c4f0..d7406d361 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -6,7 +6,7 @@
### 모든 CLI에서 AI에 쓰는 비용을 정확히 파악
-**31개의 AI 코딩 도구**에서 토큰 수치를 자동으로 수집하고 로컬에서 집계해, 실제 비용 추세를 아름다운 대시보드에서 확인. 클라우드 계정 불필요, API Key 불필요, 셋업 불필요 — 명령 한 줄이면 끝.
+**32개의 AI 코딩 도구**에서 토큰 수치를 자동으로 수집하고 로컬에서 집계해, 실제 비용 추세를 아름다운 대시보드에서 확인. TokenTracker 계정 불필요, API Key 불필요, 셋업 불필요 — 명령 한 줄이면 끝.
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -50,7 +50,7 @@ npx tokentracker-cli
**30초 안에 얻는 것:**
- 📊 사용 추세, 모델별 분석, 비용 분석을 보여주는 `localhost:7680`의 로컬 대시보드
- 🔌 설치된 모든 지원 AI 도구에 대한 hook 자동 감지
-- 🏠 100% 로컬 — 계정 없음, API Key 없음, 네트워크 호출 없음 (옵션 리더보드 제외)
+- 🏠 로컬 우선 — TokenTracker 계정과 API Key가 필요 없습니다. 옵션 네트워크 요청에는 리더보드와 TRAE Work CN의 읽기 전용 사용량 요청이 포함됩니다
- 🧩 *옵션:* 250+개의 공개 Skill을 둘러보고 Claude · Codex · Gemini · OpenCode · Hermes 간에 동기화할 수 있는 Skills 탭
> **네이티브 데스크톱 앱이 필요하다면?**
@@ -88,8 +88,8 @@ brew install xiufengsun/tokentracker/tokentracker
## ✨ 기능
-- 🔌 **31개의 AI 도구 기본 지원** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness
-- 🏠 **100% 로컬** — 토큰 데이터가 기기를 떠나지 않습니다. 계정 없음, API Key 없음.
+- 🔌 **32개의 AI 도구 기본 지원** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN
+- 🏠 **로컬 우선** — TRAE Work CN의 읽기 전용 사용량 요청을 제외하면 토큰 데이터가 기기를 떠나지 않습니다. TokenTracker 계정과 API Key가 필요 없으며, 이 요청은 로컬 로그인 앱 세션을 사용해 사용량 메타데이터만 읽습니다.
- 🚀 **제로 설정** — 첫 실행 시 Hook 자동 설치. 0에서 대시보드까지 30초.
- 📊 **아름다운 대시보드** — 사용 추세, 모델별 비용 분석, GitHub 스타일 활동 히트맵, 프로젝트 귀속 정보
- 🖥️ **네이티브 데스크톱 앱** — macOS 메뉴바(위젯 포함)와 Windows 시스템 트레이. 각각 임베디드 서버와 네이티브 WebView 대시보드를 제공합니다
@@ -195,6 +195,7 @@ brew install xiufengsun/tokentracker/tokentracker
| **Qoder** | ✅ 자동 | 패시브 SQLite 리더 (`Qoder/SharedClientCache/cache/db/local.db`; assistant `token_info`만 읽고 캐시 입력을 분리하며 prompt/response는 읽지 않음) 및 Qoder 로컬 세션의 Plan Credits / Ultimate 무료 호출 한도 |
| **AnythingLLM Desktop** | ✅ 자동 | 패시브 SQLite 리더 (`anythingllm-desktop/storage/anythingllm.db`, 메시지별 token 지표만 읽음) |
| **Claude Science** | ✅ 자동 | 패시브 SQLite 리더 (`~/.claude-science/operon-cli.db`, `frames` 테이블의 token 카운터만 읽으며 prompt·산출물·연구 내용은 읽지 않음). 네이티브 Windows 빌드가 없어 Windows에서는 WSL 안에서 실행되는 앱을 읽습니다. |
+| **TRAE Work CN** | ✅ 자동 | macOS에서 로컬 로그인된 앱의 session-token 사용량을 읽습니다. 내부 API는 변경될 수 있습니다 |
> **플러그인이나 hook을 수동으로 설치해야 하나요?** 아니요. `tokentracker` (또는 `tokentracker init`)가 첫 실행에서 모든 것을 처리합니다:
> - **Hook 기반** 도구 (Claude Code, Codex, Gemini, Every Code, **CodeBuddy**, **WorkBuddy**, **Grok Build**) — 도구 자체의 설정에 SessionEnd hook 또는 TOML notify 엔트리를 작성합니다.
@@ -214,7 +215,7 @@ brew install xiufengsun/tokentracker/tokentracker
| | **TokenTracker** | ccusage | Cursor stats |
|--------------------------|:---:|:---:|:---:|
-| **지원하는 AI 도구 수** | **31** | 1 (Claude) | 1 (Cursor) |
+| **지원하는 AI 도구 수** | **32** | 1 (Claude) | 1 (Cursor) |
| **로컬 우선, 계정 불필요** | ✅ | ✅ | ❌ |
| **네이티브 데스크톱 앱** | ✅ macOS + Windows | ❌ | ❌ |
| **데스크톱 위젯** | ✅ 4종 | ❌ | ❌ |
@@ -226,7 +227,7 @@ brew install xiufengsun/tokentracker/tokentracker
```mermaid
flowchart LR
- A["AI coding tools Claude Code · Codex · Cursor · Gemini · Kiro OpenCode · OpenClaw · Every Code · Hermes · Copilot Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM"]
+ A["AI coding tools Claude Code · Codex · Cursor · Gemini · Kiro OpenCode · OpenClaw · Every Code · Hermes · Copilot Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science · DeepSeek Harness · TRAE Work CN"]
A -->|hooks trigger| B[Token Tracker]
B -->|parse logs 30-min UTC buckets| C[(Local SQLite)]
C --> D[Web Dashboard]
diff --git a/README.md b/README.md
index b3cd67cad..21a6dce28 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,9 @@
### Track every AI token — then bring your usage to life
-An accurate, local-first token usage and cost dashboard for **31 AI coding tools** — plus a desktop pet, **4 native widgets**, and **15 achievement tracks**. No cloud account, no API keys, no setup.
+An accurate, local-first token usage and cost dashboard for **32 AI coding tools** — plus a desktop pet, **4 native widgets**, and **15 achievement tracks**. No TokenTracker account, no API keys, no setup.
+
+> **TRAE Work CN note:** On macOS, TokenTracker reads real session-token usage from the locally signed-in TRAE Work CN app. Its internal API may change.
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -85,7 +87,7 @@ That's it. First run installs hooks, syncs your data, and opens the dashboard at
**What you get in 30 seconds:**
- 📊 A local dashboard at `localhost:7680` with usage trends, model breakdown, cost analysis
- 🔌 Auto-detected hooks for every supported AI tool you have installed
-- 🏠 100% local — no account, no API keys, no network calls (except optional leaderboard)
+- 🏠 Local-first — no TokenTracker account or API keys; optional network requests include the leaderboard and the read-only TRAE Work CN usage request
- 🧩 *Optional:* a Skills tab that browses 250+ public skills and syncs them across Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes
> **Want a native desktop app?**
@@ -138,8 +140,8 @@ An Arch `PKGBUILD` for a local pacman install lives in `TokenTrackerLinux/packag
## ✨ Features
-- 🔌 **31 AI tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness
-- 🏠 **100% local** — Token data never leaves your machine. No account, no API keys.
+- 🔌 **32 AI tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN
+- 🏠 **Local-first** — Token data stays on your machine except for the read-only TRAE Work CN usage request. No TokenTracker account, no API keys; the request uses the locally signed-in app session and reads usage metadata only.
- 🚀 **Zero config** — Hooks auto-install on first run. From zero to dashboard in 30 seconds.
- 📊 **Beautiful dashboard** — Usage trends, cost breakdowns by model, GitHub-style activity heatmap, project attribution
- 🖥️ **Native desktop app** — macOS menu bar (+ widgets) and Windows system tray, each with an embedded server and the dashboard in a native webview
@@ -258,6 +260,7 @@ An Arch `PKGBUILD` for a local pacman install lives in `TokenTrackerLinux/packag
| **AnythingLLM Desktop** | ✅ Auto | Passive SQLite reader (`anythingllm-desktop/storage/anythingllm.db`; reads per-message token metrics only, never prompts or responses) |
| **Claude Science** | ✅ Auto | Passive SQLite reader (`~/.claude-science/operon-cli.db`; reads the `frames` table's token counters only, never prompts, artifacts or research content). No native Windows build — on Windows the app runs inside WSL and is read from there. |
| **DeepSeek Harness** | ✅ Auto | Passive session reader (`~/.dsh/sessions/**/session.jsonl[.zstd]`; parses session header and assistant events, handles multi-frame zstd decompression) |
+| **TRAE Work CN** | ✅ Auto | macOS session-token usage from the locally signed-in app; reads its internal API, which may change |
> **Do I need to install any plugin or hook manually?** No. `tokentracker` (or `tokentracker init`) handles everything on first run:
> - **Hook-based** tools (Claude Code, Codex, Gemini, Every Code, **CodeBuddy**, **WorkBuddy**, **Grok Build**) — we write a SessionEnd hook or TOML notify entry into the tool's own config.
@@ -276,11 +279,11 @@ Missing your tool? [Open an issue](https://github.com/xiufengsun/TokenTracker/is
## 🆚 Why TokenTracker?
-> **Looking for a ccusage alternative with a GUI?** TokenTracker covers 31 tools (not just Claude Code), adds native macOS and Windows apps + desktop widgets, and de-duplicates token records correctly across providers — so your numbers match the providers' own billing.
+> **Looking for a ccusage alternative with a GUI?** TokenTracker covers 32 tools (not just Claude Code), adds native macOS and Windows apps + desktop widgets, and de-duplicates token records correctly across providers — so your numbers match the providers' own billing.
| | **TokenTracker** | ccusage | Cursor stats |
|--------------------------|:---:|:---:|:---:|
-| **AI tools supported** | **31** | 1 (Claude) | 1 (Cursor) |
+| **AI tools supported** | **32** | 1 (Claude) | 1 (Cursor) |
| **Local-first, no account** | ✅ | ✅ | ❌ |
| **Native desktop app** | ✅ macOS + Windows | ❌ | ❌ |
| **Desktop widgets** | ✅ 4 widgets | ❌ | ❌ |
@@ -295,7 +298,7 @@ Missing your tool? [Open an issue](https://github.com/xiufengsun/TokenTracker/is
```mermaid
flowchart LR
- A["AI coding tools Claude Code · Codex · Cursor · Gemini · Kiro OpenCode · OpenClaw · Every Code · Hermes · Copilot Kimi Code · CodeBuddy · WorkBuddy · Grok Build · Kilo CLI · Kilo Code Antigravity · oh-my-pi · pi · Craft · Roo · Zed · Goose · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science"]
+ A["AI coding tools Claude Code · Codex · Cursor · Gemini · Kiro OpenCode · OpenClaw · Every Code · Hermes · Copilot Kimi Code · CodeBuddy · WorkBuddy · Grok Build · Kilo CLI · Kilo Code Antigravity · oh-my-pi · pi · Craft · Roo · Zed · Goose · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science · DeepSeek Harness · TRAE Work CN"]
A -->|hooks trigger| B[Token Tracker]
B -->|parse logs 30-min UTC buckets| C[(Local SQLite)]
C --> D[Web Dashboard]
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 657b89f81..b849b5ce6 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -6,7 +6,7 @@
### 跨所有 CLI,看清你到底在 AI 上花了多少钱
-自动采集 **31 款 AI 编码工具** 的 token 用量,全程本地聚合,用一套漂亮的 Dashboard 看真实成本与趋势。不需要云账号、不需要 API Key、不需要任何配置 —— 一条命令搞定。
+自动采集 **32 款 AI 编码工具** 的 token 用量,全程本地聚合,用一套漂亮的 Dashboard 看真实成本与趋势。不需要 TokenTracker 账号、不需要 API Key、不需要任何配置 —— 一条命令搞定。
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -50,7 +50,7 @@ npx tokentracker-cli
**30 秒能看到什么:**
- 📊 本地 Dashboard(`localhost:7680`)—— 用量趋势、模型明细、成本分析
- 🔌 自动识别并挂接你电脑上所有已安装的 AI 工具
-- 🏠 100% 本地运行 —— 无账号、无 API Key、无网络请求(排行榜是可选的)
+- 🏠 本地优先运行 —— 无需 TokenTracker 账号或 API Key;可选网络请求包括排行榜和 TRAE Work CN 的只读用量请求
- 🧩 *可选:* Skills 面板——浏览 250+ 公开 skill,并在 Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes 之间同步
> **想要原生桌面 App?**
@@ -90,8 +90,8 @@ brew install xiufengsun/tokentracker/tokentracker
## ✨ 特性
-- 🔌 **开箱即用支持 31 款 AI 工具** —— Claude Code、Codex CLI、Cursor、Gemini CLI、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Antigravity、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness
-- 🏠 **100% 本地** —— Token 数据绝不离开你的机器。无账号、无 API Key
+- 🔌 **开箱即用支持 32 款 AI 工具** —— Claude Code、Codex CLI、Cursor、Gemini CLI、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Antigravity、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness、TRAE Work CN
+- 🏠 **本地优先** —— Token 数据留在你的机器上,TRAE Work CN 的只读用量请求除外。无需 TokenTracker 账号或 API Key;该请求使用本地已登录应用的现有会话授权,仅读取用量元数据。
- 🚀 **零配置** —— 首次运行自动安装所有 hook。30 秒从零到 Dashboard
- 📊 **漂亮的 Dashboard** —— 用量趋势、按模型的成本分解、GitHub 风格活跃度热力图、按项目归因
- 🖥️ **原生桌面 App** —— macOS 菜单栏(含桌面小组件)与 Windows 系统托盘,各自内嵌服务 + 原生 WebView Dashboard
@@ -199,6 +199,7 @@ brew install xiufengsun/tokentracker/tokentracker
| **AnythingLLM Desktop** | ✅ 自动 | 被动 SQLite 读取(`anythingllm-desktop/storage/anythingllm.db`;只读取每条消息的 token 指标,不读取 prompt 或回复) |
| **Claude Science** | ✅ 自动 | 被动 SQLite 读取(`~/.claude-science/operon-cli.db`;只读取 `frames` 表的 token 计数,不读取 prompt、产物或研究内容)。没有原生 Windows 版——Windows 上该应用运行在 WSL 内,从 WSL 中读取。 |
| **DeepSeek Harness** | ✅ 自动 | 被动读取会话日志(`~/.dsh/sessions/**/session.jsonl[.zstd]`;解析会话头部与 assistant 事件,支持多帧 zstd 解压) |
+| **TRAE Work CN** | ✅ 自动 | macOS 上读取本地登录的 TRAE Work CN 应用的 session-token 用量;内部 API 可能变化 |
> **需要手动装什么插件 / hook 吗?** 不需要。`tokentracker`(或 `tokentracker init`)第一次跑的时候会全部搞定:
> - **基于 hook 的工具**(Claude Code、Codex、Gemini、Every Code、**CodeBuddy**、**WorkBuddy**、**Grok Build**)—— 我们把 SessionEnd hook 或 TOML notify 条目写入它们自己的配置文件
@@ -219,7 +220,7 @@ brew install xiufengsun/tokentracker/tokentracker
| | **TokenTracker** | ccusage | Cursor 自带统计 |
|--------------------------|:---:|:---:|:---:|
-| **支持的 AI 工具数** | **31** | 1(Claude) | 1(Cursor) |
+| **支持的 AI 工具数** | **32** | 1(Claude) | 1(Cursor) |
| **本地优先,无需账号** | ✅ | ✅ | ❌ |
| **原生桌面 App** | ✅ macOS + Windows | ❌ | ❌ |
| **桌面小组件** | ✅ 4 个小组件 | ❌ | ❌ |
@@ -231,7 +232,7 @@ brew install xiufengsun/tokentracker/tokentracker
```mermaid
flowchart LR
- A["AI 编码工具 Claude Code · Codex · Cursor · Gemini · Kiro OpenCode · OpenClaw · Every Code · Hermes · Copilot Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM"]
+ A["AI 编码工具 Claude Code · Codex · Cursor · Gemini · Kiro OpenCode · OpenClaw · Every Code · Hermes · Copilot Kimi · CodeBuddy · WorkBuddy · Grok · Kilo · Roo · Zed · Goose Antigravity · oh-my-pi · pi · Craft · Droid · Mimo · ZCode · Qoder · AnythingLLM · Claude Science · DeepSeek Harness · TRAE Work CN"]
A -->|hook 触发| B[Token Tracker]
B -->|解析日志 30 分钟 UTC 桶| C[(本地 SQLite)]
C --> D[Web Dashboard]
diff --git a/dashboard/edge-patches/tokentracker-account-devices.ts b/dashboard/edge-patches/tokentracker-account-devices.ts
index 9e7178104..b68a579fb 100644
--- a/dashboard/edge-patches/tokentracker-account-devices.ts
+++ b/dashboard/edge-patches/tokentracker-account-devices.ts
@@ -85,7 +85,7 @@ interface GroupedRow {
// branch intentionally ignores p_device_ids, so a per-device query would add
// the user's ENTIRE account-level total to EVERY device (the "N identical
// devices" skew). A device breakdown must therefore exclude them.
-const ACCOUNT_LEVEL_SOURCES = new Set(["cursor"]);
+const ACCOUNT_LEVEL_SOURCES = new Set(["cursor", "trae-cn"]);
async function sumDeviceTokens(
client: ReturnType,
diff --git a/dashboard/edge-patches/tokentracker-leaderboard-profile.ts b/dashboard/edge-patches/tokentracker-leaderboard-profile.ts
index 1fd8d9a1a..fa0a0804c 100644
--- a/dashboard/edge-patches/tokentracker-leaderboard-profile.ts
+++ b/dashboard/edge-patches/tokentracker-leaderboard-profile.ts
@@ -418,7 +418,7 @@ function canonicalSource(s: string) {
// RPC, and tokentracker-leaderboard-refresh.ts
// (parity: test/account-source-parity.test.js).
// deno-lint-ignore no-unused-vars
-const ACCOUNT_LEVEL_SOURCES = new Set(["cursor"]);
+const ACCOUNT_LEVEL_SOURCES = new Set(["cursor", "trae-cn"]);
// ─────────────────────────── Window bounds ──────────────────────────
function normalizeTimeZone(value: string | null): string | null {
diff --git a/dashboard/index.html b/dashboard/index.html
index 3dc01370b..946f6c9ec 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -123,7 +123,7 @@
"price": "0",
"priceCurrency": "USD"
},
- "description": "Local-first AI coding usage tracker for 31 tools, with a desktop pet, four widgets, achievements, native macOS and Windows apps, accurate cross-provider deduplication, and no prompt collection."
+ "description": "Local-first AI coding usage tracker for 32 tools, with a desktop pet, four widgets, achievements, native macOS and Windows apps, accurate cross-provider deduplication, and no prompt collection."
},
{
"@type": "WebSite",
@@ -194,7 +194,8 @@
{ "@type": "ListItem", "position": 28, "name": "Qoder" },
{ "@type": "ListItem", "position": 29, "name": "AnythingLLM Desktop" },
{ "@type": "ListItem", "position": 30, "name": "Claude Science" },
- { "@type": "ListItem", "position": 31, "name": "DeepSeek Harness" }
+ { "@type": "ListItem", "position": 31, "name": "DeepSeek Harness" },
+ { "@type": "ListItem", "position": 32, "name": "TRAE Work CN" }
]
},
{
@@ -212,7 +213,7 @@
"@type": "Quest "name": "Which AI coding CLIs does Token Tracker support?",
"acceptedAnswer": {
"@type": "Answer",
- "text": "Token Tracker supports 31 AI coding tools, including Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, and DeepSeek Harness."
+ "text": "Token Tracker supports 32 AI coding tools, including Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, and TRAE Work CN."
}
},
{
@@ -244,7 +245,7 @@
"name": "How do I monitor AI token usage and cost across tools?",
"acceptedAnswer": {
"@type": "Answer",
- "text": "Install Token Tracker with npx tokentracker-cli. It is an AI token usage and cost tracker that monitors token consumption and spend across all 31 supported tools in one dashboard, with per-model pricing across 2,200+ models so you can see cost by model, project, and time window without manual tracking."
+ "text": "Install Token Tracker with npx tokentracker-cli. It is an AI token usage and cost tracker that monitors token consumption and spend across all 32 supported tools in one dashboard, with per-model pricing across 2,200+ models so you can see cost by model, project, and time window without manual tracking."
}
},
{
@@ -266,7 +267,7 @@
Token Tracker: monitor AI token usage and cost with a local dashboard and desktop companion
Token Tracker is a free, open-source, local-first dashboard that automatically tracks and monitors token usage
- and cost across 31 AI coding tools — including Claude Code, OpenAI Codex, Cursor, Gemini CLI, and DeepSeek Harness. A desktop pet
+ and cost across 32 AI coding tools — including Claude Code, OpenAI Codex, Cursor, Gemini CLI, and TRAE Work CN. A desktop pet
reacts to real token activity, four desktop widgets
keep usage visible, and achievements turn coding history into progress. Native macOS and Windows apps
sit on top of accurate cross-provider deduplication. Token counts only — never prompts or conversations.
@@ -291,7 +292,7 @@
What you get after installing
Desktop pet — a pixel companion that reacts to real token burn, streaks, and rest.
Four desktop widgets — usage, activity heatmap, top models, and limits at a glance.
Achievements — unlock milestones from local and optional account-wide activity.
- Unified usage dashboard — input, output, cached, and cache-creation tokens across 31 AI coding tools.
+ Unified usage dashboard — input, output, cached, and cache-creation tokens across 32 AI coding tools.
Cost analysis — per-model pricing across 2,200+ models with an offline snapshot.
Usage limits view — live quota and subscription status for 13 providers, with last-good caching for local providers such as Antigravity and Qoder.
Service Status page — live operational and incident status from eight official provider status pages.
@@ -302,11 +303,11 @@ What you get after installing
Privacy by design : token counts only, never prompts or conversation content. Local-first; cloud sync is optional and powers only the public leaderboard.
- Supported AI coding tools (31)
+ Supported AI coding tools (32)
Use Token Tracker as a Claude Code token tracker, Codex token tracker, Cursor token tracker,
Gemini token tracker, OpenCode token tracker, GitHub Copilot token tracker, or DeepSeek Harness token tracker — one dashboard
- covers all 31 tools below.
+ covers all 32 tools below.
Claude Code — Anthropic's official coding agent CLI; integrated via SessionEnd hook.
@@ -340,6 +341,7 @@ Supported AI coding tools (31)
AnythingLLM Desktop — passive per-message token metrics reader.
Claude Science — passive reader for the frames table of operon-cli.db.
DeepSeek Harness — passive session reader (~/.dsh/sessions/**/session.jsonl[.zstd]).
+ TRAE Work CN — macOS signed-in app session-token reader; its internal API may change.
Who should use Token Tracker
@@ -370,7 +372,7 @@ How do I install Token Tracker?
Run npx tokentracker-cli. The CLI auto-detects your AI coding tools, installs hooks, and opens the dashboard.
Which AI coding tools does Token Tracker support?
- Token Tracker supports 31 tools: Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, and DeepSeek Harness.
+ Token Tracker supports 32 tools: Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, and TRAE Work CN.
Is Token Tracker free and open source?
Yes. Source on GitHub at github.com/xiufengsun/TokenTracker ; npm package tokentracker-cli.
@@ -382,7 +384,7 @@ What does Token Tracker track?
Input, output, cached, and cache-creation tokens per model, project, and time window. It also surfaces usage limits for 13 providers, including Claude, Codex, Cursor, Gemini, Antigravity, Qoder, and Qoder CN.
How do I monitor AI token usage and cost across tools?
- Token Tracker is an AI token usage and cost tracker: install it with npx tokentracker-cli, and it monitors token consumption and spend across all 31 tools in one dashboard, with per-model pricing across 2,200+ models so you can see cost by model, project, and time window without manual tracking.
00+ models so you can see cost by model, project, and time window without manual tracking.
+ Token Tracker is an AI token usage and cost tracker: install it with npx tokentracker-cli, and it monitors token consumption and spend across all 32 tools in one dashboard, with per-model pricing across 2,200+ models so you can see cost by model, project, and time window without manual tracking.
Does Token Tracker work offline?
Yes. Token Tracker is local-first. All parsing, aggregation, and dashboard rendering happen on your machine. Cloud sync is optional and powers only the public leaderboard.
diff --git a/dashboard/public/llms.txt b/dashboard/public/llms.txt
index fb13873e2..b7d28abc0 100644
--- a/dashboard/public/llms.txt
+++ b/dashboard/public/llms.txt
@@ -1,7 +1,7 @@
# Token Tracker LLMs Index
Token Tracker is a free, open-source, local-first AI coding usage and cost
-tracker for 31 tools. Its distinctive desktop experience includes a pixel pet
+tracker for 32 tools. Its distinctive desktop experience includes a pixel pet
powered by real token activity, four desktop widgets, and achievements. The
same data powers a cross-platform CLI, a native macOS menu bar app, a native
Windows system tray app, and a dashboard. It records token counts and
@@ -23,7 +23,7 @@ timestamps, never prompts, responses, or source code.
- [npm package](https://www.npmjs.com/package/tokentracker-cli)
- [Latest release](https://github.com/xiufengsun/TokenTracker/releases/latest)
-## Supported AI coding tools (31)
+## Supported AI coding tools (32)
- Claude Code
- Codex CLI
- Cursor
@@ -55,6 +55,7 @@ timestamps, never prompts, responses, or source code.
- AnythingLLM Desktop
- Claude Science
- DeepSeek Harness
+- TRAE Work CN
## Capabilities
- Desktop pet that reacts to real token burn, streaks, and rest
diff --git a/dashboard/src/lib/provider-display.js b/dashboard/src/lib/provider-display.js
index c80e75286..c1b985f29 100644
--- a/dashboard/src/lib/provider-display.js
+++ b/dashboard/src/lib/provider-display.js
@@ -6,6 +6,7 @@ const SPECIAL_PROVIDER_NAMES = {
pianthropic: "Pi · Anthropic",
pigithubcopilot: "Pi · GitHub Copilot",
picopilot: "Pi · Copilot",
+ traecn: "TRAE Work CN",
};
const SPECIAL_PROVIDER_COPY_KEYS = {
diff --git a/dashboard/src/lib/provider-display.test.js b/dashboard/src/lib/provider-display.test.js
index c83ba5d09..611a82e84 100644
--- a/dashboard/src/lib/provider-display.test.js
+++ b/dashboard/src/lib/provider-display.test.js
@@ -29,4 +29,11 @@ describe("formatProviderDisplayName", () => {
expect(formatProviderDisplayName("dsh")).toBe("DeepSeek Harness");
expect(formatProviderDisplayName("deepseek")).toBe("DeepSeek Harness");
});
+
+ it.each(["trae-cn", "TRAE-CN", "Trae_Cn"])(
+ "formats %s as TRAE Work CN",
+ (value) => {
+ expect(formatProviderDisplayName(value)).toBe("TRAE Work CN");
+ },
+ );
});
diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx
index 5c8986ae5..f419d1bc4 100644
--- a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx
+++ b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx
@@ -117,6 +117,26 @@ function CodeBuddyIcon({ size = 16, className = "" }) {
);
}
+// Compact local monogram for TRAE Work CN. It is deliberately not a claimed
+// official asset: the single-color mark stays legible in the existing 16–20px
+// slots and inherits currentColor in both themes.
+function TraeCnIcon({ size = 16, className = "" }) {
+ return (
+
+
+
+
+ );
+}
+
function HermesIcon({ size = 16, className = "" }) {
return (
@@ -313,6 +333,7 @@ const PROVIDER_ICON_MAP = {
"PI-GITHUB-COPILOT": PiIcon,
"PI-COPILOT": PiIcon,
ROOCODE: RoocodeIcon,
+ "TRAE-CN": TraeCnIcon,
ZED: ZedIcon,
};
diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx
index 22adc68ab..f8e4dc2a1 100644
--- a/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx
+++ b/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx
@@ -77,6 +77,20 @@ describe("ProviderIcon", () => {
expect(icon?.querySelector('path[fill="currentColor"]')).not.toBeNull();
}
});
+
+ it("renders the compact TRAE CN mark instead of the unknown-provider placeholder", () => {
+ const { container } = render( );
+ const icon = container.querySelector('svg[data-brand="trae-cn"]');
+
+ expect(icon).not.toBeNull();
+ expect(icon).toHaveAttribute("width", "20");
+ expect(icon).toHaveAttribute("height", "20");
+ expect(icon).toHaveAttribute("aria-hidden", "true");
+ expect(icon).toHaveAttribute("fill", "currentColor");
+ expect(icon?.querySelector("path")).not.toBeNull();
+ expect(icon?.querySelector("circle")).toBeNull();
+ expect(container.querySelector(".text-oai-gray-400")).toBeNull();
+ });
});
it("renders the Qoder CN green-crescent mark from its own asset", () => {
diff --git a/dashboard/src/ui/marketing/agent-logos.js b/dashboard/src/ui/marketing/agent-logos.js
index 3ddaf597f..5481d3e7b 100644
--- a/dashboard/src/ui/marketing/agent-logos.js
+++ b/dashboard/src/ui/marketing/agent-logos.js
@@ -33,4 +33,5 @@ export const AGENT_LOGOS = [
{ id: 29, name: "AnythingLLM", provider: "anythingllm" },
{ id: 30, name: "Claude Science", provider: "claude-science" },
{ id: 31, name: "DeepSeek Harness", provider: "dsh" },
+ { id: 32, name: "TRAE Work CN", provider: "trae-cn" },
];
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
index 01a372298..2f5bf6ac5 100644
--- a/docs/PRIVACY.md
+++ b/docs/PRIVACY.md
@@ -10,14 +10,14 @@ This document lists **every** network request the software can make, what each o
## 1. What never leaves your machine
-TokenTracker's parsers extract numbers and timestamps only.
+TokenTracker's parsers extract numbers and timestamps only. TRAE Work CN is a narrow exception to the local-only source model: during a usage sync, TokenTracker transmits the existing sign-in authorization from the locally signed-in app to TRAE's internal API for a read-only usage request. That authorization is never persisted or logged.
-**Never read, never recorded anywhere:**
+**Apart from the disclosed TRAE Work CN authorization, never read or recorded anywhere:**
- **Prompts, responses, and conversation bodies**
- **File contents** from your projects
- **Commit messages and diffs** — Git attribution runs `git log` locally, uses the subject line only to detect reverts, and keeps nothing
-- **API keys, cookies, and session tokens** belonging to your AI providers
+- **API keys, cookies, and session tokens** belonging to your AI providers are never persisted or logged by TokenTracker
**Recorded locally, never uploaded:**
@@ -54,6 +54,7 @@ To erase everything TokenTracker knows about you, delete that directory. `tokent
| **Anonymous heartbeat** | `srctyff5.us-east.insforge.app` | A one-way hash of the machine id, app version, OS platform, and shell (`cli` / `mac` / `win`). Nothing else. | At most once per day |
| **Dashboard analytics** | `us.i.posthog.com` (PostHog) | Pageviews and explicitly instrumented feature events, plus which shell you use. Autocapture and session recording are **off**; browser Do-Not-Track is respected. | While the dashboard is open |
| **Provider quota reads** | The provider's own API (`api.anthropic.com`, `chatgpt.com`, `cursor.com`, `api.github.com`, `api.kimi.com`, `api.z.ai`, `qoder.com`, `qoder.com.cn`, `openapi.qoder.sh`, `openapi.qoder.com.cn`, `cloudcode-pa.googleapis.com`, …) | Whatever that provider's own endpoint requires, authenticated with the credentials **that provider already stored on your machine**. These requests go directly from your machine to the provider — they never pass through our servers, and we never see the response. | While quota bars are visible |
+| **TRAE Work CN usage read** | TRAE's internal API | Transmits the existing sign-in authorization from the locally signed-in TRAE Work CN app to TRAE; reads usage metadata only. TokenTracker never persists or logs the auth token or prompt/response content. | During TRAE Work CN usage sync |
| **GitHub star count** | `api.github.com` | Nothing but the request itself (public repo metadata) | On dashboard load |
| **Update check** | `api.github.com` | Nothing but the request itself | Windows: once at launch. macOS: only when you click "Check for Updates" |
diff --git a/package.json b/package.json
index d564ff1f9..5f00e9213 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "tokentracker-cli",
"version": "0.88.9",
- "description": "Local-first AI coding token usage and cost tracker for 31 tools, with a dashboard, desktop pet, widgets, achievements, and native macOS/Windows apps.",
+ "description": "Local-first AI coding token usage and cost tracker for 32 tools, with a dashboard, desktop pet, widgets, achievements, and native macOS/Windows apps.",
"main": "src/cli.js",
"bin": {
"tokentracker-cli": "bin/tracker.js",
diff --git a/scripts/ops/account-usage-grouped-rpc.sql b/scripts/ops/account-usage-grouped-rpc.sql
index 7cc741e97..97e07347a 100644
--- a/scripts/ops/account-usage-grouped-rpc.sql
+++ b/scripts/ops/account-usage-grouped-rpc.sql
@@ -90,7 +90,7 @@ AS $func$
),
-- Account-level source list — keep in sync with src/lib/source-metadata.js.
cfg AS (
- SELECT ARRAY['cursor']::text[] AS account_sources
+ SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources
),
-- Stage 1: canonicalize to the raw hour grain.
hourly AS (
diff --git a/scripts/ops/leaderboard-usage-grouped-rpc.sql b/scripts/ops/leaderboard-usage-grouped-rpc.sql
index d8589a67d..5762abd68 100644
--- a/scripts/ops/leaderboard-usage-grouped-rpc.sql
+++ b/scripts/ops/leaderboard-usage-grouped-rpc.sql
@@ -62,7 +62,7 @@ SET hash_mem_multiplier TO '4'
AS $func$
WITH cfg AS (
-- Keep in sync with src/lib/source-metadata.js ACCOUNT_LEVEL_SOURCES.
- SELECT ARRAY['cursor']::text[] AS account_sources
+ SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources
),
-- Hour-grain canonical rows. TWO passes total (DISTINCT + the per_usm GROUP BY)
-- so the whole-history 'total' refresh stays within the edge's 30s budget --
diff --git a/src/commands/init.js b/src/commands/init.js
index 0abdcc7d1..28c41d1b6 100644
--- a/src/commands/init.js
+++ b/src/commands/init.js
@@ -102,7 +102,7 @@ const DIVIDER = "----------------------------------------------";
const DEFAULT_DASHBOARD_URL = "https://www.tokentracker.cc";
// Single source of truth for the welcome screen's provider count + sample list.
-// test/discovery-metadata.test.js keeps this aligned with public 27-tool copy.
+// test/discovery-metadata.test.js keeps this aligned with public 32-tool copy.
const SUPPORTED_PROVIDERS = [
"Claude Code",
"Codex CLI",
@@ -135,6 +135,7 @@ const SUPPORTED_PROVIDERS = [
"AnythingLLM Desktop",
"Claude Science",
"DeepSeek Harness",
+ "TRAE Work CN",
];
async function cmdInit(argv) {
diff --git a/src/commands/sync.js b/src/commands/sync.js
index 8d067060d..3d8f5b07c 100644
--- a/src/commands/sync.js
+++ b/src/commands/sync.js
@@ -110,6 +110,7 @@ const {
resolveDroidModel,
resolveDshSessionFiles,
parseDshIncremental,
+ parseTraeCnApiIncremental,
bucketKey,
toUtcHalfHourStart,
totalsKey,
@@ -132,6 +133,10 @@ const {
parseCursorCsv,
} = require("../lib/cursor-config");
const { purgeProjectUsage } = require("../lib/project-usage-purge");
+const {
+ fetchTraeCnUsageWithAuth,
+ resolveTraeCnStoragePath,
+} = require("../lib/trae-cn-config");
const {
isCodexSessionCursorPath,
isCursorStoreRetry,
@@ -289,6 +294,7 @@ const AUTO_SYNC_SOURCES = new Set([
"qoder-cn",
"reasonix",
"roocode",
+ "trae-cn",
"workbuddy",
"zcode",
"zed",
@@ -450,6 +456,15 @@ async function cmdSync(argv, context = {}) {
const lockWaitOptions = context && typeof context === "object"
? context.lockWaitOptions
: undefined;
+ // Narrow test-only seam for TRAE Work CN: deterministic injected fetch + a
+ // fixed "now" for the rolling range. Production keeps the global fetch and
+ // current time; no public CLI flag/config is introduced.
+ const traeCnFetchImpl = context && typeof context === "object"
+ ? context.traeCnFetchImpl
+ : undefined;
+ const traeCnNowMs = context && typeof context === "object"
+ ? context.traeCnNowMs
+ : undefined;
const syncDiagnostics = diagnostics && typeof diagnostics === "object" ? diagnostics : null;
const home = os.homedir();
const { trackerDir } = await resolveTrackerPaths({ home });
@@ -1661,6 +1676,58 @@ async function cmdSync(argv, context = {}) {
}
}
+ // ── Trae Work CN (国内版) — account-level usage API ──
+ // Phase C: a simple explicit rolling 30-day window captured once per sync
+ // (no cursor checkpoints / full-history import / adaptive splitting / page
+ // retries / background requests in this phase). Runs only for non-lightweight
+ // syncs where the source is allowed AND the CN storage path resolves — which
+ // excludes both ordinary background and `--auto --background
+ // --all-local-sources`. Fetching goes through the storage-backed Phase A
+ // helper so its single 401/403 reread/retry is used; an empty response is a
+ // successful no-op; any fetch/page/schema/auth failure skips the parser and
+ // leaves prior data untouched while unrelated providers continue.
+ let traeCnResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
+ if (
+ !isBackgroundLightweightSync &&
+ sourceAllowed("trae-cn") &&
+ resolveTraeCnStoragePath({ env: process.env, home })
+ ) {
+ const nowMs = Number.isFinite(traeCnNowMs) ? traeCnNowMs : Date.now();
+ const fetchImpl = typeof traeCnFetchImpl === "function" ? traeCnFetchImpl : fetch;
+ const endTime = Math.floor(nowMs / 1000);
+ const startTime = Math.max(1, endTime - 30 * 24 * 60 * 60);
+ try {
+ if (progress?.enabled) {
+ progress.start(`Fetching TRAE Work CN usage...`);
+ }
+ const traeCnUsage = await fetchTraeCnUsageWithAuth({
+ start_time: startTime,
+ end_time: endTime,
+ fetchImpl,
+ env: process.env,
+ home,
+ });
+ const traeCnSessions = Array.isArray(traeCnUsage?.sessions) ? traeCnUsage.sessions : [];
+ if (traeCnSessions.length > 0) {
+ if (progress?.enabled) {
+ progress.start(
+ `Parsing TRAE Work CN ${renderBar(0)} 0/${formatNumber(
+ traeCnSessions.length,
+ )} records | buckets 0`,
+ );
+ }
+ traeCnResult = await parseTraeCnApiIncremental({
+ sessions: traeCnSessions,
+ cursors,
+ queuePath,
+ onProgress: makeProviderProgress("TRAE Work CN"),
+ });
+ }
+ } catch (err) {
+ warnProviderParseFailure("TRAE Work CN", err, opts);
+ }
+ }
+
// ── Kiro (SQLite-based, with JSONL fallback; dual-install aware) ──
let kiroResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
if (sourceAllowed("kiro")) {
@@ -2554,6 +2621,7 @@ async function cmdSync(argv, context = {}) {
qoderCnResult.recordsProcessed +
claudeScienceResult.recordsProcessed +
cursorResult.recordsProcessed +
+ traeCnResult.recordsProcessed +
kiroResult.recordsProcessed +
kiroCliResult.recordsProcessed +
hermesResult.recordsProcessed +
@@ -2588,6 +2656,7 @@ async function cmdSync(argv, context = {}) {
qoderCnResult.bucketsQueued +
claudeScienceResult.bucketsQueued +
cursorResult.bucketsQueued +
+ traeCnResult.bucketsQueued +
kiroResult.bucketsQueued +
kiroCliResult.bucketsQueued +
hermesResult.bucketsQueued +
diff --git a/src/lib/rollout.js b/src/lib/rollout.js
index ef1f54c4e..32f126bb0 100644
--- a/src/lib/rollout.js
+++ b/src/lib/rollout.js
@@ -16378,6 +16378,309 @@ function readTraeEntitlementFromStorage(storagePath) {
};
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Trae Work CN (国内版) — usage API incremental parser.
+//
+// TRAE Work CN's usage API reports per-session rows that are NOT append-only:
+// a session can be re-reported with corrected token totals, a different model,
+// or a shifted time bucket. This parser keeps the last normalized contribution
+// per session_id under `cursors.traeCn` and reconciles by subtracting the
+// previous contribution from its old bucket before adding the new one, so
+// corrections retract the stale tuple instead of stacking. Only the normalized
+// contribution is persisted — no raw API rows, cost/credits, auth, refresh
+// tokens, or prompt previews. Source is always `trae-cn`.
+const TRAE_CN_SOURCE = "trae-cn";
+const TRAE_CN_STATE_VERSION = 1;
+const TRAE_CN_UNKNOWN_MODEL = "trae-cn-unknown";
+
+function normalizeTraeCnState(raw) {
+ if (raw === undefined || raw === null) {
+ return { version: TRAE_CN_STATE_VERSION, sessions: {}, updatedAt: null };
+ }
+ if (typeof raw !== "object" || Array.isArray(raw)) {
+ throw new Error("Trae CN cursor state is malformed.");
+ }
+ if (raw.version !== TRAE_CN_STATE_VERSION) {
+ throw new Error(`Trae CN cursor state version ${raw.version} is not supported.`);
+ }
+ if (!raw.sessions || typeof raw.sessions !== "object" || Array.isArray(raw.sessions)) {
+ throw new Error("Trae CN cursor sessions are malformed.");
+ }
+ return {
+ version: TRAE_CN_STATE_VERSION,
+ sessions: { ...raw.sessions },
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null,
+ };
+}
+
+function isValidTraeCnTotals(totals) {
+ if (!totals || typeof totals !== "object") return false;
+ for (const key of [
+ "input_tokens",
+ "cached_input_tokens",
+ "cache_creation_input_tokens",
+ "output_tokens",
+ "reasoning_output_tokens",
+ "total_tokens",
+ "billable_total_tokens",
+ "conversation_count",
+ ]) {
+ if (!Number.isSafeInteger(totals[key]) || totals[key] < 0) return false;
+ }
+ return true;
+}
+
+// Canonical UTC half-hour bucketStart produced by toUtcHalfHourStart:
+// "YYYY-MM-DDTHH:00:00.000Z" or "YYYY-MM-DDTHH:30:00.000Z".
+function isValidTraeCnBucketStart(value) {
+ if (typeof value !== "string") return false;
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:(00|30):00\.000Z$/.test(value)) return false;
+ const dt = new Date(value);
+ return Number.isFinite(dt.getTime()) && dt.toISOString() === value;
+}
+
+// Validate a stored prior contribution before it is ever subtracted, so a
+// tampered or corrupt cursor fails closed instead of silently rewinding buckets.
+// Enforces this provider's fixed canonical invariant (all totals safe
+// nonnegative integers, reasoning=0, conversation_count=1,
+// total=input+cached+cacheCreation+output, billable=total) plus a canonical
+// half-hour UTC bucketStart.
+function validateTraeCnStoredContribution(entry) {
+ if (!entry || typeof entry !== "object") {
+ throw new Error("Trae CN stored session contribution is malformed.");
+ }
+ if (
+ typeof entry.model !== "string" ||
+ !entry.model.trim() ||
+ entry.model.includes("|")
+ ) {
+ throw new Error("Trae CN stored session model is malformed.");
+ }
+ if (!isValidTraeCnBucketStart(entry.bucketStart)) {
+ throw new Error("Trae CN stored session bucket is malformed.");
+ }
+ if (!isValidTraeCnTotals(entry.totals)) {
+ throw new Error("Trae CN stored session totals are malformed.");
+ }
+ const { totals } = entry;
+ if (totals.reasoning_output_tokens !== 0 || totals.conversation_count !== 1) {
+ throw new Error("Trae CN stored session totals are malformed.");
+ }
+ const sum =
+ totals.input_tokens +
+ totals.cached_input_tokens +
+ totals.cache_creation_input_tokens +
+ totals.output_tokens;
+ if (totals.total_tokens !== sum || totals.billable_total_tokens !== totals.total_tokens) {
+ throw new Error("Trae CN stored session totals are malformed.");
+ }
+}
+
+// Token fields may live in `row.extra_info` (object or JSON string) or at the
+// top level of the row. Missing values are NOT coerced to zero.
+function traeCnExtraInfo(row) {
+ const extra = row?.extra_info;
+ if (extra && typeof extra === "object" && !Array.isArray(extra)) return extra;
+ if (typeof extra === "string") {
+ try {
+ const parsed = JSON.parse(extra);
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
+ } catch (_e) {}
+ }
+ return null;
+}
+
+function traeCnTokenField(row, extra, key) {
+ if (extra && extra[key] !== undefined && extra[key] !== null) return extra[key];
+ if (row && row[key] !== undefined && row[key] !== null) return row[key];
+ return undefined;
+}
+
+// Normalize one raw session row into the canonical contribution, or throw
+// before any cursor/bucket mutation happens. Returns
+// { sessionId, model, bucketStart, totals }.
+function normalizeTraeCnSession(row) {
+ const sessionId = typeof row?.session_id === "string" ? row.session_id.trim() : "";
+ if (!sessionId) {
+ throw new Error("Trae CN session row is missing session_id.");
+ }
+ let model;
+ if (row?.model_name === undefined || row?.model_name === null) {
+ model = TRAE_CN_UNKNOWN_MODEL;
+ } else if (typeof row.model_name === "string") {
+ model = row.model_name.trim() || TRAE_CN_UNKNOWN_MODEL;
+ } else {
+ throw new Error("Trae CN session row has an invalid model_name.");
+ }
+ if (model.includes("|")) {
+ throw new Error("Trae CN session row has an unsupported model name.");
+ }
+ if (!Number.isSafeInteger(row?.usage_time) || row.usage_time <= 0) {
+ throw new Error("Trae CN session row has an invalid usage_time.");
+ }
+ const bucketStart = toUtcHalfHourStart(row.usage_time * 1000);
+ if (!bucketStart) {
+ throw new Error("Trae CN session row has an invalid usage_time.");
+ }
+ const extra = traeCnExtraInfo(row);
+ const fields = [
+ ["input_token", traeCnTokenField(row, extra, "input_token")],
+ ["output_token", traeCnTokenField(row, extra, "output_token")],
+ ["cache_read_token", traeCnTokenField(row, extra, "cache_read_token")],
+ ["cache_write_token", traeCnTokenField(row, extra, "cache_write_token")],
+ ];
+ for (const [label, value] of fields) {
+ if (!Number.isSafeInteger(value) || value < 0) {
+ throw new Error(`Trae CN session row has an invalid ${label}.`);
+ }
+ }
+ const total = fields[0][1] + fields[1][1] + fields[2][1] + fields[3][1];
+ if (!Number.isSafeInteger(total)) {
+ throw new Error("Trae CN session row token totals overflow.");
+ }
+ return {
+ sessionId,
+ model,
+ bucketStart,
+ totals: {
+ input_tokens: fields[0][1],
+ output_tokens: fields[1][1],
+ cached_input_tokens: fields[2][1],
+ cache_creation_input_tokens: fields[3][1],
+ reasoning_output_tokens: 0,
+ total_tokens: total,
+ billable_total_tokens: total,
+ conversation_count: 1,
+ },
+ };
+}
+
+// Fail closed instead of letting subtractTotals clamp a stored prior
+// contribution that exceeds its source bucket (a corruption signal).
+function assertTraeCnBucketCovers(bucketTotals, previousTotals) {
+ for (const key of [
+ "input_tokens",
+ "cached_input_tokens",
+ "cache_creation_input_tokens",
+ "output_tokens",
+ "reasoning_output_tokens",
+ "total_tokens",
+ "billable_total_tokens",
+ "conversation_count",
+ ]) {
+ if ((bucketTotals[key] || 0) < (previousTotals[key] || 0)) {
+ throw new Error("Trae CN state corruption: stored contribution exceeds its bucket totals.");
+ }
+ }
+}
+
+async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgress } = {}) {
+ if (!Array.isArray(sessions)) {
+ throw new Error("Trae CN sessions must be an array.");
+ }
+ if (!cursors || typeof cursors !== "object" || Array.isArray(cursors)) {
+ throw new Error("Trae CN cursors must be a writable object.");
+ }
+ await ensureDir(path.dirname(queuePath));
+
+ // Validate persisted state up front (fresh absent state initializes version
+ // 1; malformed state / unexpected version fails closed instead of resetting).
+ // Deep-clone the normalized working states so reconciliation and enqueue
+ // mutations never touch `cursors` before the final assignment succeeds.
+ const hourlyState = structuredClone(normalizeHourlyState(cursors?.hourly));
+ const traeCnState = structuredClone(normalizeTraeCnState(cursors?.traeCn));
+
+ if (sessions.length === 0) {
+ // Empty payload is a successful no-op: it never erases prior contributions.
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
+ }
+
+ // Full prevalidation + in-payload dedupe BEFORE any cursor/bucket mutation.
+ const bySession = new Map();
+ for (const row of sessions) {
+ const normalized = normalizeTraeCnSession(row);
+ const existing = bySession.get(normalized.sessionId);
+ if (existing) {
+ const identical =
+ existing.model === normalized.model &&
+ existing.bucketStart === normalized.bucketStart &&
+ totalsKey(existing.totals) === totalsKey(normalized.totals);
+ if (!identical) {
+ throw new Error("Trae CN session rows contain conflicting contributions.");
+ }
+ continue; // exact duplicate within one payload is accepted once
+ }
+ bySession.set(normalized.sessionId, normalized);
+ }
+
+ const touchedBuckets = new Set();
+ const cb = typeof onProgress === "function" ? onProgress : null;
+ const total = sessions.length;
+ let index = 0;
+ let eventsAggregated = 0;
+
+ // Validate EVERY existing stored contribution up front — not just sessions
+ // present in the incoming payload — so an unrelated malformed entry fails
+ // closed before any reconciliation mutation.
+ for (const sessionId of Object.keys(traeCnState.sessions)) {
+ validateTraeCnStoredContribution(traeCnState.sessions[sessionId]);
+ }
+
+ // Deterministic: process unique session ids in sorted order.
+ const sessionIds = [...bySession.keys()].sort();
+ for (const sessionId of sessionIds) {
+ const current = bySession.get(sessionId);
+ const previousEntry = traeCnState.sessions[sessionId];
+
+ const unchanged =
+ previousEntry &&
+ totalsKey(previousEntry.totals) === totalsKey(current.totals) &&
+ previousEntry.bucketStart === current.bucketStart &&
+ previousEntry.model === current.model;
+ if (unchanged) {
+ index += 1;
+ if (cb) cb({ index, total, eventsAggregated, bucketsQueued: touchedBuckets.size });
+ continue;
+ }
+
+ if (previousEntry) {
+ const oldBucket = getHourlyBucket(
+ hourlyState,
+ TRAE_CN_SOURCE,
+ previousEntry.model,
+ previousEntry.bucketStart,
+ );
+ assertTraeCnBucketCovers(oldBucket.totals, previousEntry.totals);
+ subtractTotals(oldBucket.totals, previousEntry.totals);
+ touchedBuckets.add(bucketKey(TRAE_CN_SOURCE, previousEntry.model, previousEntry.bucketStart));
+ }
+
+ const bucket = getHourlyBucket(hourlyState, TRAE_CN_SOURCE, current.model, current.bucketStart);
+ addTotals(bucket.totals, current.totals);
+ touchedBuckets.add(bucketKey(TRAE_CN_SOURCE, current.model, current.bucketStart));
+
+ traeCnState.sessions[sessionId] = {
+ model: current.model,
+ bucketStart: current.bucketStart,
+ totals: { ...current.totals },
+ updatedAt: new Date().toISOString(),
+ };
+ eventsAggregated += 1;
+ index += 1;
+ if (cb) cb({ index, total, eventsAggregated, bucketsQueued: touchedBuckets.size });
+ }
+
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
+ const updatedAt = new Date().toISOString();
+ hourlyState.updatedAt = updatedAt;
+ traeCnState.updatedAt = updatedAt;
+ // Assign cursor state only after enqueue succeeds.
+ cursors.hourly = hourlyState;
+ cursors.traeCn = traeCnState;
+
+ return { recordsProcessed: total, eventsAggregated, bucketsQueued };
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// DeepSeek Harness (dsh) — passive reader of the harness's session logs.
//
@@ -17186,6 +17489,7 @@ module.exports = {
resolveTraePath,
resolveTraeStoragePath,
readTraeEntitlementFromStorage,
+ parseTraeCnApiIncremental,
// DeepSeek Harness (dsh) — passive session-log reader
resolveDshHome,
resolveDshSessionFiles,
diff --git a/src/lib/source-metadata.js b/src/lib/source-metadata.js
index bc659b630..06d8bf182 100644
--- a/src/lib/source-metadata.js
+++ b/src/lib/source-metadata.js
@@ -1,4 +1,4 @@
-const ACCOUNT_LEVEL_SOURCES = new Set(["cursor"]);
+const ACCOUNT_LEVEL_SOURCES = new Set(["cursor", "trae-cn"]);
function normalizeSource(value) {
return String(value || "").trim().toLowerCase();
diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js
new file mode 100644
index 000000000..b7124f465
--- /dev/null
+++ b/src/lib/trae-cn-config.js
@@ -0,0 +1,433 @@
+"use strict";
+
+/**
+ * TRAE Work CN (国内版) config + usage fetch. Phase A: macOS + test-injected
+ * paths only. Resolves the CN home, reads the `iCubeAuthInfo://icube.cloudide`
+ * blob from User/globalStorage/storage.json (plaintext JSON or Base64 tc v5),
+ * and talks to the TRAE CN usage API. JWTs / refresh tokens are never
+ * persisted, never logged, and never put into error messages; a
+ * present-but-unreadable blob fails closed with a generic error.
+ *
+ * tc v5 blob: [6-byte magic "tc\x05\x10\x00\x00"][32-byte salt][AES-128-CBC ct]
+ * kdf_buf = SHA-512(salt) || (JG xor KG) // 128 bytes
+ * kdf_out = SHA-512(kdf_buf) // 64 bytes
+ * key = kdf_out[0..16], iv = kdf_out[16..32]
+ * plaintext = [SHA-512(json)][json] // integrity prefix stripped
+ */
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+const TRAE_CN_HOME_ENV = "TOKENTRACKER_TRAE_CN_HOME";
+const TRAE_CN_APP_DIR = "TRAE SOLO CN";
+const TRAE_CN_AUTH_KEY = "iCubeAuthInfo://icube.cloudide";
+
+const TRAE_CN_USAGE_URL = "https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session";
+const TRAE_CN_USAGE_TYPE = [7];
+const TRAE_CN_USAGE_PAGE_SIZE = 20;
+const TRAE_CN_USAGE_MAX_PAGES = 100;
+const TRAE_CN_USAGE_DELAY_MS = 300;
+const TRAE_CN_USAGE_TIMEOUT_MS = 30 * 1000;
+
+const TRAE_CN_MAGIC = Buffer.from([0x74, 0x63, 0x05, 0x10, 0x00, 0x00]);
+const TRAE_CN_SALT_LEN = 32;
+const TRAE_CN_HASH_LEN = 64;
+const TRAE_CN_AES_KEY_LEN = 16;
+const TRAE_CN_IV_LEN = 16;
+
+// JG / KG — the two halves of the hardcoded (obfuscated) password in the TRAE
+// client's byteCrypto.js. Changing these breaks every existing blob.
+const TRAE_CN_JG = Buffer.from([
+ 82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130,
+ 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61,
+ 238, 76, 149, 11, 66, 250, 195, 78, 8, 46, 161, 102, 40, 217, 36, 178, 118, 91, 162, 73,
+ 109, 139, 209, 37,
+]);
+const TRAE_CN_KG = Buffer.from([
+ 31, 221, 168, 51, 136, 7, 199, 49, 177, 18, 16, 89, 39, 128, 236, 95, 96, 81, 127, 169, 25,
+ 181, 74, 13, 45, 229, 122, 159, 147, 201, 156, 239, 160, 224, 59, 77, 174, 42, 245, 176,
+ 200, 235, 187, 60, 131, 83, 153, 97, 23, 43, 4, 126, 186, 119, 214, 38, 225, 105, 20, 99,
+ 85, 33, 12, 125,
+]);
+
+// ---------------------------------------------------------------------------
+// Path resolution
+// ---------------------------------------------------------------------------
+
+function resolveTraeCnHome({ env = process.env, home = os.homedir(), platform = process.platform } = {}) {
+ const override = env[TRAE_CN_HOME_ENV];
+ if (typeof override === "string" && override.trim()) {
+ return path.resolve(override.trim());
+ }
+ if (platform === "darwin") {
+ return path.join(home, "Library", "Application Support", TRAE_CN_APP_DIR);
+ }
+ // ponytail: this phase is macOS + test-injected paths only.
+ return null;
+}
+
+function resolveTraeCnStoragePath(options = {}) {
+ const home = resolveTraeCnHome(options);
+ return home ? path.join(home, "User", "globalStorage", "storage.json") : null;
+}
+
+// ---------------------------------------------------------------------------
+// tc v5 decryption
+// ---------------------------------------------------------------------------
+
+function traeCnHardcodedPassword() {
+ const pw = Buffer.alloc(64);
+ for (let i = 0; i < 64; i++) pw[i] = TRAE_CN_JG[i] ^ TRAE_CN_KG[i];
+ return pw;
+}
+
+function deriveTraeCnKeyIv(salt) {
+ if (!Buffer.isBuffer(salt) || salt.length !== TRAE_CN_SALT_LEN) {
+ throw new Error("Trae CN auth salt is malformed.");
+ }
+ const kdfBuf = Buffer.concat([
+ crypto.createHash("sha512").update(salt).digest(),
+ traeCnHardcodedPassword(),
+ ]);
+ const kdfOut = crypto.createHash("sha512").update(kdfBuf).digest();
+ return {
+ key: kdfOut.subarray(0, TRAE_CN_AES_KEY_LEN),
+ iv: kdfOut.subarray(TRAE_CN_AES_KEY_LEN, TRAE_CN_AES_KEY_LEN + TRAE_CN_IV_LEN),
+ };
+}
+
+/** Decrypt a raw tc v5 blob; returns the JSON bytes (integrity prefix stripped). */
+function decryptTraeCnBlob(blob) {
+ const minLen = TRAE_CN_MAGIC.length + TRAE_CN_SALT_LEN + 16;
+ if (!Buffer.isBuffer(blob) || blob.length < minLen) {
+ throw new Error("Trae CN auth blob is too short.");
+ }
+ if (!blob.subarray(0, TRAE_CN_MAGIC.length).equals(TRAE_CN_MAGIC)) {
+ throw new Error("Trae CN auth blob has an unknown format.");
+ }
+ const salt = blob.subarray(TRAE_CN_MAGIC.length, TRAE_CN_MAGIC.length + TRAE_CN_SALT_LEN);
+ const ciphertext = blob.subarray(TRAE_CN_MAGIC.length + TRAE_CN_SALT_LEN);
+ if (ciphertext.length % 16 !== 0) {
+ throw new Error("Trae CN auth blob is not block-aligned.");
+ }
+ const { key, iv } = deriveTraeCnKeyIv(salt);
+ const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv); // PKCS7 default
+ let plaintext;
+ try {
+ plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
+ } catch (_error) {
+ throw new Error("Trae CN auth blob could not be decrypted.");
+ }
+ if (plaintext.length < TRAE_CN_HASH_LEN) {
+ throw new Error("Trae CN auth blob is missing its integrity digest.");
+ }
+ const expected = plaintext.subarray(0, TRAE_CN_HASH_LEN);
+ const data = plaintext.subarray(TRAE_CN_HASH_LEN);
+ const actual = crypto.createHash("sha512").update(data).digest();
+ if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
+ throw new Error("Trae CN auth blob failed its integrity check.");
+ }
+ return data;
+}
+
+/** Decrypt a Base64-encoded tc v5 blob and return the plaintext UTF-8 string. */
+function decryptTraeCnBase64(value) {
+ const blob = Buffer.from(String(value).trim(), "base64");
+ return decryptTraeCnBlob(blob).toString("utf8");
+}
+
+// ---------------------------------------------------------------------------
+// Credential reading
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse a stored auth value: a plaintext JSON object / JSON string, or a
+ * Base64-encoded tc v5 blob. Fails closed with a credential-safe error.
+ */
+function parseTraeCnAuthValue(value) {
+ if (value && typeof value === "object") {
+ return value;
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ throw new Error("Trae CN auth data is empty.");
+ }
+ if (trimmed.startsWith("{")) {
+ let parsed;
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch (_error) {
+ throw new Error("Trae CN auth JSON is malformed.");
+ }
+ if (!parsed || typeof parsed !== "object") {
+ throw new Error("Trae CN auth JSON is malformed.");
+ }
+ return parsed;
+ }
+ let decrypted;
+ try {
+ decrypted = decryptTraeCnBase64(trimmed);
+ } catch (error) {
+ if (error && error.message.startsWith("Trae CN auth")) throw error;
+ throw new Error("Trae CN auth payload is malformed.");
+ }
+ let parsed;
+ try {
+ parsed = JSON.parse(decrypted);
+ } catch (_error) {
+ throw new Error("Trae CN auth payload is malformed.");
+ }
+ if (!parsed || typeof parsed !== "object") {
+ throw new Error("Trae CN auth payload is malformed.");
+ }
+ return parsed;
+ }
+ throw new Error("Trae CN auth data is missing or malformed.");
+}
+
+/**
+ * Read + decrypt the auth object from storage.json. Returns null when the
+ * file or the auth key is absent (not configured). Throws a credential-safe
+ * error when present data is malformed.
+ */
+function readTraeCnAuthFromStorage({ env, home, platform, fsModule = fs } = {}) {
+ const storagePath = resolveTraeCnStoragePath({ env, home, platform });
+ if (!storagePath) return null;
+ let raw;
+ try {
+ raw = fsModule.readFileSync(storagePath, "utf8");
+ } catch (_error) {
+ return null;
+ }
+ let parsed;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (_error) {
+ throw new Error("Trae CN storage.json is not valid JSON.");
+ }
+ const value = parsed && typeof parsed === "object" ? parsed[TRAE_CN_AUTH_KEY] : undefined;
+ if (value === undefined || value === null) return null;
+ return parseTraeCnAuthValue(value);
+}
+
+/** Extract a nonempty JWT from a parsed auth object. */
+function extractTraeCnToken(auth) {
+ const token = auth && typeof auth === "object" ? auth.token : undefined;
+ if (typeof token === "string" && token.trim()) {
+ return token.trim();
+ }
+ throw new Error("Trae CN auth data has no token.");
+}
+
+// ---------------------------------------------------------------------------
+// Usage API
+// ---------------------------------------------------------------------------
+
+function usageError(message, { status, apiCode } = {}) {
+ const error = new Error(message);
+ if (status !== undefined) error.status = status;
+ if (apiCode !== undefined) error.apiCode = apiCode;
+ if (status === 401 || status === 403) error.code = "AUTH_EXPIRED";
+ return error;
+}
+
+function assertUsageParams({ start_time, end_time, page_size }) {
+ if (!Number.isInteger(start_time) || start_time <= 0) {
+ throw new Error("Trae CN usage start_time must be a positive integer epoch second.");
+ }
+ if (!Number.isInteger(end_time) || end_time <= 0) {
+ throw new Error("Trae CN usage end_time must be a positive integer epoch second.");
+ }
+ if (start_time > end_time) {
+ throw new Error("Trae CN usage start_time must not be after end_time.");
+ }
+ if (!Number.isInteger(page_size) || page_size <= 0 || page_size > TRAE_CN_USAGE_PAGE_SIZE) {
+ throw new Error(
+ `Trae CN usage page_size must be a positive integer no greater than ${TRAE_CN_USAGE_PAGE_SIZE}.`,
+ );
+ }
+}
+
+/**
+ * Fetch one page of TRAE CN usage. Returns { sessions, total, page_num, page_size }
+ * where `total` is null when the API omits it. Errors never contain the JWT
+ * or the raw request/response body.
+ */
+async function fetchTraeCnUsagePage({
+ jwt,
+ start_time,
+ end_time,
+ page_num,
+ page_size = TRAE_CN_USAGE_PAGE_SIZE,
+ url = TRAE_CN_USAGE_URL,
+ fetchImpl = fetch,
+ timeoutMs = TRAE_CN_USAGE_TIMEOUT_MS,
+} = {}) {
+ if (typeof jwt !== "string" || !jwt.trim()) {
+ throw new Error("Trae CN usage request requires a token.");
+ }
+ if (!Number.isInteger(page_num) || page_num < 1) {
+ throw new Error("Trae CN usage page_num must be a positive integer.");
+ }
+ assertUsageParams({ start_time, end_time, page_size });
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ let response;
+ try {
+ response = await fetchImpl(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Cloud-IDE-JWT ${jwt.trim()}`,
+ },
+ body: JSON.stringify({
+ usage_type: TRAE_CN_USAGE_TYPE,
+ start_time,
+ end_time,
+ page_num,
+ page_size,
+ }),
+ signal: controller.signal,
+ });
+ } catch (_error) {
+ throw new Error("Trae CN usage API request failed.");
+ } finally {
+ clearTimeout(timer);
+ }
+ if (response.status === 401 || response.status === 403) {
+ throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status });
+ }
+ if (!response.ok) {
+ throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status });
+ }
+ let parsed;
+ try {
+ parsed = await response.json();
+ } catch (_error) {
+ throw new Error("Trae CN usage API returned a non-JSON response.");
+ }
+ const apiCode = parsed?.data?.code ?? parsed?.code;
+ if (apiCode !== undefined && Number(apiCode) !== 0) {
+ throw usageError(`Trae CN usage API returned error code ${apiCode}.`, {
+ status: response.status,
+ apiCode: Number(apiCode),
+ });
+ }
+ // Documented wrapper: data.user_usage_group_by_sessions, or the top-level
+ // equivalent. Only this module parses the wrapper; token rows/buckets are
+ // normalized elsewhere. A body without the session-array wrapper fails
+ // closed as a schema error instead of being misread as an empty result;
+ // an explicit empty array remains valid.
+ const data = parsed?.data && typeof parsed.data === "object" ? parsed.data : parsed;
+ if (!Array.isArray(data?.user_usage_group_by_sessions)) {
+ throw new Error("Trae CN usage API response is missing the session list.");
+ }
+ const sessions = data.user_usage_group_by_sessions;
+ const rawTotal = data?.total;
+ const total = rawTotal === undefined || rawTotal === null ? null : Number(rawTotal);
+ return { sessions, total, page_num, page_size };
+}
+
+/**
+ * Fetch usage across pages serially. Starts at the 1-based page_num, at most
+ * maxPages pages, with a delay between pages (pass delayMs = 0 to disable in
+ * tests). Stops at the stated total when present, otherwise on an empty page.
+ * Throws if maxPages is reached while another page would still be needed.
+ */
+async function fetchTraeCnUsage({
+ jwt,
+ start_time,
+ end_time,
+ page_num = 1,
+ page_size = TRAE_CN_USAGE_PAGE_SIZE,
+ fetchImpl = fetch,
+ maxPages = TRAE_CN_USAGE_MAX_PAGES,
+ delayMs = TRAE_CN_USAGE_DELAY_MS,
+ pageFetcher = fetchTraeCnUsagePage,
+} = {}) {
+ const sessions = [];
+ let total = null;
+ let pagesFetched = 0;
+ let page = page_num;
+ for (;;) {
+ if (pagesFetched >= maxPages) {
+ // A further page would be needed but the cap prevents it: fail closed
+ // rather than return a partial snapshot that looks authoritative.
+ throw new Error("Trae CN usage pagination exceeded the maximum page count.");
+ }
+ const result = await pageFetcher({ jwt, start_time, end_time, page_num: page, page_size, fetchImpl });
+ pagesFetched += 1;
+ total = result.total;
+ if (Array.isArray(result.sessions)) sessions.push(...result.sessions);
+ if (result.sessions.length === 0) break;
+ if (total !== null && sessions.length >= total) break;
+ if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs));
+ page += 1;
+ }
+ return { sessions, total, pages_fetched: pagesFetched };
+}
+
+/**
+ * High-level storage-backed fetch: read the JWT from storage.json, fetch
+ * usage, and on a 401/403 re-read + re-decrypt storage.json and retry exactly
+ * once. Never persists or logs the JWT / refresh token; no refreshToken flow.
+ */
+async function fetchTraeCnUsageWithAuth({
+ start_time,
+ end_time,
+ page_num = 1,
+ page_size = TRAE_CN_USAGE_PAGE_SIZE,
+ fetchImpl = fetch,
+ delayMs = TRAE_CN_USAGE_DELAY_MS,
+ maxPages = TRAE_CN_USAGE_MAX_PAGES,
+ readAuth = readTraeCnAuthFromStorage,
+ usageFetcher = fetchTraeCnUsage,
+ ...storageOptions
+} = {}) {
+ let auth = readAuth(storageOptions);
+ let jwt = auth ? extractTraeCnToken(auth) : null;
+ if (!jwt) {
+ throw new Error("Trae CN credentials are not configured.");
+ }
+ const run = () =>
+ usageFetcher({ jwt, start_time, end_time, page_num, page_size, fetchImpl, delayMs, maxPages });
+ try {
+ return await run();
+ } catch (error) {
+ const authFailure =
+ error?.code === "AUTH_EXPIRED" || error?.status === 401 || error?.status === 403;
+ if (!authFailure) throw error;
+ // Session may have rotated since the first read; re-read storage once.
+ auth = readAuth(storageOptions);
+ jwt = auth ? extractTraeCnToken(auth) : null;
+ if (!jwt) throw error;
+ return await run();
+ }
+}
+
+module.exports = {
+ TRAE_CN_HOME_ENV,
+ TRAE_CN_APP_DIR,
+ TRAE_CN_AUTH_KEY,
+ TRAE_CN_USAGE_URL,
+ TRAE_CN_USAGE_TYPE,
+ TRAE_CN_USAGE_PAGE_SIZE,
+ TRAE_CN_USAGE_MAX_PAGES,
+ TRAE_CN_USAGE_DELAY_MS,
+ TRAE_CN_MAGIC,
+ resolveTraeCnHome,
+ resolveTraeCnStoragePath,
+ traeCnHardcodedPassword,
+ deriveTraeCnKeyIv,
+ decryptTraeCnBlob,
+ decryptTraeCnBase64,
+ parseTraeCnAuthValue,
+ readTraeCnAuthFromStorage,
+ extractTraeCnToken,
+ fetchTraeCnUsagePage,
+ fetchTraeCnUsage,
+ fetchTraeCnUsageWithAuth,
+};
diff --git a/test/discovery-metadata.test.js b/test/discovery-metadata.test.js
index ea66285ae..2327a380c 100644
--- a/test/discovery-metadata.test.js
+++ b/test/discovery-metadata.test.js
@@ -9,14 +9,14 @@ const ROOT = path.resolve(__dirname, "..");
const read = (relativePath) => fs.readFileSync(path.join(ROOT, relativePath), "utf8");
const README_EXPECTATIONS = [
- ["README.md", /31 AI coding tools/, /\|\s+\*\*AI tools supported\*\*\s+\|\s+\*\*31\*\*/, /Rate-limit tracking.*✅ 13 providers/],
- ["README.zh-CN.md", /31 款 AI 编码工具/, /\|\s+\*\*支持的 AI 工具数\*\*\s+\|\s+\*\*31\*\*/, /限额追踪.*✅ 13 家 provider/],
- ["README.ja.md", /31 種類の AI コーディングツール/, /\|\s+\*\*対応 AI ツール数\*\*\s+\|\s+\*\*31\*\*/, /レート制限トラッキング.*✅ 13 プロバイダー/],
- ["README.ko.md", /31개의 AI 코딩 도구/, /\|\s+\*\*지원하는 AI 도구 수\*\*\s+\|\s+\*\*31\*\*/, /레이트 제한 추적.*✅ 13개 프로바이더/],
- ["README.de.md", /31 KI-Coding-Tools/, /\|\s+\*\*Unterstützte KI-Tools\*\*\s+\|\s+\*\*31\*\*/, /Rate-Limit-Tracking.*✅ 13 Provider/],
+ ["README.md", /32 AI coding tools/, /\|\s+\*\*AI tools supported\*\*\s+\|\s+\*\*32\*\*/, /Rate-limit tracking.*✅ 13 providers/],
+ ["README.zh-CN.md", /32 款 AI 编码工具/, /\|\s+\*\*支持的 AI 工具数\*\*\s+\|\s+\*\*32\*\*/, /限额追踪.*✅ 13 家 provider/],
+ ["README.ja.md", /32 種類の AI コーディングツール/, /\|\s+\*\*対応 AI ツール数\*\*\s+\|\s+\*\*32\*\*/, /レート制限トラッキング.*✅ 13 プロバイダー/],
+ ["README.ko.md", /32개의 AI 코딩 도구/, /\|\s+\*\*지원하는 AI 도구 수\*\*\s+\|\s+\*\*32\*\*/, /레이트 제한 추적.*✅ 13개 프로바이더/],
+ ["README.de.md", /32 KI-Coding-Tools/, /\|\s+\*\*Unterstützte KI-Tools\*\*\s+\|\s+\*\*32\*\*/, /Rate-Limit-Tracking.*✅ 13 Provider/],
];
-test("public discovery surfaces describe all 31 supported tools", () => {
+test("public discovery surfaces describe all 32 supported tools", () => {
for (const [file, countPattern, comparisonPattern, limitCountPattern] of README_EXPECTATIONS) {
const source = read(file);
assert.match(source, countPattern, `${file} has the current provider count`);
@@ -25,12 +25,14 @@ test("public discovery surfaces describe all 31 supported tools", () => {
assert.match(source, /AnythingLLM Desktop/, `${file} lists AnythingLLM Desktop`);
assert.match(source, /Qoder/, `${file} lists Qoder`);
assert.match(source, /DeepSeek Harness/, `${file} lists DeepSeek Harness`);
+ assert.match(source, /TRAE Work CN/, `${file} lists TRAE Work CN`);
assert.match(source, limitCountPattern, `${file} rate-limit row carries the current usage-limits provider count`);
}
const index = read("dashboard/index.html");
assert.doesNotMatch(index, /13 AI coding/);
- assert.match(index, /Supported AI coding tools \(31\)/);
+ assert.match(index, /Supported AI coding tools \(32\)/);
+ assert.match(index, /TRAE Work CN/);
assert.match(index, /Desktop pet/);
assert.match(index, /Four desktop widgets/);
assert.match(index, /Achievements/);
@@ -38,41 +40,43 @@ test("public discovery surfaces describe all 31 supported tools", () => {
assert.match(index, /usage limits for 13 providers/i);
const llms = read("dashboard/public/llms.txt");
- assert.match(llms, /Supported AI coding tools \(31\)/);
+ assert.match(llms, /Supported AI coding tools \(32\)/);
+ assert.match(llms, /TRAE Work CN/);
assert.match(llms, /desktop pet/i);
assert.match(llms, /four desktop widgets/i);
assert.match(llms, /achievements/i);
});
-test("marketing logo wall includes the same 31 product integrations", () => {
+test("marketing logo wall includes the same 32 product integrations", () => {
const source = read("dashboard/src/ui/marketing/agent-logos.js");
const providers = [...source.matchAll(/provider:\s*"([^"]+)"/g)].map((match) => match[1]);
- assert.equal(providers.length, 31);
- assert.equal(new Set(providers).size, 31);
+ assert.equal(providers.length, 32);
+ assert.equal(new Set(providers).size, 32);
- for (const provider of ["every-code", "reasonix", "kilocode", "roocode", "zed", "goose", "droid", "qoder", "anythingllm", "dsh"]) {
+ for (const provider of ["every-code", "reasonix", "kilocode", "roocode", "zed", "goose", "droid", "qoder", "anythingllm", "dsh", "trae-cn"]) {
assert.ok(providers.includes(provider), `logo wall includes ${provider}`);
}
});
-test("CLI onboarding advertises the same 31 supported integrations", () => {
+test("CLI onboarding advertises the same 32 supported integrations", () => {
const source = read("src/commands/init.js");
const block = source.match(/const SUPPORTED_PROVIDERS = \[([\s\S]*?)\];/);
assert.ok(block, "init defines SUPPORTED_PROVIDERS");
const providers = [...block[1].matchAll(/^\s*"([^"]+)",?$/gm)].map((match) => match[1]);
- assert.equal(providers.length, 31);
- assert.equal(new Set(providers).size, 31);
+ assert.equal(providers.length, 32);
+ assert.equal(new Set(providers).size, 32);
assert.ok(providers.includes("Droid"));
assert.ok(providers.includes("AnythingLLM Desktop"));
assert.ok(providers.includes("Qoder"));
assert.ok(providers.includes("Reasonix"));
assert.ok(providers.includes("DeepSeek Harness"));
+ assert.ok(providers.includes("TRAE Work CN"));
});
test("npm metadata carries the current product hook", () => {
const pkg = JSON.parse(read("package.json"));
- assert.match(pkg.description, /31 tools/);
+ assert.match(pkg.description, /32 tools/);
assert.match(pkg.description, /desktop pet/);
assert.ok(pkg.keywords.includes("desktop-widget"));
assert.ok(pkg.keywords.includes("ai-coding-tools"));
diff --git a/test/source-metadata.test.js b/test/source-metadata.test.js
new file mode 100644
index 000000000..560823989
--- /dev/null
+++ b/test/source-metadata.test.js
@@ -0,0 +1,54 @@
+/**
+ * Source-metadata classification tests. Verifies TRAE Work CN is an
+ * account-level source (case-insensitively) and that personal scope filters it
+ * out while preserving local sources.
+ */
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+
+const {
+ getSourceScope,
+ isAccountLevelSource,
+ normalizeUsageScope,
+ filterRowsByUsageScope,
+ listExcludedSources,
+} = require("../src/lib/source-metadata");
+
+test("trae-cn is classified as an account-level source case-insensitively", () => {
+ assert.equal(getSourceScope("trae-cn"), "account");
+ assert.equal(getSourceScope("TRAE-CN"), "account");
+ assert.equal(getSourceScope(" Trae-Cn "), "account");
+ assert.equal(isAccountLevelSource("trae-cn"), true);
+ // Pre-existing classification is preserved.
+ assert.equal(getSourceScope("cursor"), "account");
+ assert.equal(getSourceScope("codex"), "local");
+ assert.equal(getSourceScope("claude"), "local");
+});
+
+test("personal scope filters account-level trae-cn rows while preserving local sources", () => {
+ const rows = [
+ { source: "trae-cn", total_tokens: 10 },
+ { source: "TRAE-CN", total_tokens: 20 },
+ { source: "codex", total_tokens: 30 },
+ { source: "claude", total_tokens: 40 },
+ ];
+ const personal = filterRowsByUsageScope(rows, "personal");
+ assert.deepEqual(personal, [
+ { source: "codex", total_tokens: 30 },
+ { source: "claude", total_tokens: 40 },
+ ]);
+ assert.equal(filterRowsByUsageScope(rows, "all").length, 4);
+ assert.equal(normalizeUsageScope("personal"), "personal");
+});
+
+test("listExcludedSources reports trae-cn as excluded from personal scope", () => {
+ const excluded = listExcludedSources(
+ [{ source: "trae-cn" }, { source: "TRAE-CN" }, { source: "codex" }],
+ "personal",
+ );
+ assert.deepEqual(excluded, [
+ { source: "trae-cn", source_scope: "account", reason: "account_level_source" },
+ ]);
+});
diff --git a/test/sync-trae-cn.test.js b/test/sync-trae-cn.test.js
new file mode 100644
index 000000000..b7647b5af
--- /dev/null
+++ b/test/sync-trae-cn.test.js
@@ -0,0 +1,260 @@
+/**
+ * TRAE Work CN sync wiring tests.
+ *
+ * All synthetic: no live API, no real JWT. Exercises the non-background auto
+ * sync block (rolling 30-day range, storage-backed auth, single parser call)
+ * and the gating that keeps every background / all-local variant from fetching.
+ * The injected fetch + fixed now come through cmdSync's narrow context seam
+ * (context.traeCnFetchImpl / context.traeCnNowMs).
+ */
+"use strict";
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const test = require("node:test");
+
+const { cmdSync } = require("../src/commands/sync");
+const { getSourceScope } = require("../src/lib/source-metadata");
+
+const TRAE_CN_AUTH_KEY = "iCubeAuthInfo://icube.cloudide";
+const TRAE_CN_USAGE_URL = "https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session";
+const NOW_MS = 1_800_000_000_000;
+const ROLLING_DAYS = 30 * 24 * 60 * 60;
+
+function tokenCountLine({ ts, totalTokens }) {
+ const usage = {
+ input_tokens: totalTokens,
+ cached_input_tokens: 0,
+ output_tokens: 0,
+ reasoning_output_tokens: 0,
+ total_tokens: totalTokens,
+ };
+ return JSON.stringify({
+ type: "event_msg",
+ timestamp: ts,
+ payload: {
+ type: "token_count",
+ info: { last_token_usage: usage, total_token_usage: usage },
+ },
+ });
+}
+
+async function withTempTraeEnv(fn) {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-trae-sync-"));
+ const traeCnHome = path.join(home, "trae-cn-data");
+ const saved = {
+ HOME: process.env.HOME,
+ USERPROFILE: process.env.USERPROFILE,
+ CODEX_HOME: process.env.CODEX_HOME,
+ CODE_HOME: process.env.CODE_HOME,
+ GEMINI_HOME: process.env.GEMINI_HOME,
+ OPENCODE_HOME: process.env.OPENCODE_HOME,
+ XDG_DATA_HOME: process.env.XDG_DATA_HOME,
+ TOKENTRACKER_REASONIX_HOME: process.env.TOKENTRACKER_REASONIX_HOME,
+ REASONIX_STATE_HOME: process.env.REASONIX_STATE_HOME,
+ TOKENTRACKER_DEVICE_TOKEN: process.env.TOKENTRACKER_DEVICE_TOKEN,
+ TOKENTRACKER_INSFORGE_BASE_URL: process.env.TOKENTRACKER_INSFORGE_BASE_URL,
+ TOKENTRACKER_OPENCLAW_HOME: process.env.TOKENTRACKER_OPENCLAW_HOME,
+ TOKENTRACKER_OPENCLAW_AGENT_ID: process.env.TOKENTRACKER_OPENCLAW_AGENT_ID,
+ TOKENTRACKER_OPENCLAW_PREV_SESSION_ID: process.env.TOKENTRACKER_OPENCLAW_PREV_SESSION_ID,
+ TOKENTRACKER_OPENCLAW_SESSION_KEY: process.env.TOKENTRACKER_OPENCLAW_SESSION_KEY,
+ TOKENTRACKER_TRAE_CN_HOME: process.env.TOKENTRACKER_TRAE_CN_HOME,
+ TOKENTRACKER_WSL_MODE: process.env.TOKENTRACKER_WSL_MODE,
+ };
+ try {
+ process.env.HOME = home;
+ process.env.USERPROFILE = home;
+ process.env.CODEX_HOME = path.join(home, ".codex");
+ process.env.CODE_HOME = path.join(home, ".code");
+ process.env.GEMINI_HOME = path.join(home, ".gemini");
+ process.env.OPENCODE_HOME = path.join(home, ".opencode");
+ process.env.XDG_DATA_HOME = path.join(home, ".local", "share");
+ process.env.TOKENTRACKER_OPENCLAW_HOME = path.join(home, ".openclaw");
+ process.env.TOKENTRACKER_TRAE_CN_HOME = traeCnHome;
+ delete process.env.TOKENTRACKER_REASONIX_HOME;
+ delete process.env.REASONIX_STATE_HOME;
+ delete process.env.TOKENTRACKER_DEVICE_TOKEN;
+ delete process.env.TOKENTRACKER_INSFORGE_BASE_URL;
+ delete process.env.TOKENTRACKER_OPENCLAW_AGENT_ID;
+ delete process.env.TOKENTRACKER_OPENCLAW_PREV_SESSION_ID;
+ delete process.env.TOKENTRACKER_OPENCLAW_SESSION_KEY;
+ delete process.env.TOKENTRACKER_WSL_MODE;
+ fs.mkdirSync(path.join(traeCnHome, "User", "globalStorage"), { recursive: true });
+ return await fn({ home, traeCnHome });
+ } finally {
+ for (const [key, value] of Object.entries(saved)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+}
+
+function writeTraeCnStorage(traeCnHome, auth = { token: "fake-jwt-token", refreshToken: "synthetic" }) {
+ fs.writeFileSync(
+ path.join(traeCnHome, "User", "globalStorage", "storage.json"),
+ JSON.stringify({ [TRAE_CN_AUTH_KEY]: auth }),
+ );
+}
+
+function readQueueRows(home) {
+ const queuePath = path.join(home, ".tokentracker", "tracker", "queue.jsonl");
+ if (!fs.existsSync(queuePath)) return [];
+ return fs.readFileSync(queuePath, "utf8")
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line));
+}
+
+function readCursors(home) {
+ const cursorsPath = path.join(home, ".tokentracker", "tracker", "cursors.json");
+ if (!fs.existsSync(cursorsPath)) return {};
+ return JSON.parse(fs.readFileSync(cursorsPath, "utf8"));
+}
+
+function traeFetchOnce(body, requests = []) {
+ return async (url, options) => {
+ requests.push({ url, options });
+ return { status: 200, ok: true, json: async () => body };
+ };
+}
+
+function halfHourBucket(epochSeconds) {
+ const d = new Date(epochSeconds * 1000);
+ const half = d.getUTCMinutes() >= 30 ? 30 : 0;
+ return new Date(
+ Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), half, 0, 0),
+ ).toISOString();
+}
+
+function sampleSession(usageTime) {
+ return {
+ session_id: "s1",
+ model_name: "doubao-pro",
+ usage_time: usageTime,
+ input_token: 100,
+ output_token: 10,
+ cache_read_token: 0,
+ cache_write_token: 0,
+ };
+}
+
+test("non-background auto trae-cn sync fetches the exact rolling range and queues the row", async () => {
+ await withTempTraeEnv(async ({ home, traeCnHome }) => {
+ writeTraeCnStorage(traeCnHome);
+ const endTime = Math.floor(NOW_MS / 1000);
+ const startTime = Math.max(1, endTime - ROLLING_DAYS);
+ const usageTime = startTime + 3600;
+ const requests = [];
+ const fetchImpl = traeFetchOnce(
+ { user_usage_group_by_sessions: [sampleSession(usageTime)], total: 1 },
+ requests,
+ );
+
+ await cmdSync(["--auto", "--source=trae-cn"], { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS });
+
+ assert.equal(requests.length, 1, "single page fetch");
+ assert.equal(requests[0].url, TRAE_CN_USAGE_URL);
+ assert.equal(requests[0].options.method, "POST");
+ assert.equal(requests[0].options.headers["Content-Type"], "application/json");
+ assert.equal(requests[0].options.headers.Authorization, "Cloud-IDE-JWT fake-jwt-token");
+ const body = JSON.parse(requests[0].options.body);
+ assert.deepEqual(body.usage_type, [7]);
+ assert.equal(body.start_time, startTime);
+ assert.equal(body.end_time, endTime);
+ assert.equal(body.page_num, 1);
+ assert.equal(body.page_size, 20);
+
+ const traeRows = readQueueRows(home).filter((row) => row.source === "trae-cn");
+ assert.equal(traeRows.length, 1);
+ assert.equal(traeRows[0].model, "doubao-pro");
+ assert.equal(traeRows[0].input_tokens, 100);
+ assert.equal(traeRows[0].output_tokens, 10);
+ assert.equal(traeRows[0].total_tokens, 110);
+ assert.equal(traeRows[0].conversation_count, 1);
+ assert.equal(traeRows[0].hour_start, halfHourBucket(usageTime));
+
+ const cursors = readCursors(home);
+ assert.equal(cursors.traeCn.version, 1);
+ assert.equal(cursors.traeCn.sessions["s1"].totals.input_tokens, 100);
+ assert.equal(cursors.traeCn.sessions["s1"].bucketStart, halfHourBucket(usageTime));
+ assert.equal(getSourceScope("trae-cn"), "account", "source-scope account");
+ });
+});
+
+test("repeated fixed-now sync is idempotent (no token growth / no new queue row)", async () => {
+ await withTempTraeEnv(async ({ home, traeCnHome }) => {
+ writeTraeCnStorage(traeCnHome);
+ const endTime = Math.floor(NOW_MS / 1000);
+ const fetchImpl = traeFetchOnce({
+ user_usage_group_by_sessions: [sampleSession(endTime - 86400)],
+ total: 1,
+ });
+ await cmdSync(["--auto", "--source=trae-cn"], { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS });
+
+ const queuePath = path.join(home, ".tokentracker", "tracker", "queue.jsonl");
+ const before = fs.readFileSync(queuePath, "utf8");
+ await cmdSync(["--auto", "--source=trae-cn"], { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS });
+ assert.equal(fs.readFileSync(queuePath, "utf8"), before, "queue unchanged on repeat");
+
+ const traeRows = readQueueRows(home).filter((row) => row.source === "trae-cn");
+ assert.equal(traeRows.length, 1);
+ assert.equal(traeRows[0].total_tokens, 110);
+ });
+});
+
+test("background and all-local syncs never call the TRAE CN fetch", async () => {
+ await withTempTraeEnv(async ({ home, traeCnHome }) => {
+ writeTraeCnStorage(traeCnHome);
+ let fetchCalls = 0;
+ const fetchImpl = async () => {
+ fetchCalls += 1;
+ throw new Error("should not fetch");
+ };
+ const opts = { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS };
+
+ await cmdSync(["--auto", "--background"], opts);
+ assert.equal(fetchCalls, 0);
+ await cmdSync(["--auto", "--background", "--all-local-sources"], opts);
+ assert.equal(fetchCalls, 0);
+ await cmdSync(["--auto", "--background", "--source=trae-cn"], opts);
+ assert.equal(fetchCalls, 0);
+
+ assert.equal(
+ readQueueRows(home).some((row) => row.source === "trae-cn"),
+ false,
+ );
+ });
+});
+
+test("failed TRAE CN fetch skips parser/queue/cursor mutation while other providers continue", async () => {
+ await withTempTraeEnv(async ({ home, traeCnHome }) => {
+ writeTraeCnStorage(traeCnHome);
+ const fetchImpl = async () => {
+ throw new Error("network down");
+ };
+ const opts = { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS };
+
+ // Scoped run: failing fetch must not create trae-cn queue rows or cursor state.
+ await cmdSync(["--auto", "--source=trae-cn"], opts);
+ assert.equal(readQueueRows(home).filter((row) => row.source === "trae-cn").length, 0);
+ assert.equal(readCursors(home).traeCn, undefined);
+
+ // Full run: an unrelated local provider still parses while trae-cn is skipped.
+ const codexHome = process.env.CODEX_HOME;
+ fs.mkdirSync(path.join(codexHome, "sessions", "2026", "06", "30"), { recursive: true });
+ fs.writeFileSync(
+ path.join(codexHome, "sessions", "2026", "06", "30", "rollout-2026-06-30T00-00-00-aaaa-ffff.jsonl"),
+ tokenCountLine({ ts: "2026-06-30T00:00:00.000Z", totalTokens: 31 }) + "\n",
+ "utf8",
+ );
+ await cmdSync(["--auto"], opts);
+ const rows = readQueueRows(home);
+ assert.ok(rows.some((row) => row.source === "codex"), "unrelated provider continued");
+ assert.ok(!rows.some((row) => row.source === "trae-cn"), "no trae-cn rows after failure");
+ assert.equal(readCursors(home).traeCn, undefined);
+ });
+});
diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js
new file mode 100644
index 000000000..d447e34ec
--- /dev/null
+++ b/test/trae-cn-config.test.js
@@ -0,0 +1,570 @@
+/**
+ * TRAE Work CN config tests. Synthetic data only — no real JWTs / fixtures.
+ *
+ * The encrypted-tc-v5 coverage relies on a frozen static fixture (FIXTURE_B64)
+ * produced once with the documented KDF + a fixed 32-byte salt, plus an
+ * independent encryptor in this file that writes its own KDF. Neither imports
+ * production `deriveTraeCnKeyIv`, so a broken production KDF fails the
+ * decrypt assertions instead of being hidden by test-only encryption.
+ */
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+const {
+ TRAE_CN_HOME_ENV,
+ TRAE_CN_AUTH_KEY,
+ TRAE_CN_USAGE_URL,
+ TRAE_CN_USAGE_DELAY_MS,
+ resolveTraeCnHome,
+ resolveTraeCnStoragePath,
+ decryptTraeCnBase64,
+ parseTraeCnAuthValue,
+ readTraeCnAuthFromStorage,
+ extractTraeCnToken,
+ fetchTraeCnUsagePage,
+ fetchTraeCnUsage,
+ fetchTraeCnUsageWithAuth,
+} = require("../src/lib/trae-cn-config");
+
+const CN_APP_DIR = "TRAE SOLO CN";
+
+// Frozen fixture: encrypted with the documented KDF, fixed 32-byte salt.
+const FIXTURE_B64 =
+ "dGMFEAAA3q2+796tvu/erb7v3q2+796tvu/erb7v3q2+796tvu/43q0BL3Fo+gI3mEsCEdP2c66sO1HLQB7bKIlHUL7xJg0PVJHLSzn/gDVQmH7Ksh0wflKSFHtHgz7rwynP4aOOx3CPgDR5lQ8neR7CWouH94Dh99HlsWwqo29etbVBVbTU18KIS74UPyA5ASAWnYHqKPQSfv8uCn6GOMfOyJPNPMI6Yz666PBNVFTastLi70ThZ6Lb/Gs+bjyTDrQnSfjDnDJPinjJejWWlboznbnME/rNkmc2LLMftj8jGqorIq4=";
+const FIXTURE_TOKEN = "fake.jwt.eyJzdWIiOiJ0ZXN0IiwiZXhwIjo5OTk5OTk5OTk5fQ.sig";
+const FIXTURE_AUTH = {
+ token: FIXTURE_TOKEN,
+ refreshToken: "synthetic-refresh",
+ userId: "user-123",
+};
+
+// Independent encryptor — own copy of the documented constants + KDF.
+const TEST_MAGIC = Buffer.from([0x74, 0x63, 0x05, 0x10, 0x00, 0x00]);
+const TEST_JG = Buffer.from([
+ 82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130,
+ 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61,
+ 238, 76, 149, 11, 66, 250, 195, 78, 8, 46, 161, 102, 40, 217, 36, 178, 118, 91, 162, 73,
+ 109, 139, 209, 37,
+]);
+const TEST_KG = Buffer.from([
+ 31, 221, 168, 51, 136, 7, 199, 49, 177, 18, 16, 89, 39, 128, 236, 95, 96, 81, 127, 169, 25,
+ 181, 74, 13, 45, 229, 122, 159, 147, 201, 156, 239, 160, 224, 59, 77, 174, 42, 245, 176,
+ 200, 235, 187, 60, 131, 83, 153, 97, 23, 43, 4, 126, 186, 119, 214, 38, 225, 105, 20, 99,
+ 85, 33, 12, 125,
+]);
+
+function testPassword() {
+ const pw = Buffer.alloc(64);
+ for (let i = 0; i < 64; i++) pw[i] = TEST_JG[i] ^ TEST_KG[i];
+ return pw;
+}
+
+function testDerive(salt) {
+ const kdfBuf = Buffer.concat([crypto.createHash("sha512").update(salt).digest(), testPassword()]);
+ const out = crypto.createHash("sha512").update(kdfBuf).digest();
+ return { key: out.subarray(0, 16), iv: out.subarray(16, 32) };
+}
+
+function testEncrypt(plainText, salt = Buffer.alloc(32, 0x11)) {
+ const plain = Buffer.from(plainText, "utf8");
+ const { key, iv } = testDerive(salt);
+ const hash = crypto.createHash("sha512").update(plain).digest();
+ const cipher = crypto.createCipheriv("aes-128-cbc", key, iv);
+ const ct = Buffer.concat([cipher.update(Buffer.concat([hash, plain])), cipher.final()]);
+ return Buffer.concat([TEST_MAGIC, salt, ct]);
+}
+
+function makeTraeCnHome() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "trae-cn-test-"));
+}
+
+function writeStorage(traeHome, value, { authKey = TRAE_CN_AUTH_KEY } = {}) {
+ const dir = path.join(traeHome, "User", "globalStorage");
+ fs.mkdirSync(dir, { recursive: true });
+ const storagePath = path.join(dir, "storage.json");
+ const payload = {};
+ if (value !== undefined) payload[authKey] = value;
+ fs.writeFileSync(storagePath, JSON.stringify(payload));
+ return storagePath;
+}
+
+function jsonResponse(status, body) {
+ return {
+ status,
+ ok: status >= 200 && status < 300,
+ json: async () => body,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Path resolution
+// ---------------------------------------------------------------------------
+
+test("resolveTraeCnHome honors TOKENTRACKER_TRAE_CN_HOME override", () => {
+ assert.equal(
+ resolveTraeCnHome({ env: { [TRAE_CN_HOME_ENV]: "/custom/cn-home" } }),
+ path.resolve("/custom/cn-home"),
+ );
+ // Whitespace-only override falls back to the platform default.
+ const fallback = resolveTraeCnHome({ env: { [TRAE_CN_HOME_ENV]: " " }, platform: "darwin", home: "/Users/t" });
+ assert.equal(fallback, path.join("/Users/t", "Library", "Application Support", CN_APP_DIR));
+});
+
+test("resolveTraeCnHome resolves the macOS default and rejects other platforms", () => {
+ const home = "/Users/tester";
+ assert.equal(
+ resolveTraeCnHome({ platform: "darwin", home }),
+ path.join(home, "Library", "Application Support", CN_APP_DIR),
+ );
+ // Phase A is macOS + test-injected paths only.
+ assert.equal(resolveTraeCnHome({ platform: "win32", home, env: {} }), null);
+ assert.equal(resolveTraeCnHome({ platform: "linux", home, env: {} }), null);
+});
+
+test("resolveTraeCnStoragePath points at User/globalStorage/storage.json", () => {
+ const override = "/tmp/cn-install";
+ assert.equal(
+ resolveTraeCnStoragePath({ env: { [TRAE_CN_HOME_ENV]: override } }),
+ path.join(path.resolve(override), "User", "globalStorage", "storage.json"),
+ );
+ assert.equal(
+ resolveTraeCnStoragePath({ platform: "darwin", home: "/Users/tester", env: {} }),
+ path.join("/Users/tester", "Library", "Application Support", CN_APP_DIR, "User", "globalStorage", "storage.json"),
+ );
+ assert.equal(resolveTraeCnStoragePath({ platform: "win32", home: "/Users/tester", env: {} }), null);
+});
+
+// ---------------------------------------------------------------------------
+// Storage auth reading: plaintext + encrypted tc v5
+// ---------------------------------------------------------------------------
+
+test("frozen tc v5 fixture decrypts to the expected auth (broken-KDF detector)", () => {
+ assert.deepEqual(parseTraeCnAuthValue(FIXTURE_B64), FIXTURE_AUTH);
+ assert.equal(JSON.parse(decryptTraeCnBase64(FIXTURE_B64)).token, FIXTURE_TOKEN);
+});
+
+test("independent-encryptor round trip decrypts through production", () => {
+ const blob = testEncrypt(JSON.stringify({ token: "roundtrip.jwt.abc", refreshToken: "x" }));
+ assert.deepEqual(parseTraeCnAuthValue(blob.toString("base64")), { token: "roundtrip.jwt.abc", refreshToken: "x" });
+});
+
+test("readTraeCnAuthFromStorage reads plaintext JSON object auth", () => {
+ const home = makeTraeCnHome();
+ writeStorage(home, { token: "plain.jwt.1", refreshToken: "r1" });
+ assert.deepEqual(
+ readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }),
+ { token: "plain.jwt.1", refreshToken: "r1" },
+ );
+});
+
+test("readTraeCnAuthFromStorage reads plaintext JSON string auth", () => {
+ const home = makeTraeCnHome();
+ writeStorage(home, JSON.stringify({ token: "plain.jwt.2", refreshToken: "r2" }));
+ assert.deepEqual(
+ readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }),
+ { token: "plain.jwt.2", refreshToken: "r2" },
+ );
+});
+
+test("readTraeCnAuthFromStorage reads the encrypted tc v5 blob", () => {
+ const home = makeTraeCnHome();
+ writeStorage(home, FIXTURE_B64);
+ const auth = readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" });
+ assert.equal(auth.token, FIXTURE_TOKEN);
+ assert.equal(extractTraeCnToken(auth), FIXTURE_TOKEN);
+});
+
+test("readTraeCnAuthFromStorage returns null when not configured", () => {
+ const home = makeTraeCnHome();
+ const storagePath = path.join(home, "User", "globalStorage", "storage.json");
+ assert.equal(readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }), null);
+ fs.mkdirSync(path.dirname(storagePath), { recursive: true });
+ fs.writeFileSync(storagePath, JSON.stringify({}));
+ assert.equal(readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }), null);
+});
+
+// ---------------------------------------------------------------------------
+// Malformed / tampered rejection (credential-safe errors)
+// ---------------------------------------------------------------------------
+
+test("malformed storage.json fails closed with a credential-safe error", () => {
+ const home = makeTraeCnHome();
+ const storagePath = path.join(home, "User", "globalStorage", "storage.json");
+ fs.mkdirSync(path.dirname(storagePath), { recursive: true });
+ fs.writeFileSync(storagePath, "{not json");
+ assert.throws(
+ () => readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }),
+ /not valid JSON/,
+ );
+});
+
+test("malformed plaintext JSON fails closed", () => {
+ const home = makeTraeCnHome();
+ writeStorage(home, "{broken");
+ assert.throws(
+ () => readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" }),
+ /auth JSON is malformed/,
+ );
+});
+
+test("tampered ciphertext is rejected and leaks nothing", () => {
+ const blob = Buffer.from(FIXTURE_B64, "base64");
+ blob[blob.length - 16] ^= 0x01; // flip a byte in the ciphertext
+ const tampered = blob.toString("base64");
+ assert.throws(() => parseTraeCnAuthValue(tampered));
+ try {
+ parseTraeCnAuthValue(tampered);
+ assert.fail("expected throw");
+ } catch (error) {
+ assert.ok(!error.message.includes(FIXTURE_TOKEN), "error must not contain the token");
+ assert.ok(!error.message.includes(tampered), "error must not contain the blob");
+ }
+});
+
+test("wrong magic / too-short / bad base64 are rejected", () => {
+ const blob = Buffer.from(FIXTURE_B64, "base64");
+ blob[0] = 0x78; // "x"
+ assert.throws(() => parseTraeCnAuthValue(blob.toString("base64")), /unknown format/);
+ assert.throws(() => parseTraeCnAuthValue(Buffer.alloc(16).toString("base64")), /too short|unknown format/);
+ assert.throws(() => parseTraeCnAuthValue("not-base64@#$"), /Trae CN auth/);
+ assert.throws(() => parseTraeCnAuthValue(""), /empty|malformed/);
+ assert.throws(() => parseTraeCnAuthValue(42), /missing or malformed/);
+});
+
+test("extractTraeCnToken extracts a nonempty JWT and rejects empty/missing", () => {
+ assert.equal(extractTraeCnToken({ token: " abc.jwt.xyz " }), "abc.jwt.xyz");
+ assert.throws(() => extractTraeCnToken({ token: "" }), /has no token/);
+ assert.throws(() => extractTraeCnToken({}), /has no token/);
+ assert.throws(() => extractTraeCnToken(null), /has no token/);
+});
+
+// ---------------------------------------------------------------------------
+// Usage API page fetch
+// ---------------------------------------------------------------------------
+
+const JWT = "test.jwt.usage-123";
+const START = 1700000000;
+const END = 1700086400;
+
+test("fetchTraeCnUsagePage sends the exact request", async () => {
+ let captured;
+ const fetchImpl = async (url, options) => {
+ captured = { url, options };
+ return jsonResponse(200, { user_usage_group_by_sessions: [{ id: 1 }], total: 1 });
+ };
+ await fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl });
+ assert.equal(captured.url, TRAE_CN_USAGE_URL);
+ assert.equal(captured.options.method, "POST");
+ assert.equal(captured.options.headers["Content-Type"], "application/json");
+ assert.equal(captured.options.headers.Authorization, `Cloud-IDE-JWT ${JWT}`);
+ assert.deepEqual(JSON.parse(captured.options.body), {
+ usage_type: [7],
+ start_time: START,
+ end_time: END,
+ page_num: 1,
+ page_size: 20,
+ });
+});
+
+test("fetchTraeCnUsagePage parses both documented wrappers", async () => {
+ const topLevel = await fetchTraeCnUsagePage({
+ jwt: JWT, start_time: START, end_time: END, page_num: 1,
+ fetchImpl: async () => jsonResponse(200, { user_usage_group_by_sessions: [{ id: 1 }, { id: 2 }], total: 2 }),
+ });
+ assert.equal(topLevel.sessions.length, 2);
+ assert.equal(topLevel.total, 2);
+
+ const nested = await fetchTraeCnUsagePage({
+ jwt: JWT, start_time: START, end_time: END, page_num: 1,
+ fetchImpl: async () => jsonResponse(200, { data: { user_usage_group_by_sessions: [{ id: 3 }], total: 1 } }),
+ });
+ assert.equal(nested.sessions.length, 1);
+ assert.equal(nested.total, 1);
+
+ const noTotal = await fetchTraeCnUsagePage({
+ jwt: JWT, start_time: START, end_time: END, page_num: 1,
+ fetchImpl: async () => jsonResponse(200, { user_usage_group_by_sessions: [{ id: 9 }] }),
+ });
+ assert.equal(noTotal.sessions.length, 1);
+ assert.equal(noTotal.total, null);
+});
+
+test("fetchTraeCnUsagePage fails closed when the session wrapper is missing", async () => {
+ const bodies = [
+ {},
+ { data: {} },
+ { data: { other: 1 } },
+ { user_usage_group_by_sessions: "not-an-array" },
+ { data: { user_usage_group_by_sessions: null } },
+ ];
+ for (const body of bodies) {
+ await assert.rejects(
+ fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl: async () => jsonResponse(200, body) }),
+ (error) => {
+ assert.match(error.message, /missing the session list/);
+ assert.ok(!error.message.includes(JWT), "schema error must not leak the JWT");
+ return true;
+ },
+ );
+ }
+});
+
+test("fetchTraeCnUsagePage accepts an explicit empty session array", async () => {
+ const result = await fetchTraeCnUsagePage({
+ jwt: JWT, start_time: START, end_time: END, page_num: 1,
+ fetchImpl: async () => jsonResponse(200, { user_usage_group_by_sessions: [], total: 0 }),
+ });
+ assert.deepEqual(result.sessions, []);
+ assert.equal(result.total, 0);
+});
+
+test("fetchTraeCnUsagePage validates start_time / end_time / page_size locally", async () => {
+ const ok = () => async () => jsonResponse(200, { user_usage_group_by_sessions: [] });
+ const page = (overrides) =>
+ fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl: ok(), ...overrides });
+
+ await assert.rejects(page({ start_time: 0 }), /start_time must be a positive integer/);
+ await assert.rejects(page({ start_time: -1 }), /start_time must be a positive integer/);
+ await assert.rejects(page({ start_time: 1.5 }), /start_time must be a positive integer/);
+ await assert.rejects(page({ start_time: Infinity }), /start_time must be a positive integer/);
+ await assert.rejects(page({ start_time: "1700000000" }), /start_time must be a positive integer/);
+
+ await assert.rejects(page({ end_time: 0 }), /end_time must be a positive integer/);
+ await assert.rejects(page({ end_time: 1.5 }), /end_time must be a positive integer/);
+
+ await assert.rejects(page({ start_time: END, end_time: START }), /start_time must not be after end_time/);
+
+ await assert.rejects(page({ page_size: 0 }), /page_size must be a positive integer no greater than 20/);
+ await assert.rejects(page({ page_size: -5 }), /page_size must be a positive integer no greater than 20/);
+ await assert.rejects(page({ page_size: 21 }), /page_size must be a positive integer no greater than 20/);
+ await assert.rejects(page({ page_size: 2.5 }), /page_size must be a positive integer no greater than 20/);
+
+ const valid = await page({ page_size: 20, start_time: START, end_time: END });
+ assert.deepEqual(valid.sessions, []);
+ const smallPage = await page({ page_size: 5 });
+ assert.equal(smallPage.page_size, 5);
+});
+
+test("fetchTraeCnUsagePage errors are useful and credential-safe", async () => {
+ await assert.rejects(
+ fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl: async () => jsonResponse(401, {}) }),
+ (error) => {
+ assert.match(error.message, /HTTP 401/);
+ assert.equal(error.status, 401);
+ assert.equal(error.code, "AUTH_EXPIRED");
+ assert.ok(!error.message.includes(JWT));
+ return true;
+ },
+ );
+
+ await assert.rejects(
+ fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl: async () => jsonResponse(500, {}) }),
+ (error) => {
+ assert.match(error.message, /HTTP 500/);
+ assert.ok(!error.message.includes(JWT));
+ return true;
+ },
+ );
+
+ await assert.rejects(
+ fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl: async () => jsonResponse(200, { code: 10001, data: {} }) }),
+ (error) => {
+ assert.match(error.message, /error code 10001/);
+ assert.equal(error.apiCode, 10001);
+ assert.ok(!error.message.includes(JWT));
+ return true;
+ },
+ );
+
+ await assert.rejects(
+ fetchTraeCnUsagePage({ jwt: "", start_time: START, end_time: END, page_num: 1 }),
+ /requires a token/,
+ );
+ await assert.rejects(
+ fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 0 }),
+ /positive integer/,
+ );
+});
+
+test("fetchTraeCnUsagePage sanitizes transport exceptions containing the JWT", async () => {
+ const jwtCanary = "jwt-transport-canary";
+ await assert.rejects(
+ fetchTraeCnUsagePage({
+ jwt: jwtCanary,
+ start_time: START,
+ end_time: END,
+ page_num: 1,
+ fetchImpl: async (_url, options) => {
+ throw new Error(`request failed ${jwtCanary} ${options.headers.Authorization}`);
+ },
+ }),
+ (error) => {
+ assert.equal(error.message, "Trae CN usage API request failed.");
+ assert.ok(!error.message.includes(jwtCanary));
+ return true;
+ },
+ );
+});
+
+// ---------------------------------------------------------------------------
+// Pagination
+// ---------------------------------------------------------------------------
+
+function pagedFetch({ rowsPerPage, total } = {}) {
+ const requestedPages = [];
+ const fetchImpl = async (_url, options) => {
+ const body = JSON.parse(options.body);
+ requestedPages.push(body.page_num);
+ const rows = Array.from({ length: rowsPerPage(body) }, (_v, i) => ({ id: body.page_num * 1000 + i }));
+ const responseBody = { user_usage_group_by_sessions: rows };
+ if (total !== undefined) responseBody.total = total;
+ return jsonResponse(200, responseBody);
+ };
+ return { fetchImpl, requestedPages };
+}
+
+test("fetchTraeCnUsage paginates serially and stops at the stated total", async () => {
+ const total = 45;
+ const { fetchImpl, requestedPages } = pagedFetch({
+ rowsPerPage: (body) => {
+ const remaining = total - (body.page_num - 1) * body.page_size;
+ return Math.max(0, Math.min(body.page_size, remaining));
+ },
+ total,
+ });
+ const result = await fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0 });
+ assert.deepEqual(requestedPages, [1, 2, 3]);
+ assert.equal(result.sessions.length, 45);
+ assert.equal(result.total, 45);
+ assert.equal(result.pages_fetched, 3);
+});
+
+test("fetchTraeCnUsage stops on an empty page when total is absent", async () => {
+ const requestedPages = [];
+ const fetchImpl = async (_url, options) => {
+ const body = JSON.parse(options.body);
+ requestedPages.push(body.page_num);
+ const rows = body.page_num === 1 ? [{ id: 1 }] : [];
+ return jsonResponse(200, { user_usage_group_by_sessions: rows });
+ };
+ const result = await fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0 });
+ assert.deepEqual(requestedPages, [1, 2]);
+ assert.equal(result.sessions.length, 1);
+ assert.equal(result.total, null);
+ assert.equal(result.pages_fetched, 2);
+});
+
+test("fetchTraeCnUsage fails closed when maxPages is reached and more is needed", async () => {
+ const { fetchImpl, requestedPages } = pagedFetch({ rowsPerPage: () => 20 });
+ await assert.rejects(
+ fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0, maxPages: 3 }),
+ /exceeded the maximum page count/,
+ );
+ // Never returns a partial snapshot: all 3 cap pages were fetched, then it
+ // throws because page 4 would still be needed.
+ assert.deepEqual(requestedPages, [1, 2, 3]);
+});
+
+test("fetchTraeCnUsage succeeds when maxPages exactly covers the total", async () => {
+ const { fetchImpl, requestedPages } = pagedFetch({ rowsPerPage: () => 10, total: 30 });
+ const result = await fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0, maxPages: 3 });
+ assert.deepEqual(requestedPages, [1, 2, 3]);
+ assert.equal(result.sessions.length, 30);
+ assert.equal(result.pages_fetched, 3);
+});
+
+test("production inter-page delay constant is 300ms (tests inject delayMs: 0)", () => {
+ assert.equal(TRAE_CN_USAGE_DELAY_MS, 300);
+});
+
+// ---------------------------------------------------------------------------
+// Storage-backed fetch with single 401/403 retry
+// ---------------------------------------------------------------------------
+
+test("fetchTraeCnUsageWithAuth retries exactly once on 401 by re-reading storage", async () => {
+ const home = makeTraeCnHome();
+ const storagePath = writeStorage(home, { token: "rotated.jwt.2", refreshToken: "r2" });
+ const authCalls = [];
+ const usageCalls = [];
+ const readAuth = () => {
+ authCalls.push(fs.readFileSync(storagePath, "utf8"));
+ return readTraeCnAuthFromStorage({ env: { [TRAE_CN_HOME_ENV]: home }, platform: "darwin" });
+ };
+ const usageFetcher = async ({ jwt }) => {
+ usageCalls.push(jwt);
+ if (usageCalls.length === 1) {
+ const error = new Error("Trae CN usage API returned HTTP 401.");
+ error.status = 401;
+ error.code = "AUTH_EXPIRED";
+ throw error;
+ }
+ return { sessions: [{ id: 7 }], total: 1, pages_fetched: 1, truncated: false };
+ };
+ const result = await fetchTraeCnUsageWithAuth({
+ start_time: START,
+ end_time: END,
+ fetchImpl: async () => jsonResponse(200, {}),
+ delayMs: 0,
+ readAuth,
+ usageFetcher,
+ env: { [TRAE_CN_HOME_ENV]: home },
+ platform: "darwin",
+ });
+ assert.equal(usageCalls.length, 2);
+ assert.equal(usageCalls[0], "rotated.jwt.2");
+ assert.equal(usageCalls[1], "rotated.jwt.2");
+ assert.equal(authCalls.length, 2, "storage re-read once");
+ assert.equal(result.sessions[0].id, 7);
+});
+
+test("fetchTraeCnUsageWithAuth never retries more than once", async () => {
+ const usageCalls = [];
+ const usageFetcher = async () => {
+ usageCalls.push(1);
+ const error = new Error("Trae CN usage API returned HTTP 403.");
+ error.status = 403;
+ error.code = "AUTH_EXPIRED";
+ throw error;
+ };
+ await assert.rejects(
+ fetchTraeCnUsageWithAuth({
+ start_time: START, end_time: END, fetchImpl: async () => jsonResponse(200, {}), delayMs: 0,
+ readAuth: () => ({ token: "a.jwt.1", refreshToken: "r" }),
+ usageFetcher,
+ }),
+ /HTTP 403/,
+ );
+ assert.equal(usageCalls.length, 2, "exactly two attempts");
+});
+
+test("fetchTraeCnUsageWithAuth does not retry on non-auth errors", async () => {
+ const usageCalls = [];
+ const usageFetcher = async () => {
+ usageCalls.push(1);
+ const error = new Error("Trae CN usage API returned HTTP 500.");
+ error.status = 500;
+ throw error;
+ };
+ await assert.rejects(
+ fetchTraeCnUsageWithAuth({
+ start_time: START, end_time: END, fetchImpl: async () => jsonResponse(200, {}), delayMs: 0,
+ readAuth: () => ({ token: "a.jwt.1", refreshToken: "r" }),
+ usageFetcher,
+ }),
+ /HTTP 500/,
+ );
+ assert.equal(usageCalls.length, 1);
+});
+
+test("fetchTraeCnUsageWithAuth fails when credentials are missing", async () => {
+ await assert.rejects(
+ fetchTraeCnUsageWithAuth({ start_time: START, end_time: END, readAuth: () => null, usageFetcher: async () => ({}) }),
+ /credentials are not configured/,
+ );
+});
diff --git a/test/trae-cn-parser.test.js b/test/trae-cn-parser.test.js
new file mode 100644
index 000000000..ba4b98baf
--- /dev/null
+++ b/test/trae-cn-parser.test.js
@@ -0,0 +1,504 @@
+/**
+ * Trae Work CN usage-API incremental parser tests.
+ *
+ * Synthetic rows only. Verifies the parser's contract:
+ * - raw normalization / prevalidation before any cursor/bucket mutation
+ * - per-session reconciliation (subtract old contribution, add new) with a
+ * versioned cursors.traeCn state and source "trae-cn"
+ * - fail-closed corruption / malformed-state handling
+ */
+"use strict";
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const test = require("node:test");
+
+const { parseTraeCnApiIncremental } = require("../src/lib/rollout");
+
+function tempQueue() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-trae-cn-"));
+ return {
+ dir,
+ queuePath: path.join(dir, "queue.jsonl"),
+ };
+}
+
+function queueRows(queuePath) {
+ if (!fs.existsSync(queuePath)) return [];
+ return fs.readFileSync(queuePath, "utf8")
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line));
+}
+
+function lastTraeRow(queuePath, model, hourStart) {
+ return queueRows(queuePath)
+ .filter(
+ (row) =>
+ row.source === "trae-cn" &&
+ row.model === model &&
+ (hourStart === undefined || row.hour_start === hourStart),
+ )
+ .at(-1);
+}
+
+// Compare only the deterministic, meaningful state (drops updatedAt timestamps).
+function stripState(cursors) {
+ const buckets = {};
+ for (const [key, bucket] of Object.entries(cursors.hourly?.buckets || {})) {
+ buckets[key] = bucket?.totals;
+ }
+ const sessions = {};
+ for (const [id, entry] of Object.entries(cursors.traeCn?.sessions || {})) {
+ sessions[id] = { model: entry.model, bucketStart: entry.bucketStart, totals: entry.totals };
+ }
+ return { hourlyBuckets: buckets, sessions };
+}
+
+const T1 = 1_700_000_000; // 2023-11-14T22:13:20Z -> 22:00 bucket
+const T2 = 1_700_002_000; // 2023-11-14T22:46:40Z -> 22:30 bucket
+const B1 = "2023-11-14T22:00:00.000Z";
+const B2 = "2023-11-14T22:30:00.000Z";
+
+function sessionRow(overrides = {}) {
+ return {
+ session_id: "s1",
+ model_name: "doubao-pro",
+ usage_time: T1,
+ input_token: 100,
+ output_token: 10,
+ cache_read_token: 0,
+ cache_write_token: 0,
+ ...overrides,
+ };
+}
+
+test("first insert maps direct and extra_info shapes into canonical buckets", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {};
+ let progressCalls = 0;
+ const onProgress = () => {
+ progressCalls += 1;
+ };
+ const direct = sessionRow({ session_id: "s-direct", cache_read_token: 30, cache_write_token: 40 });
+ const extra = {
+ session_id: "s-extra",
+ model_name: "doubao-lite",
+ usage_time: T2,
+ extra_info: { input_token: 5, output_token: 6, cache_read_token: 7, cache_write_token: 8 },
+ };
+
+ const result = await parseTraeCnApiIncremental({ sessions: [direct, extra], cursors, queuePath, onProgress });
+ assert.equal(result.recordsProcessed, 2);
+ assert.equal(result.eventsAggregated, 2);
+ assert.ok(result.bucketsQueued >= 1);
+ assert.ok(progressCalls > 0, "progress callback fires");
+
+ const rows = queueRows(queuePath).filter((row) => row.source === "trae-cn");
+ const pro = rows.find((row) => row.model === "doubao-pro");
+ const lite = rows.find((row) => row.model === "doubao-lite");
+ assert.ok(pro, "direct row queued");
+ assert.ok(lite, "extra_info row queued");
+
+ assert.equal(pro.hour_start, B1);
+ assert.deepEqual(
+ {
+ input: pro.input_tokens,
+ cached: pro.cached_input_tokens,
+ cacheCreation: pro.cache_creation_input_tokens,
+ output: pro.output_tokens,
+ reasoning: pro.reasoning_output_tokens,
+ total: pro.total_tokens,
+ billable: pro.billable_total_tokens,
+ conv: pro.conversation_count,
+ },
+ { input: 100, cached: 30, cacheCreation: 40, output: 10, reasoning: 0, total: 180, billable: 180, conv: 1 },
+ );
+
+ assert.equal(lite.hour_start, B2);
+ assert.equal(lite.input_tokens, 5);
+ assert.equal(lite.output_tokens, 6);
+ assert.equal(lite.cached_input_tokens, 7);
+ assert.equal(lite.cache_creation_input_tokens, 8);
+ assert.equal(lite.total_tokens, 26);
+
+ assert.equal(cursors.traeCn.version, 1);
+ assert.equal(cursors.traeCn.sessions["s-direct"].model, "doubao-pro");
+ assert.equal(cursors.traeCn.sessions["s-direct"].bucketStart, B1);
+ assert.equal(cursors.traeCn.sessions["s-direct"].totals.input_tokens, 100);
+ assert.equal(cursors.traeCn.sessions["s-extra"].bucketStart, B2);
+});
+
+test("identical payload twice appends no queue row and no token growth", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {};
+ const row = sessionRow();
+
+ const first = await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath });
+ assert.equal(first.eventsAggregated, 1);
+ const before = fs.readFileSync(queuePath, "utf8");
+
+ const second = await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath });
+ assert.equal(second.recordsProcessed, 1);
+ assert.equal(second.eventsAggregated, 0);
+ assert.equal(second.bucketsQueued, 0);
+ assert.equal(fs.readFileSync(queuePath, "utf8"), before, "queue unchanged");
+});
+
+test("growth and downward correction preserve a shared-bucket sibling", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {};
+ const a = sessionRow({ session_id: "A" });
+ const b = sessionRow({ session_id: "B", input_token: 200, output_token: 20 });
+
+ await parseTraeCnApiIncremental({ sessions: [a, b], cursors, queuePath });
+
+ const grow = await parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: "A", input_token: 150 })], cursors, queuePath });
+ assert.equal(grow.eventsAggregated, 1);
+ let latest = lastTraeRow(queuePath, "doubao-pro");
+ assert.equal(latest.input_tokens, 200 + 150, "sibling B kept while A grows");
+ assert.equal(latest.output_tokens, 20 + 10);
+
+ const shrink = await parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: "A", input_token: 60 })], cursors, queuePath });
+ assert.equal(shrink.eventsAggregated, 1);
+ latest = lastTraeRow(queuePath, "doubao-pro");
+ assert.equal(latest.input_tokens, 200 + 60, "sibling B kept while A shrinks");
+ assert.equal(latest.total_tokens, 200 + 20 + 60 + 10);
+ assert.equal(cursors.traeCn.sessions.B.totals.input_tokens, 200, "sibling contribution preserved in cursor");
+});
+
+test("model and time-bucket moves retract the old tuple and write the new one", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {};
+ const row = sessionRow();
+
+ await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath });
+ assert.equal(lastTraeRow(queuePath, "doubao-pro", B1).total_tokens, 110);
+
+ // Model move: same bucket, different model.
+ const moved = await parseTraeCnApiIncremental({ sessions: [sessionRow({ model_name: "doubao-lite" })], cursors, queuePath });
+ assert.equal(moved.eventsAggregated, 1);
+ assert.equal(lastTraeRow(queuePath, "doubao-pro", B1).total_tokens, 0, "old model tuple retracted to zero");
+ assert.equal(lastTraeRow(queuePath, "doubao-lite", B1).total_tokens, 110);
+
+ // Time-bucket move: same model, shifted half-hour bucket.
+ const shifted = await parseTraeCnApiIncremental({ sessions: [sessionRow({ model_name: "doubao-lite", usage_time: T2 })], cursors, queuePath });
+ assert.equal(shifted.eventsAggregated, 1);
+ assert.equal(lastTraeRow(queuePath, "doubao-lite", B1).total_tokens, 0, "old time tuple retracted to zero");
+ assert.equal(lastTraeRow(queuePath, "doubao-lite", B2).total_tokens, 110);
+});
+
+test("missing model_name values use the TRAE sentinel and reconcile without changing totals", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {};
+ const missing = sessionRow({ session_id: "missing-model" });
+ delete missing.model_name;
+ const nullModel = sessionRow({ session_id: "null-model", model_name: null });
+ const blankModel = sessionRow({ session_id: "blank-model", model_name: " ", mode: "" });
+
+ await parseTraeCnApiIncremental({ sessions: [missing, nullModel, blankModel], cursors, queuePath });
+ assert.equal(cursors.traeCn.sessions["missing-model"].model, "trae-cn-unknown");
+ assert.equal(cursors.traeCn.sessions["null-model"].model, "trae-cn-unknown");
+ assert.equal(cursors.traeCn.sessions["blank-model"].model, "trae-cn-unknown");
+ assert.equal(lastTraeRow(queuePath, "trae-cn-unknown", B1).total_tokens, 330);
+
+ await parseTraeCnApiIncremental({
+ sessions: [sessionRow({ session_id: "transition", model_name: "doubao-pro" })],
+ cursors,
+ queuePath,
+ });
+ await parseTraeCnApiIncremental({
+ sessions: [sessionRow({ session_id: "transition", model_name: "" })],
+ cursors,
+ queuePath,
+ });
+ assert.equal(lastTraeRow(queuePath, "doubao-pro", B1).total_tokens, 0);
+ assert.equal(lastTraeRow(queuePath, "trae-cn-unknown", B1).total_tokens, 440);
+
+ await parseTraeCnApiIncremental({
+ sessions: [sessionRow({ session_id: "transition", model_name: "doubao-pro" })],
+ cursors,
+ queuePath,
+ });
+ assert.equal(lastTraeRow(queuePath, "trae-cn-unknown", B1).total_tokens, 330);
+ assert.equal(lastTraeRow(queuePath, "doubao-pro", B1).total_tokens, 110);
+});
+
+test("reordered payload and identical duplicate session records yield the same result", async (t) => {
+ const dirA = tempQueue();
+ const dirB = tempQueue();
+ t.after(() => {
+ fs.rmSync(dirA.dir, { recursive: true, force: true });
+ fs.rmSync(dirB.dir, { recursive: true, force: true });
+ });
+ const cursorsA = {};
+ const cursorsB = {};
+ const a = sessionRow({ session_id: "A", input_token: 10, output_token: 1 });
+ const b = sessionRow({ session_id: "B", model_name: "doubao-lite", usage_time: T2, input_token: 20, output_token: 2 });
+
+ await parseTraeCnApiIncremental({ sessions: [a, b], cursors: cursorsA, queuePath: dirA.queuePath });
+ await parseTraeCnApiIncremental({ sessions: [b, a], cursors: cursorsB, queuePath: dirB.queuePath });
+ assert.deepEqual(stripState(cursorsB), stripState(cursorsA), "order does not change the result");
+
+ // Identical duplicate session records are accepted once within one payload.
+ const dirC = tempQueue();
+ t.after(() => fs.rmSync(dirC.dir, { recursive: true, force: true }));
+ const cursorsC = {};
+ const dup = await parseTraeCnApiIncremental({
+ sessions: [a, { ...a }],
+ cursors: cursorsC,
+ queuePath: dirC.queuePath,
+ });
+ assert.equal(dup.recordsProcessed, 2);
+ assert.equal(dup.eventsAggregated, 1, "exact duplicate accepted once");
+ assert.equal(cursorsC.traeCn.sessions.A.totals.input_tokens, 10);
+});
+
+test("conflicting duplicate ids and malformed rows fail before any mutation", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {};
+ const canary = "session-id-canary";
+ const row = sessionRow({ session_id: canary });
+
+ await assert.rejects(
+ parseTraeCnApiIncremental({ sessions: [row, sessionRow({ session_id: canary, input_token: 200 })], cursors, queuePath }),
+ (error) => {
+ assert.match(error.message, /conflicting contributions/);
+ assert.ok(!error.message.includes(canary));
+ return true;
+ },
+ );
+ assert.equal(cursors.hourly, undefined, "no hourly state assigned");
+ assert.equal(cursors.traeCn, undefined, "no traeCn state assigned");
+ assert.equal(fs.existsSync(queuePath), false, "no queue rows written");
+
+ // Malformed rows: missing token field must not be coerced to zero.
+ const missing = sessionRow({ session_id: canary });
+ delete missing.cache_read_token;
+ await assert.rejects(
+ parseTraeCnApiIncremental({ sessions: [missing], cursors, queuePath }),
+ (error) => {
+ assert.match(error.message, /invalid cache_read_token/);
+ assert.ok(!error.message.includes(canary));
+ return true;
+ },
+ );
+ await assert.rejects(
+ parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, cache_read_token: "lots" })], cursors, queuePath }),
+ (error) => {
+ assert.match(error.message, /invalid cache_read_token/);
+ assert.ok(!error.message.includes(canary));
+ return true;
+ },
+ );
+ // Model names containing "|" are rejected because bucket keys are unescaped.
+ await assert.rejects(
+ parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, model_name: "bad|model" })], cursors, queuePath }),
+ (error) => {
+ assert.match(error.message, /unsupported model name/);
+ assert.ok(!error.message.includes(canary));
+ return true;
+ },
+ );
+ // Invalid usage_time / sessions / cursors.
+ await assert.rejects(
+ parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, usage_time: -5 })], cursors, queuePath }),
+ (error) => {
+ assert.match(error.message, /invalid usage_time/);
+ assert.ok(!error.message.includes(canary));
+ return true;
+ },
+ );
+ await assert.rejects(parseTraeCnApiIncremental({ sessions: "nope", cursors, queuePath }), /sessions must be an array/);
+ await assert.rejects(parseTraeCnApiIncremental({ sessions: [], cursors: null, queuePath }), /cursors must be a writable object/);
+
+ assert.equal(cursors.hourly, undefined);
+ assert.equal(cursors.traeCn, undefined);
+ assert.equal(fs.existsSync(queuePath), false);
+});
+
+test("empty payload is a no-op and preserves existing contributions", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {};
+ const row = sessionRow();
+
+ await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath });
+ const before = fs.readFileSync(queuePath, "utf8");
+
+ const empty = await parseTraeCnApiIncremental({ sessions: [], cursors, queuePath });
+ assert.equal(empty.recordsProcessed, 0);
+ assert.equal(empty.eventsAggregated, 0);
+ assert.equal(empty.bucketsQueued, 0);
+ assert.equal(fs.readFileSync(queuePath, "utf8"), before, "no queue change");
+ assert.equal(cursors.traeCn.sessions["s1"].totals.input_tokens, 100, "contribution preserved");
+ assert.equal(lastTraeRow(queuePath, "doubao-pro").total_tokens, 110);
+});
+
+test("cursors survive a serialize/parse restart and a later correction", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ let cursors = {};
+ const row = sessionRow();
+
+ await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath });
+ cursors = JSON.parse(JSON.stringify(cursors));
+
+ // Identical re-run after restart is a no-op.
+ const before = fs.readFileSync(queuePath, "utf8");
+ const noop = await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath });
+ assert.equal(noop.eventsAggregated, 0);
+ assert.equal(noop.bucketsQueued, 0);
+ assert.equal(fs.readFileSync(queuePath, "utf8"), before);
+
+ // Correction after restart retracts the old tuple and writes the new one.
+ const corrected = await parseTraeCnApiIncremental({ sessions: [sessionRow({ input_token: 120 })], cursors, queuePath });
+ assert.equal(corrected.eventsAggregated, 1);
+ const latest = lastTraeRow(queuePath, "doubao-pro");
+ assert.equal(latest.input_tokens, 120);
+ assert.equal(latest.total_tokens, 130);
+});
+
+test("unexpected cursor version fails closed instead of resetting", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = { traeCn: { version: 99, sessions: {} } };
+ await assert.rejects(
+ parseTraeCnApiIncremental({ sessions: [sessionRow()], cursors, queuePath }),
+ /version 99 is not supported/,
+ );
+ assert.equal(cursors.traeCn.version, 99, "malformed state is not reset");
+});
+
+test("a stored contribution exceeding its bucket fails closed without mutation", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const bucketKey = `trae-cn|doubao-pro|${B1}`;
+ const cursors = {
+ hourly: {
+ version: 3,
+ buckets: {
+ [bucketKey]: {
+ totals: {
+ input_tokens: 5,
+ cached_input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ output_tokens: 5,
+ reasoning_output_tokens: 0,
+ total_tokens: 10,
+ billable_total_tokens: 10,
+ conversation_count: 1,
+ },
+ queuedKey: null,
+ },
+ },
+ },
+ traeCn: {
+ version: 1,
+ sessions: {
+ "session-id-canary": {
+ model: "doubao-pro",
+ bucketStart: B1,
+ totals: {
+ input_tokens: 100,
+ output_tokens: 10,
+ cached_input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ reasoning_output_tokens: 0,
+ total_tokens: 110,
+ billable_total_tokens: 110,
+ conversation_count: 1,
+ },
+ updatedAt: "x",
+ },
+ },
+ },
+ };
+ const before = JSON.stringify(cursors);
+ await assert.rejects(
+ parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: "session-id-canary", input_token: 120 })], cursors, queuePath }),
+ (error) => {
+ assert.match(error.message, /corruption/);
+ assert.ok(!error.message.includes("session-id-canary"));
+ return true;
+ },
+ );
+ assert.equal(JSON.stringify(cursors), before, "cursor state untouched");
+ assert.equal(fs.existsSync(queuePath), false);
+});
+
+test("failed enqueue leaves the serialized cursors byte-identical", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {};
+ const row = sessionRow();
+
+ await parseTraeCnApiIncremental({ sessions: [row], cursors, queuePath });
+ const before = JSON.stringify(cursors);
+
+ // Corrected snapshot whose queuePath is an existing directory: enqueue's
+ // appendFile fails (EISDIR) after reconciliation already mutated the working
+ // state. The deep-cloned working states must keep cursors untouched.
+ await assert.rejects(
+ parseTraeCnApiIncremental({ sessions: [sessionRow({ input_token: 120 })], cursors, queuePath: dir }),
+ );
+ assert.equal(JSON.stringify(cursors), before, "cursors byte-identical after failed enqueue");
+});
+
+test("an unrelated malformed stored session fails a new valid payload before mutation", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {
+ traeCn: {
+ version: 1,
+ sessions: {
+ good: {
+ model: "doubao-pro",
+ bucketStart: B1,
+ totals: {
+ input_tokens: 100,
+ cached_input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ output_tokens: 10,
+ reasoning_output_tokens: 0,
+ total_tokens: 110,
+ billable_total_tokens: 110,
+ conversation_count: 1,
+ },
+ updatedAt: "x",
+ },
+ bad: {
+ model: "doubao-lite",
+ bucketStart: B1,
+ totals: {
+ input_tokens: 1,
+ cached_input_tokens: 0,
+ cache_creation_input_tokens: 0,
+ output_tokens: 1,
+ reasoning_output_tokens: 1, // violates the canonical invariant
+ total_tokens: 2,
+ billable_total_tokens: 2,
+ conversation_count: 1,
+ },
+ updatedAt: "x",
+ },
+ },
+ },
+ };
+ const before = JSON.stringify(cursors);
+ await assert.rejects(
+ parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: "new-session" })], cursors, queuePath }),
+ /stored session totals are malformed/,
+ );
+ assert.equal(JSON.stringify(cursors), before, "no mutation on malformed stored state");
+ assert.equal(fs.existsSync(queuePath), false, "no queue rows written");
+});
From 79ab6b1bcfeb5fe19402b4221adedc18ae7f39cb Mon Sep 17 00:00:00 2001
From: KunWei <2088220700@qq.com>
Date: Sun, 16 Aug 2026 14:26:42 +0800
Subject: [PATCH 02/19] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20TRAE=20CN=20C?=
=?UTF-8?q?odeQL=20=E5=AE=89=E5=85=A8=E5=91=8A=E8=AD=A6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/lib/trae-cn-config.js | 5 +++--
test/trae-cn-config.test.js | 10 +++++++++-
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js
index b7124f465..2eafe0289 100644
--- a/src/lib/trae-cn-config.js
+++ b/src/lib/trae-cn-config.js
@@ -91,6 +91,7 @@ function deriveTraeCnKeyIv(salt) {
crypto.createHash("sha512").update(salt).digest(),
traeCnHardcodedPassword(),
]);
+ // codeql[js/insufficient-password-hash]: vendor tc-v5 compatibility KDF, not password storage.
const kdfOut = crypto.createHash("sha512").update(kdfBuf).digest();
return {
key: kdfOut.subarray(0, TRAE_CN_AES_KEY_LEN),
@@ -262,7 +263,6 @@ async function fetchTraeCnUsagePage({
end_time,
page_num,
page_size = TRAE_CN_USAGE_PAGE_SIZE,
- url = TRAE_CN_USAGE_URL,
fetchImpl = fetch,
timeoutMs = TRAE_CN_USAGE_TIMEOUT_MS,
} = {}) {
@@ -277,10 +277,11 @@ async function fetchTraeCnUsagePage({
const timer = setTimeout(() => controller.abort(), timeoutMs);
let response;
try {
- response = await fetchImpl(url, {
+ response = await fetchImpl(TRAE_CN_USAGE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
+ // codeql[js/file-access-to-http]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted.
Authorization: `Cloud-IDE-JWT ${jwt.trim()}`,
},
body: JSON.stringify({
diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js
index d447e34ec..b8d11dcba 100644
--- a/test/trae-cn-config.test.js
+++ b/test/trae-cn-config.test.js
@@ -67,6 +67,7 @@ function testPassword() {
function testDerive(salt) {
const kdfBuf = Buffer.concat([crypto.createHash("sha512").update(salt).digest(), testPassword()]);
+ // codeql[js/insufficient-password-hash]: vendor tc-v5 compatibility KDF, not password storage.
const out = crypto.createHash("sha512").update(kdfBuf).digest();
return { key: out.subarray(0, 16), iv: out.subarray(16, 32) };
}
@@ -258,7 +259,14 @@ test("fetchTraeCnUsagePage sends the exact request", async () => {
captured = { url, options };
return jsonResponse(200, { user_usage_group_by_sessions: [{ id: 1 }], total: 1 });
};
- await fetchTraeCnUsagePage({ jwt: JWT, start_time: START, end_time: END, page_num: 1, fetchImpl });
+ await fetchTraeCnUsagePage({
+ jwt: JWT,
+ start_time: START,
+ end_time: END,
+ page_num: 1,
+ url: "https://attacker.example/redirect",
+ fetchImpl,
+ });
assert.equal(captured.url, TRAE_CN_USAGE_URL);
assert.equal(captured.options.method, "POST");
assert.equal(captured.options.headers["Content-Type"], "application/json");
From 9e59c13967eb90ba543e758724055ba4c678c82a Mon Sep 17 00:00:00 2001
From: KunWei <2088220700@qq.com>
Date: Sun, 16 Aug 2026 14:43:19 +0800
Subject: [PATCH 03/19] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20TRAE=20CN=20?=
=?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=8D=E9=A6=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.de.md | 2 +-
README.ja.md | 6 +-
README.ko.md | 6 +-
README.md | 4 +-
README.zh-CN.md | 2 +-
dashboard/src/content/copy.csv | 1 +
dashboard/src/content/i18n/zh-TW/core.json | 1 +
dashboard/src/content/i18n/zh/core.json | 1 +
dashboard/src/lib/provider-display.js | 3 +-
dashboard/src/lib/provider-display.test.js | 4 +-
dashboard/src/ui/marketing/agent-logos.js | 4 +-
docs/PRIVACY.md | 4 +-
src/commands/sync.js | 9 +-
src/lib/trae-cn-config.js | 109 +++++++++++----------
test/discovery-metadata.test.js | 43 +++++++-
test/sync-trae-cn.test.js | 56 +++++++++++
test/trae-cn-config.test.js | 65 ++++++++++++
17 files changed, 247 insertions(+), 73 deletions(-)
diff --git a/README.de.md b/README.de.md
index 0d7a76c32..429353207 100644
--- a/README.de.md
+++ b/README.de.md
@@ -194,7 +194,7 @@ Aktualisieren mit `brew upgrade --cask xiufengsun/tokentracker/tokentracker`. De
| **Qoder** | ✅ Auto | Passiver SQLite-Reader (`Qoder/SharedClientCache/cache/db/local.db`; liest nur assistant-`token_info`, trennt Cache-Eingaben und liest keine Prompts oder Antworten) sowie Plan Credits und Ultimate-Gratisaufrufe aus der lokalen Qoder-Sitzung |
| **AnythingLLM Desktop** | ✅ Auto | Passiver SQLite-Reader (`anythingllm-desktop/storage/anythingllm.db`, nur Token-Metriken pro Nachricht) |
| **Claude Science** | ✅ Auto | Passiver SQLite-Reader (`~/.claude-science/operon-cli.db`, nur die Token-Zähler der `frames`-Tabelle; keine Prompts, Artefakte oder Forschungsinhalte). Kein natives Windows-Build — unter Windows läuft die App in WSL und wird von dort gelesen. |
-| **TRAE Work CN** | ✅ Auto | Liest unter macOS die Session-Token-Nutzung der lokal angemeldeten App; die interne API kann sich ändern |
+| **TRAE Work CN** | ✅ Auto | Liest bei vorhandener lokaler TRAE Work CN-Anmeldung während eines zulässigen Syncs außerhalb des Hintergrundbetriebs unter macOS die Session-Token-Nutzung der angemeldeten App; die interne API kann sich ändern |
> **Muss ich Plugins oder Hooks manuell installieren?** Nein. `tokentracker` (oder `tokentracker init`) erledigt alles beim ersten Start:
> - **Hook-basiert** (Claude Code, Codex, Gemini, Every Code, CodeBuddy, WorkBuddy, Grok Build) — wir schreiben einen SessionEnd-Hook oder TOML-Notify-Eintrag in die Konfiguration des Tools.
diff --git a/README.ja.md b/README.ja.md
index 8bd89b2b8..2fd494bf7 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -186,6 +186,7 @@ brew install xiufengsun/tokentracker/tokentracker
| **Antigravity** | ✅ 自動 | パッシブなトランスクリプトリーダー (`~/.gemini/{antigravity,antigravity-ide,antigravity-cli}/brain/**/transcript.jsonl`) |
| **pi** (`@mariozechner/pi-coding-agent`) | ✅ 自動 | パッシブリーダー (`~/.pi/agent/sessions/**/*.jsonl`) |
| **Craft Agents** | ✅ 自動 | パッシブなセッションリーダー (`~/.craft-agent` + workspace session logs) |
+| **Reasonix** | ✅ 自動 | パッシブなテレメトリリーダー (`~/.reasonix/**/*.jsonl.telemetry.json`) |
| **Roo Code** (VS Code 拡張) | ✅ 自動 | パッシブな `ui_messages.json` リーダー (`rooveterinaryinc.roo-cline`) |
| **Zed Agent** | ✅ 自動 | パッシブな SQLite リーダー (`threads.db`、hosted `zed.dev` models only) |
| **Goose** (Block) | ✅ 自動 | パッシブな SQLite リーダー (`sessions.db`、cumulative deltas) |
@@ -195,12 +196,13 @@ brew install xiufengsun/tokentracker/tokentracker
| **Qoder** | ✅ 自動 | パッシブ SQLite リーダー (`Qoder/SharedClientCache/cache/db/local.db`; assistant の `token_info` のみを読み、キャッシュ入力を分離。prompt/response は読みません) と、Qoder ローカルセッションからの Plan Credits / Ultimate 無料呼び出し上限 |
| **AnythingLLM Desktop** | ✅ 自動 | パッシブな SQLite リーダー (`anythingllm-desktop/storage/anythingllm.db`、メッセージごとの token 指標のみ) |
| **Claude Science** | ✅ 自動 | パッシブな SQLite リーダー (`~/.claude-science/operon-cli.db`、`frames` テーブルの token カウンタのみ。prompt・成果物・研究内容は読みません)。ネイティブ Windows 版はなく、Windows では WSL 内で動作するアプリを読み取ります。 |
-| **TRAE Work CN** | ✅ 自動 | macOS のローカルサインイン済みアプリから session-token 使用量を読み取ります。内部 API は変更される可能性があります |
+| **DeepSeek Harness** | ✅ 自動 | パッシブなセッションリーダー (`~/.dsh/sessions/**/session.jsonl[.zstd]`、セッションヘッダーと assistant イベントを解析し、複数フレームの zstd 展開に対応) |
+| **TRAE Work CN** | ✅ 自動 | ローカル TRAE Work CN のサインイン認証がある場合、実行可能な非バックグラウンド同期中に macOS のサインイン済みアプリから session-token 使用量を読み取ります。内部 API は変更される可能性があります |
> **プラグインや hook を手動でインストールする必要はありますか?** いいえ。`tokentracker`(または `tokentracker init`)が初回実行ですべて処理します:
> - **Hook ベース**のツール (Claude Code、Codex、Gemini、Every Code、**CodeBuddy**、**WorkBuddy**、**Grok Build**) — ツール自身の設定に SessionEnd hook または TOML notify エントリーを書き込みます。
> - **プラグインベース**のツール (OpenCode、**OpenClaw**) — プラグインは npm パッケージ内に同梱されています。OpenClaw のセッションプラグインは `~/.tokentracker/tracker/openclaw-plugin/openclaw-session-sync/` にあり、OpenClaw 自身の CLI でリンクして有効化したうえで、同期を起動するセッション終了イベントを許可するために `hooks.allowConversationAccess=true` を設定します。ダウンロードもドラッグ&ドロップも不要です。
-> - **パッシブリーダー** (Cursor、Kiro、Hermes、Kimi Code、Copilot、**Grok Build**、**oh-my-pi**、**pi**、**Craft Agents**、**Kilo CLI**、**Kilo Code**、**Roo Code**、**Antigravity**、**Zed Agent**、**Goose**、**Droid**、**Mimo Code**、**ZCode**、**AnythingLLM Desktop**、**Claude Science**) — これらのツールには何もインストールしません。ツールがすでに出力しているファイル (SQLite DB、JSONL、OTEL エクスポート、session logs) を読むだけです。Copilot App / CLI の使用量は `~/.copilot/session-store.db` からリクエスト単位で読み取ります。`data.db` は旧データ移行時のベースラインとして一度だけ使い、store が正規ソースになった後は監視専用です。Chat 拡張と旧 CLI は引き続き OTEL を使用し、重複するリクエストは TokenTracker が一度だけ集計します。移行前の混在 App/CLI 履歴でモデルを安全に分離できない残量は、推測したリクエストモデルではなく `github-copilot-legacy` の集計値として保持します。
+> - **パッシブリーダー** (Cursor、Kiro、Hermes、Kimi Code、Copilot、**Grok Build**、**oh-my-pi**、**pi**、**Craft Agents**、**Reasonix**、**Kilo CLI**、**Kilo Code**、**Roo Code**、**Antigravity**、**Zed Agent**、**Goose**、**Droid**、**Mimo Code**、**ZCode**、**AnythingLLM Desktop**、**Claude Science**、**DeepSeek Harness**) — これらのツールには何もインストールしません。ツールがすでに出力しているファイル (SQLite DB、JSONL、OTEL エクスポート、session logs) を読むだけです。Copilot App / CLI の使用量は `~/.copilot/session-store.db` からリクエスト単位で読み取ります。`data.db` は旧データ移行時のベースラインとして一度だけ使い、store が正規ソースになった後は監視専用です。Chat 拡張と旧 CLI は引き続き OTEL を使用し、重複するリクエストは TokenTracker が一度だけ集計します。移行前の混在 App/CLI 履歴でモデルを安全に分離できない残量は、推測したリクエストモデルではなく `github-copilot-legacy` の集計値として保持します。
> - **Grok Build の推定** — 現在のローカルテレメトリは `updates.jsonl` の累積 `totalTokens` を公開していますが、安定したプロンプト/出力/キャッシュの内訳はありません。`signals.json` は `contextTokensUsed` のスナップショットを使ったフォールバックとして残っています。コールごとの利用詳細が利用可能になるまで、TokenTracker は Grok のコストを推定します。
>
> いつでも `tokentracker status` を実行すれば、各統合の状態を確認できます。`skipped` と表示されている場合、`detail` 列にその理由が示されます(例: ツール CLI が `PATH` にない、設定が読めない)。
diff --git a/README.ko.md b/README.ko.md
index d7406d361..77e5de392 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -186,6 +186,7 @@ brew install xiufengsun/tokentracker/tokentracker
| **Antigravity** | ✅ 자동 | 패시브 트랜스크립트 리더 (`~/.gemini/{antigravity,antigravity-ide,antigravity-cli}/brain/**/transcript.jsonl`) |
| **pi** (`@mariozechner/pi-coding-agent`) | ✅ 자동 | 패시브 리더 (`~/.pi/agent/sessions/**/*.jsonl`) |
| **Craft Agents** | ✅ 자동 | 패시브 세션 리더 (`~/.craft-agent` + workspace session logs) |
+| **Reasonix** | ✅ 자동 | 패시브 텔레메트리 리더 (`~/.reasonix/**/*.jsonl.telemetry.json`) |
| **Roo Code** (VS Code 확장) | ✅ 자동 | 패시브 `ui_messages.json` 리더 (`rooveterinaryinc.roo-cline`) |
| **Zed Agent** | ✅ 자동 | 패시브 SQLite 리더 (`threads.db`, hosted `zed.dev` models only) |
| **Goose** (Block) | ✅ 자동 | 패시브 SQLite 리더 (`sessions.db`, cumulative deltas) |
@@ -195,12 +196,13 @@ brew install xiufengsun/tokentracker/tokentracker
| **Qoder** | ✅ 자동 | 패시브 SQLite 리더 (`Qoder/SharedClientCache/cache/db/local.db`; assistant `token_info`만 읽고 캐시 입력을 분리하며 prompt/response는 읽지 않음) 및 Qoder 로컬 세션의 Plan Credits / Ultimate 무료 호출 한도 |
| **AnythingLLM Desktop** | ✅ 자동 | 패시브 SQLite 리더 (`anythingllm-desktop/storage/anythingllm.db`, 메시지별 token 지표만 읽음) |
| **Claude Science** | ✅ 자동 | 패시브 SQLite 리더 (`~/.claude-science/operon-cli.db`, `frames` 테이블의 token 카운터만 읽으며 prompt·산출물·연구 내용은 읽지 않음). 네이티브 Windows 빌드가 없어 Windows에서는 WSL 안에서 실행되는 앱을 읽습니다. |
-| **TRAE Work CN** | ✅ 자동 | macOS에서 로컬 로그인된 앱의 session-token 사용량을 읽습니다. 내부 API는 변경될 수 있습니다 |
+| **DeepSeek Harness** | ✅ 자동 | 패시브 세션 리더 (`~/.dsh/sessions/**/session.jsonl[.zstd]`, 세션 헤더와 assistant 이벤트를 파싱하고 멀티 프레임 zstd 압축 해제를 지원) |
+| **TRAE Work CN** | ✅ 자동 | 로컬 TRAE Work CN 인증이 있을 때 실행 가능한 비백그라운드 동기화 중에 macOS의 로그인된 앱에서 session-token 사용량을 읽습니다. 내부 API는 변경될 수 있습니다 |
> **플러그인이나 hook을 수동으로 설치해야 하나요?** 아니요. `tokentracker` (또는 `tokentracker init`)가 첫 실행에서 모든 것을 처리합니다:
> - **Hook 기반** 도구 (Claude Code, Codex, Gemini, Every Code, **CodeBuddy**, **WorkBuddy**, **Grok Build**) — 도구 자체의 설정에 SessionEnd hook 또는 TOML notify 엔트리를 작성합니다.
> - **플러그인 기반** 도구 (OpenCode, **OpenClaw**) — 플러그인은 npm 패키지 안에 포함되어 있습니다. OpenClaw 세션 플러그인은 `~/.tokentracker/tracker/openclaw-plugin/openclaw-session-sync/`에 있으며, OpenClaw 자체 CLI로 링크하고 활성화한 뒤 동기화를 트리거하는 세션 종료 이벤트를 허용하도록 `hooks.allowConversationAccess=true`를 설정합니다. 다운로드, 드래그 앤 드롭 불필요.
-> - **패시브 리더** (Cursor, Kiro, Hermes, Kimi Code, Copilot, **Grok Build**, **oh-my-pi**, **pi**, **Craft Agents**, **Kilo CLI**, **Kilo Code**, **Roo Code**, **Antigravity**, **Zed Agent**, **Goose**, **Droid**, **Mimo Code**, **ZCode**, **AnythingLLM Desktop**, **Claude Science**) — 이들 도구에는 아무것도 설치하지 않습니다. 도구가 이미 생성하는 파일 (SQLite DB, JSONL, OTEL export, session logs)만 읽습니다. Copilot App / CLI 사용량은 `~/.copilot/session-store.db`에서 요청별로 읽습니다. `data.db`는 레거시 마이그레이션 기준선으로 한 번만 사용하며 store가 정식 소스가 된 뒤에는 관찰 전용으로 유지됩니다. Chat 확장과 이전 CLI는 계속 OTEL을 사용하며, TokenTracker가 겹치는 요청을 한 번만 집계합니다. 마이그레이션 전 혼합 App/CLI 기록에서 모델을 안전하게 분리할 수 없는 잔여분은 추정 모델 대신 `github-copilot-legacy` 집계로 보존합니다.
+> - **패시브 리더** (Cursor, Kiro, Hermes, Kimi Code, Copilot, **Grok Build**, **oh-my-pi**, **pi**, **Craft Agents**, **Reasonix**, **Kilo CLI**, **Kilo Code**, **Roo Code**, **Antigravity**, **Zed Agent**, **Goose**, **Droid**, **Mimo Code**, **ZCode**, **AnythingLLM Desktop**, **Claude Science**, **DeepSeek Harness**) — 이들 도구에는 아무것도 설치하지 않습니다. 도구가 이미 생성하는 파일 (SQLite DB, JSONL, OTEL export, session logs)만 읽습니다. Copilot App / CLI 사용량은 `~/.copilot/session-store.db`에서 요청별로 읽습니다. `data.db`는 레거시 마이그레이션 기준선으로 한 번만 사용하며 store가 정식 소스가 된 뒤에는 관찰 전용으로 유지됩니다. Chat 확장과 이전 CLI는 계속 OTEL을 사용하며, TokenTracker가 겹치는 요청을 한 번만 집계합니다. 마이그레이션 전 혼합 App/CLI 기록에서 모델을 안전하게 분리할 수 없는 잔여분은 추정 모델 대신 `github-copilot-legacy` 집계로 보존합니다.
> - **Grok Build 추정** — 현재 로컬 텔레메트리는 `updates.jsonl`의 누적 `totalTokens`를 노출하지만, 안정적인 프롬프트/출력/캐시 분할은 제공하지 않습니다; `signals.json`은 `contextTokensUsed` 스냅샷을 사용한 폴백으로 남아 있습니다. 호출별 사용 상세 정보가 제공될 때까지 TokenTracker는 Grok 비용을 추정합니다.
>
> 언제든 `tokentracker status`로 각 통합의 상태를 확인할 수 있습니다. `skipped`로 표시되면 `detail` 컬럼이 이유를 설명합니다 (예: 도구 CLI가 `PATH`에 없음, 설정 읽기 불가).
diff --git a/README.md b/README.md
index 21a6dce28..2ac4476c3 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
An accurate, local-first token usage and cost dashboard for **32 AI coding tools** — plus a desktop pet, **4 native widgets**, and **15 achievement tracks**. No TokenTracker account, no API keys, no setup.
-> **TRAE Work CN note:** On macOS, TokenTracker reads real session-token usage from the locally signed-in TRAE Work CN app. Its internal API may change.
+> **TRAE Work CN note:** On macOS, during eligible non-background sync when local TRAE Work CN auth exists, TokenTracker reads usage metadata from the locally signed-in app; no prompts are sent, and its internal API may change.
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -260,7 +260,7 @@ An Arch `PKGBUILD` for a local pacman install lives in `TokenTrackerLinux/packag
| **AnythingLLM Desktop** | ✅ Auto | Passive SQLite reader (`anythingllm-desktop/storage/anythingllm.db`; reads per-message token metrics only, never prompts or responses) |
| **Claude Science** | ✅ Auto | Passive SQLite reader (`~/.claude-science/operon-cli.db`; reads the `frames` table's token counters only, never prompts, artifacts or research content). No native Windows build — on Windows the app runs inside WSL and is read from there. |
| **DeepSeek Harness** | ✅ Auto | Passive session reader (`~/.dsh/sessions/**/session.jsonl[.zstd]`; parses session header and assistant events, handles multi-frame zstd decompression) |
-| **TRAE Work CN** | ✅ Auto | macOS session-token usage from the locally signed-in app; reads its internal API, which may change |
+| **TRAE Work CN** | ✅ Auto | During eligible non-background sync when local TRAE Work CN auth exists, reads session-token usage from the locally signed-in macOS app; its internal API may change |
> **Do I need to install any plugin or hook manually?** No. `tokentracker` (or `tokentracker init`) handles everything on first run:
> - **Hook-based** tools (Claude Code, Codex, Gemini, Every Code, **CodeBuddy**, **WorkBuddy**, **Grok Build**) — we write a SessionEnd hook or TOML notify entry into the tool's own config.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index b849b5ce6..480537f81 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -199,7 +199,7 @@ brew install xiufengsun/tokentracker/tokentracker
| **AnythingLLM Desktop** | ✅ 自动 | 被动 SQLite 读取(`anythingllm-desktop/storage/anythingllm.db`;只读取每条消息的 token 指标,不读取 prompt 或回复) |
| **Claude Science** | ✅ 自动 | 被动 SQLite 读取(`~/.claude-science/operon-cli.db`;只读取 `frames` 表的 token 计数,不读取 prompt、产物或研究内容)。没有原生 Windows 版——Windows 上该应用运行在 WSL 内,从 WSL 中读取。 |
| **DeepSeek Harness** | ✅ 自动 | 被动读取会话日志(`~/.dsh/sessions/**/session.jsonl[.zstd]`;解析会话头部与 assistant 事件,支持多帧 zstd 解压) |
-| **TRAE Work CN** | ✅ 自动 | macOS 上读取本地登录的 TRAE Work CN 应用的 session-token 用量;内部 API 可能变化 |
+| **TRAE Work CN** | ✅ 自动 | 仅在存在本地 TRAE Work CN 登录授权的可执行非后台同步期间,读取 macOS 本地登录应用的 session-token 用量;内部 API 可能变化 |
> **需要手动装什么插件 / hook 吗?** 不需要。`tokentracker`(或 `tokentracker init`)第一次跑的时候会全部搞定:
> - **基于 hook 的工具**(Claude Code、Codex、Gemini、Every Code、**CodeBuddy**、**WorkBuddy**、**Grok Build**)—— 我们把 SessionEnd hook 或 TOML notify 条目写入它们自己的配置文件
diff --git a/dashboard/src/content/copy.csv b/dashboard/src/content/copy.csv
index 8a18a2fcd..166c5b49a 100644
--- a/dashboard/src/content/copy.csv
+++ b/dashboard/src/content/copy.csv
@@ -736,6 +736,7 @@ limits.provider.antigravity,ui,LimitsPage,UsageLimitsPanel,provider_antigravity,
limits.provider.zcode,ui,LimitsPage,UsageLimitsPanel,provider_zcode,"ZCode",,active
provider.display.omp,ui,*,ProviderDisplay,omp,"oh-my-pi",,active
provider.display.deepseek_harness,ui,*,ProviderDisplay,deepseek_harness,"DeepSeek Harness",,active
+provider.display.trae_work_cn,ui,*,ProviderDisplay,trae_work_cn,"TRAE Work CN",,active
limits.label.zcode_glm52,ui,LimitsPage,UsageLimitsPanel,zcode_glm52,"GLM-5.2",,active
limits.label.zcode_glm5t,ui,LimitsPage,UsageLimitsPanel,zcode_glm5t,"GLM-5-Turbo",,active
limits.label.zcode_5h,ui,LimitsPage,UsageLimitsPanel,zcode_5h,"5h",,active
diff --git a/dashboard/src/content/i18n/zh-TW/core.json b/dashboard/src/content/i18n/zh-TW/core.json
index bb94cb579..5b9aa5e4c 100644
--- a/dashboard/src/content/i18n/zh-TW/core.json
+++ b/dashboard/src/content/i18n/zh-TW/core.json
@@ -154,6 +154,7 @@
"limits.provider.copilot": "GitHub Copilot",
"provider.display.omp": "oh-my-pi",
"provider.display.deepseek_harness": "DeepSeek Harness",
+ "provider.display.trae_work_cn": "TRAE Work CN",
"limits.provider.antigravity": "Antigravity",
"limits.provider.zcode": "ZCode",
"limits.provider.opencode_go": "OpenCode Go",
diff --git a/dashboard/src/content/i18n/zh/core.json b/dashboard/src/content/i18n/zh/core.json
index d00d8f42e..400a69e59 100644
--- a/dashboard/src/content/i18n/zh/core.json
+++ b/dashboard/src/content/i18n/zh/core.json
@@ -154,6 +154,7 @@
"limits.provider.copilot": "GitHub Copilot",
"provider.display.omp": "oh-my-pi",
"provider.display.deepseek_harness": "DeepSeek Harness",
+ "provider.display.trae_work_cn": "TRAE Work CN",
"limits.provider.antigravity": "Antigravity",
"limits.provider.zcode": "ZCode",
"limits.provider.opencode_go": "OpenCode Go",
diff --git a/dashboard/src/lib/provider-display.js b/dashboard/src/lib/provider-display.js
index c1b985f29..ba59242fd 100644
--- a/dashboard/src/lib/provider-display.js
+++ b/dashboard/src/lib/provider-display.js
@@ -6,13 +6,14 @@ const SPECIAL_PROVIDER_NAMES = {
pianthropic: "Pi · Anthropic",
pigithubcopilot: "Pi · GitHub Copilot",
picopilot: "Pi · Copilot",
- traecn: "TRAE Work CN",
};
const SPECIAL_PROVIDER_COPY_KEYS = {
deepseek: "provider.display.deepseek_harness",
dsh: "provider.display.deepseek_harness",
omp: "provider.display.omp",
+ traecn: "provider.display.trae_work_cn",
+ traeworkcn: "provider.display.trae_work_cn",
};
function normalizedProviderKey(value) {
diff --git a/dashboard/src/lib/provider-display.test.js b/dashboard/src/lib/provider-display.test.js
index 611a82e84..bedcf4ead 100644
--- a/dashboard/src/lib/provider-display.test.js
+++ b/dashboard/src/lib/provider-display.test.js
@@ -30,8 +30,8 @@ describe("formatProviderDisplayName", () => {
expect(formatProviderDisplayName("deepseek")).toBe("DeepSeek Harness");
});
- it.each(["trae-cn", "TRAE-CN", "Trae_Cn"])(
- "formats %s as TRAE Work CN",
+ it.each(["trae-cn", "TRAE-CN", "Trae_Cn", "TRAE Work CN"])(
+ "uses the catalog-backed TRAE Work CN name for %s",
(value) => {
expect(formatProviderDisplayName(value)).toBe("TRAE Work CN");
},
diff --git a/dashboard/src/ui/marketing/agent-logos.js b/dashboard/src/ui/marketing/agent-logos.js
index 5481d3e7b..02fbb414d 100644
--- a/dashboard/src/ui/marketing/agent-logos.js
+++ b/dashboard/src/ui/marketing/agent-logos.js
@@ -1,6 +1,8 @@
// Canonical list of supported coding agents, in display order. Shared by the
// marketing landing carousel and the dashboard auth/expired gate so the two
// surfaces can't drift. Names are tooltip/a11y metadata only.
+import { copy } from "../../lib/copy";
+
export const AGENT_LOGOS = [
{ id: 1, name: "Claude Code", provider: "claude" },
{ id: 2, name: "Codex", provider: "codex" },
@@ -33,5 +35,5 @@ export const AGENT_LOGOS = [
{ id: 29, name: "AnythingLLM", provider: "anythingllm" },
{ id: 30, name: "Claude Science", provider: "claude-science" },
{ id: 31, name: "DeepSeek Harness", provider: "dsh" },
- { id: 32, name: "TRAE Work CN", provider: "trae-cn" },
+ { id: 32, name: copy("provider.display.trae_work_cn"), provider: "trae-cn" },
];
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
index 2f5bf6ac5..ecc068c10 100644
--- a/docs/PRIVACY.md
+++ b/docs/PRIVACY.md
@@ -10,7 +10,7 @@ This document lists **every** network request the software can make, what each o
## 1. What never leaves your machine
-TokenTracker's parsers extract numbers and timestamps only. TRAE Work CN is a narrow exception to the local-only source model: during a usage sync, TokenTracker transmits the existing sign-in authorization from the locally signed-in app to TRAE's internal API for a read-only usage request. That authorization is never persisted or logged.
+TokenTracker's parsers extract numbers and timestamps only. TRAE Work CN is a narrow exception to the local-only source model: during an eligible non-background sync, when local TRAE Work CN auth exists, TokenTracker transmits the existing sign-in authorization from the locally signed-in app to TRAE's internal API for a read-only usage request. It is not an unconditional or default generic network request. That authorization is never persisted or logged.
**Apart from the disclosed TRAE Work CN authorization, never read or recorded anywhere:**
@@ -54,7 +54,7 @@ To erase everything TokenTracker knows about you, delete that directory. `tokent
| **Anonymous heartbeat** | `srctyff5.us-east.insforge.app` | A one-way hash of the machine id, app version, OS platform, and shell (`cli` / `mac` / `win`). Nothing else. | At most once per day |
| **Dashboard analytics** | `us.i.posthog.com` (PostHog) | Pageviews and explicitly instrumented feature events, plus which shell you use. Autocapture and session recording are **off**; browser Do-Not-Track is respected. | While the dashboard is open |
| **Provider quota reads** | The provider's own API (`api.anthropic.com`, `chatgpt.com`, `cursor.com`, `api.github.com`, `api.kimi.com`, `api.z.ai`, `qoder.com`, `qoder.com.cn`, `openapi.qoder.sh`, `openapi.qoder.com.cn`, `cloudcode-pa.googleapis.com`, …) | Whatever that provider's own endpoint requires, authenticated with the credentials **that provider already stored on your machine**. These requests go directly from your machine to the provider — they never pass through our servers, and we never see the response. | While quota bars are visible |
-| **TRAE Work CN usage read** | TRAE's internal API | Transmits the existing sign-in authorization from the locally signed-in TRAE Work CN app to TRAE; reads usage metadata only. TokenTracker never persists or logs the auth token or prompt/response content. | During TRAE Work CN usage sync |
+| **TRAE Work CN usage read** | TRAE's internal API | Transmits the existing sign-in authorization from the locally signed-in TRAE Work CN app to TRAE; reads usage metadata only. TokenTracker never persists or logs the auth token or prompt/response content. | During eligible non-background sync when local TRAE Work CN auth exists |
| **GitHub star count** | `api.github.com` | Nothing but the request itself (public repo metadata) | On dashboard load |
| **Update check** | `api.github.com` | Nothing but the request itself | Windows: once at launch. macOS: only when you click "Check for Updates" |
diff --git a/src/commands/sync.js b/src/commands/sync.js
index 3d8f5b07c..5dc7d31f3 100644
--- a/src/commands/sync.js
+++ b/src/commands/sync.js
@@ -1684,13 +1684,16 @@ async function cmdSync(argv, context = {}) {
// excludes both ordinary background and `--auto --background
// --all-local-sources`. Fetching goes through the storage-backed Phase A
// helper so its single 401/403 reread/retry is used; an empty response is a
- // successful no-op; any fetch/page/schema/auth failure skips the parser and
- // leaves prior data untouched while unrelated providers continue.
+ // successful no-op; complete snapshots are bounded to 100 pages/2,000 rows;
+ // over-capacity or other fetch/page/schema/auth failures skip the parser and
+ // leave prior data untouched while unrelated providers continue.
let traeCnResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
+ const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
if (
!isBackgroundLightweightSync &&
sourceAllowed("trae-cn") &&
- resolveTraeCnStoragePath({ env: process.env, home })
+ traeCnStoragePath &&
+ fssync.existsSync(traeCnStoragePath)
) {
const nowMs = Number.isFinite(traeCnNowMs) ? traeCnNowMs : Date.now();
const fetchImpl = typeof traeCnFetchImpl === "function" ? traeCnFetchImpl : fetch;
diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js
index 2eafe0289..c9ca3f954 100644
--- a/src/lib/trae-cn-config.js
+++ b/src/lib/trae-cn-config.js
@@ -275,61 +275,63 @@ async function fetchTraeCnUsagePage({
assertUsageParams({ start_time, end_time, page_size });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
- let response;
try {
- response = await fetchImpl(TRAE_CN_USAGE_URL, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- // codeql[js/file-access-to-http]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted.
- Authorization: `Cloud-IDE-JWT ${jwt.trim()}`,
- },
- body: JSON.stringify({
- usage_type: TRAE_CN_USAGE_TYPE,
- start_time,
- end_time,
- page_num,
- page_size,
- }),
- signal: controller.signal,
- });
- } catch (_error) {
- throw new Error("Trae CN usage API request failed.");
+ let response;
+ try {
+ response = await fetchImpl(TRAE_CN_USAGE_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ // codeql[js/file-data-to-network]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted.
+ Authorization: `Cloud-IDE-JWT ${jwt.trim()}`,
+ },
+ body: JSON.stringify({
+ usage_type: TRAE_CN_USAGE_TYPE,
+ start_time,
+ end_time,
+ page_num,
+ page_size,
+ }),
+ signal: controller.signal,
+ });
+ } catch (_error) {
+ throw new Error("Trae CN usage API request failed.");
+ }
+ if (response.status === 401 || response.status === 403) {
+ throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status });
+ }
+ if (!response.ok) {
+ throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status });
+ }
+ let parsed;
+ try {
+ parsed = await response.json();
+ } catch (_error) {
+ throw new Error("Trae CN usage API returned a non-JSON response.");
+ }
+ const apiCode = parsed?.data?.code ?? parsed?.code;
+ if (apiCode !== undefined && Number(apiCode) !== 0) {
+ throw usageError(`Trae CN usage API returned error code ${apiCode}.`, {
+ status: response.status,
+ apiCode: Number(apiCode),
+ });
+ }
+ // Documented wrapper: data.user_usage_group_by_sessions, or the top-level
+ // equivalent. Only this module parses the wrapper; token rows/buckets are
+ // normalized elsewhere. A body without the session-array wrapper fails
+ // closed as a schema error instead of being misread as an empty result;
+ // an explicit empty array remains valid.
+ const data = parsed?.data && typeof parsed.data === "object" ? parsed.data : parsed;
+ if (!Array.isArray(data?.user_usage_group_by_sessions)) {
+ throw new Error("Trae CN usage API response is missing the session list.");
+ }
+ const sessions = data.user_usage_group_by_sessions;
+ const rawTotal = data?.total;
+ const total = rawTotal === undefined || rawTotal === null ? null : Number(rawTotal);
+ return { sessions, total, page_num, page_size };
} finally {
clearTimeout(timer);
}
- if (response.status === 401 || response.status === 403) {
- throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status });
- }
- if (!response.ok) {
- throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status });
- }
- let parsed;
- try {
- parsed = await response.json();
- } catch (_error) {
- throw new Error("Trae CN usage API returned a non-JSON response.");
- }
- const apiCode = parsed?.data?.code ?? parsed?.code;
- if (apiCode !== undefined && Number(apiCode) !== 0) {
- throw usageError(`Trae CN usage API returned error code ${apiCode}.`, {
- status: response.status,
- apiCode: Number(apiCode),
- });
- }
- // Documented wrapper: data.user_usage_group_by_sessions, or the top-level
- // equivalent. Only this module parses the wrapper; token rows/buckets are
- // normalized elsewhere. A body without the session-array wrapper fails
- // closed as a schema error instead of being misread as an empty result;
- // an explicit empty array remains valid.
- const data = parsed?.data && typeof parsed.data === "object" ? parsed.data : parsed;
- if (!Array.isArray(data?.user_usage_group_by_sessions)) {
- throw new Error("Trae CN usage API response is missing the session list.");
- }
- const sessions = data.user_usage_group_by_sessions;
- const rawTotal = data?.total;
- const total = rawTotal === undefined || rawTotal === null ? null : Number(rawTotal);
- return { sessions, total, page_num, page_size };
}
/**
@@ -362,6 +364,11 @@ async function fetchTraeCnUsage({
const result = await pageFetcher({ jwt, start_time, end_time, page_num: page, page_size, fetchImpl });
pagesFetched += 1;
total = result.total;
+ if (pagesFetched === 1 && Number.isFinite(total) && total > page_size * maxPages) {
+ const error = new Error("Trae CN usage snapshot exceeds the supported capacity.");
+ error.code = "TRAE_CN_USAGE_CAPACITY_EXCEEDED";
+ throw error;
+ }
if (Array.isArray(result.sessions)) sessions.push(...result.sessions);
if (result.sessions.length === 0) break;
if (total !== null && sessions.length >= total) break;
diff --git a/test/discovery-metadata.test.js b/test/discovery-metadata.test.js
index 2327a380c..11f052e31 100644
--- a/test/discovery-metadata.test.js
+++ b/test/discovery-metadata.test.js
@@ -8,6 +8,41 @@ const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const read = (relativePath) => fs.readFileSync(path.join(ROOT, relativePath), "utf8");
+const CANONICAL_PROVIDERS = [
+ "Claude Code",
+ "Codex CLI",
+ "Cursor",
+ "Gemini CLI",
+ "Antigravity",
+ "Kiro",
+ "OpenCode",
+ "OpenClaw",
+ "Every Code",
+ "Hermes Agent",
+ "GitHub Copilot",
+ "Kimi Code",
+ "CodeBuddy",
+ "WorkBuddy",
+ "Grok Build",
+ "oh-my-pi",
+ "pi",
+ "Craft Agents",
+ "Reasonix",
+ "Kilo CLI",
+ "Kilo Code",
+ "Roo Code",
+ "Zed Agent",
+ "Goose",
+ "Droid",
+ "Mimo Code",
+ "ZCode",
+ "Qoder",
+ "AnythingLLM Desktop",
+ "Claude Science",
+ "DeepSeek Harness",
+ "TRAE Work CN",
+];
+
const README_EXPECTATIONS = [
["README.md", /32 AI coding tools/, /\|\s+\*\*AI tools supported\*\*\s+\|\s+\*\*32\*\*/, /Rate-limit tracking.*✅ 13 providers/],
["README.zh-CN.md", /32 款 AI 编码工具/, /\|\s+\*\*支持的 AI 工具数\*\*\s+\|\s+\*\*32\*\*/, /限额追踪.*✅ 13 家 provider/],
@@ -21,11 +56,9 @@ test("public discovery surfaces describe all 32 supported tools", () => {
const source = read(file);
assert.match(source, countPattern, `${file} has the current provider count`);
assert.match(source, comparisonPattern, `${file} comparison table has the current provider count`);
- assert.match(source, /Droid/, `${file} lists Droid`);
- assert.match(source, /AnythingLLM Desktop/, `${file} lists AnythingLLM Desktop`);
- assert.match(source, /Qoder/, `${file} lists Qoder`);
- assert.match(source, /DeepSeek Harness/, `${file} lists DeepSeek Harness`);
- assert.match(source, /TRAE Work CN/, `${file} lists TRAE Work CN`);
+ for (const provider of CANONICAL_PROVIDERS) {
+ assert.match(source, new RegExp(provider), `${file} lists ${provider}`);
+ }
assert.match(source, limitCountPattern, `${file} rate-limit row carries the current usage-limits provider count`);
}
diff --git a/test/sync-trae-cn.test.js b/test/sync-trae-cn.test.js
index b7647b5af..91040c6fd 100644
--- a/test/sync-trae-cn.test.js
+++ b/test/sync-trae-cn.test.js
@@ -230,6 +230,62 @@ test("background and all-local syncs never call the TRAE CN fetch", async () =>
});
});
+test("an absent TRAE CN storage file does not call the fetch", async () => {
+ await withTempTraeEnv(async ({ home }) => {
+ let fetchCalls = 0;
+ const fetchImpl = async () => {
+ fetchCalls += 1;
+ throw new Error("should not fetch");
+ };
+
+ await cmdSync(["--auto", "--source=trae-cn"], {
+ traeCnFetchImpl: fetchImpl,
+ traeCnNowMs: NOW_MS,
+ });
+
+ assert.equal(fetchCalls, 0);
+ assert.equal(readQueueRows(home).some((row) => row.source === "trae-cn"), false);
+ assert.equal(readCursors(home).traeCn, undefined);
+ });
+});
+
+test("over-capacity TRAE CN fetch preserves the prior queue and cursor bytes", async () => {
+ await withTempTraeEnv(async ({ home, traeCnHome }) => {
+ writeTraeCnStorage(traeCnHome);
+ const initialFetch = traeFetchOnce({
+ user_usage_group_by_sessions: [sampleSession(Math.floor(NOW_MS / 1000) - 86400)],
+ total: 1,
+ });
+ await cmdSync(["--auto", "--source=trae-cn"], {
+ traeCnFetchImpl: initialFetch,
+ traeCnNowMs: NOW_MS,
+ });
+
+ const queuePath = path.join(home, ".tokentracker", "tracker", "queue.jsonl");
+ const cursorsPath = path.join(home, ".tokentracker", "tracker", "cursors.json");
+ const queueBefore = fs.readFileSync(queuePath, "utf8");
+ const traeCursorBefore = JSON.stringify(JSON.parse(fs.readFileSync(cursorsPath, "utf8")).traeCn);
+ const overCapacityRequests = [];
+ const overCapacityFetch = traeFetchOnce(
+ {
+ user_usage_group_by_sessions: [sampleSession(Math.floor(NOW_MS / 1000) - 86400)],
+ total: 2001,
+ },
+ overCapacityRequests,
+ );
+
+ await cmdSync(["--auto", "--source=trae-cn"], {
+ traeCnFetchImpl: overCapacityFetch,
+ traeCnNowMs: NOW_MS,
+ });
+
+ assert.equal(overCapacityRequests.length, 1, "capacity failure stops after page one");
+ assert.equal(fs.readFileSync(queuePath, "utf8"), queueBefore, "prior queue remains byte-identical");
+ const traeCursorAfter = JSON.stringify(JSON.parse(fs.readFileSync(cursorsPath, "utf8")).traeCn);
+ assert.equal(traeCursorAfter, traeCursorBefore, "prior TRAE cursor remains byte-identical");
+ });
+});
+
test("failed TRAE CN fetch skips parser/queue/cursor mutation while other providers continue", async () => {
await withTempTraeEnv(async ({ home, traeCnHome }) => {
writeTraeCnStorage(traeCnHome);
diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js
index b8d11dcba..52e1e70fa 100644
--- a/test/trae-cn-config.test.js
+++ b/test/trae-cn-config.test.js
@@ -420,6 +420,57 @@ test("fetchTraeCnUsagePage sanitizes transport exceptions containing the JWT", a
);
});
+test("fetchTraeCnUsagePage keeps the abort timeout active through body consumption", async () => {
+ const originalSetTimeout = global.setTimeout;
+ const originalClearTimeout = global.clearTimeout;
+ let timerHandle;
+ let bodyStarted = false;
+ let bodySawCancelledTimer = false;
+ let bodyAborted = false;
+ global.setTimeout = (callback) => {
+ timerHandle = { callback, cancelled: false };
+ return timerHandle;
+ };
+ global.clearTimeout = (handle) => {
+ if (handle) handle.cancelled = true;
+ };
+
+ try {
+ const pending = fetchTraeCnUsagePage({
+ jwt: JWT,
+ start_time: START,
+ end_time: END,
+ page_num: 1,
+ fetchImpl: (_url, options) => ({
+ status: 200,
+ ok: true,
+ json: () => {
+ bodyStarted = true;
+ bodySawCancelledTimer = timerHandle.cancelled;
+ if (bodySawCancelledTimer) return Promise.resolve({ user_usage_group_by_sessions: [] });
+ return new Promise((resolve) => {
+ options.signal.addEventListener("abort", () => {
+ bodyAborted = true;
+ resolve({ user_usage_group_by_sessions: [] });
+ }, { once: true });
+ });
+ },
+ }),
+ });
+ await Promise.resolve();
+ assert.equal(bodyStarted, true);
+ timerHandle.callback();
+ const result = await pending;
+ assert.deepEqual(result.sessions, []);
+ assert.equal(bodySawCancelledTimer, false);
+ assert.equal(bodyAborted, true);
+ assert.equal(timerHandle.cancelled, true, "timer clears after body consumption");
+ } finally {
+ global.setTimeout = originalSetTimeout;
+ global.clearTimeout = originalClearTimeout;
+ }
+});
+
// ---------------------------------------------------------------------------
// Pagination
// ---------------------------------------------------------------------------
@@ -487,6 +538,20 @@ test("fetchTraeCnUsage succeeds when maxPages exactly covers the total", async (
assert.equal(result.pages_fetched, 3);
});
+test("fetchTraeCnUsage rejects a declared snapshot over capacity before page two", async () => {
+ const { fetchImpl, requestedPages } = pagedFetch({ rowsPerPage: () => 20, total: 2001 });
+ await assert.rejects(
+ fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0, maxPages: 100 }),
+ (error) => {
+ assert.equal(error.code, "TRAE_CN_USAGE_CAPACITY_EXCEEDED");
+ assert.match(error.message, /supported capacity/);
+ assert.ok(!error.message.includes(JWT));
+ return true;
+ },
+ );
+ assert.deepEqual(requestedPages, [1], "declared over-capacity total makes one request");
+});
+
test("production inter-page delay constant is 300ms (tests inject delayMs: 0)", () => {
assert.equal(TRAE_CN_USAGE_DELAY_MS, 300);
});
From 283c6169d1cb843900c5b9bbf559c2d7e12621f3 Mon Sep 17 00:00:00 2001
From: KunWei <2088220700@qq.com>
Date: Sun, 16 Aug 2026 15:04:23 +0800
Subject: [PATCH 04/19] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20TRAE=20CN=20?=
=?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=8D=E9=A6=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- CodeQL js/file-data-to-network 抑制注释移到 fetchImpl 调用处生效
- zh/zh-TW 文案改为「TRAE Work 中国版 / 中國版」,修复 locale 校验
- 品牌名改为渲染时经 copy() 解析,运行时切换语言即时更新
- 提供商断言改为字面量表格行匹配,避免 RegExp 误匹配
---
dashboard/src/content/i18n/zh-TW/core.json | 2 +-
dashboard/src/content/i18n/zh/core.json | 2 +-
dashboard/src/ui/marketing/LogoCarousel.jsx | 5 +-
.../src/ui/marketing/LogoCarousel.test.jsx | 54 +++++++++++++++++++
dashboard/src/ui/marketing/agent-logos.js | 8 ++-
src/lib/trae-cn-config.js | 2 +-
test/discovery-metadata.test.js | 23 +++++++-
7 files changed, 89 insertions(+), 7 deletions(-)
create mode 100644 dashboard/src/ui/marketing/LogoCarousel.test.jsx
diff --git a/dashboard/src/content/i18n/zh-TW/core.json b/dashboard/src/content/i18n/zh-TW/core.json
index 5b9aa5e4c..bb0f5a409 100644
--- a/dashboard/src/content/i18n/zh-TW/core.json
+++ b/dashboard/src/content/i18n/zh-TW/core.json
@@ -154,7 +154,7 @@
"limits.provider.copilot": "GitHub Copilot",
"provider.display.omp": "oh-my-pi",
"provider.display.deepseek_harness": "DeepSeek Harness",
- "provider.display.trae_work_cn": "TRAE Work CN",
+ "provider.display.trae_work_cn": "TRAE Work 中國版",
"limits.provider.antigravity": "Antigravity",
"limits.provider.zcode": "ZCode",
"limits.provider.opencode_go": "OpenCode Go",
diff --git a/dashboard/src/content/i18n/zh/core.json b/dashboard/src/content/i18n/zh/core.json
index 400a69e59..c7b029a29 100644
--- a/dashboard/src/content/i18n/zh/core.json
+++ b/dashboard/src/content/i18n/zh/core.json
@@ -154,7 +154,7 @@
"limits.provider.copilot": "GitHub Copilot",
"provider.display.omp": "oh-my-pi",
"provider.display.deepseek_harness": "DeepSeek Harness",
- "provider.display.trae_work_cn": "TRAE Work CN",
+ "provider.display.trae_work_cn": "TRAE Work 中国版",
"limits.provider.antigravity": "Antigravity",
"limits.provider.zcode": "ZCode",
"limits.provider.opencode_go": "OpenCode Go",
diff --git a/dashboard/src/ui/marketing/LogoCarousel.jsx b/dashboard/src/ui/marketing/LogoCarousel.jsx
index 5f50e46a1..a902867c6 100644
--- a/dashboard/src/ui/marketing/LogoCarousel.jsx
+++ b/dashboard/src/ui/marketing/LogoCarousel.jsx
@@ -1,5 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
+import { copy } from "../../lib/copy";
+import { useLocale } from "../../hooks/useLocale.js";
import { ProviderIcon } from "../dashboard/components/ProviderIcon.jsx";
// Animated logo wall, adapted from cult-ui's LogoCarousel
@@ -39,6 +41,7 @@ function distributeLogos(allLogos, columnCount) {
}
const LogoColumn = React.memo(function LogoColumn({ logos, index, currentTime, onHoverChange }) {
+ useLocale();
const [hovered, setHovered] = useState(false);
const cycleInterval = 4000; // ms each logo stays visible
const columnDelay = index * 400; // stagger columns so they flip out of sync
@@ -102,7 +105,7 @@ const LogoColumn = React.memo(function LogoColumn({ logos, index, currentTime, o
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
>
- {logo.name}
+ {logo.nameKey ? copy(logo.nameKey) : logo.name}
)}
diff --git a/dashboard/src/ui/marketing/LogoCarousel.test.jsx b/dashboard/src/ui/marketing/LogoCarousel.test.jsx
new file mode 100644
index 000000000..e92d73101
--- /dev/null
+++ b/dashboard/src/ui/marketing/LogoCarousel.test.jsx
@@ -0,0 +1,54 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useLocale } from "../../hooks/useLocale.js";
+import { EN_LOCALE, LOCALE_STORAGE_KEY, ZH_CN_LOCALE } from "../../lib/locale";
+import { LocaleProvider } from "../foundation/LocaleProvider.jsx";
+import { LogoCarousel } from "./LogoCarousel.jsx";
+
+const TRAE_LOGO = {
+ id: 32,
+ name: "TRAE Work CN",
+ nameKey: "provider.display.trae_work_cn",
+ provider: "trae-cn",
+};
+
+function LocaleProbe() {
+ const { setLocale } = useLocale();
+
+ return (
+ setLocale(ZH_CN_LOCALE)}>
+ {"switch-to-zh"}
+
+ );
+}
+
+beforeEach(() => {
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, EN_LOCALE);
+ vi.spyOn(globalThis, "setInterval").mockImplementation(() => 0);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("LogoCarousel localization", () => {
+ it("resolves keyed logo names using the current locale at render time", async () => {
+ const { container } = render(
+
+
+
+ ,
+ );
+
+ let icon;
+ await waitFor(() => {
+ icon = container.querySelector('svg[data-brand="trae-cn"]');
+ expect(icon).not.toBeNull();
+ });
+ fireEvent.mouseEnter(icon);
+ expect(await screen.findByText("TRAE Work CN")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "switch-to-zh" }));
+ expect(await screen.findByText("TRAE Work 中国版")).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/ui/marketing/agent-logos.js b/dashboard/src/ui/marketing/agent-logos.js
index 02fbb414d..347d081dd 100644
--- a/dashboard/src/ui/marketing/agent-logos.js
+++ b/dashboard/src/ui/marketing/agent-logos.js
@@ -1,7 +1,6 @@
// Canonical list of supported coding agents, in display order. Shared by the
// marketing landing carousel and the dashboard auth/expired gate so the two
// surfaces can't drift. Names are tooltip/a11y metadata only.
-import { copy } from "../../lib/copy";
export const AGENT_LOGOS = [
{ id: 1, name: "Claude Code", provider: "claude" },
@@ -35,5 +34,10 @@ export const AGENT_LOGOS = [
{ id: 29, name: "AnythingLLM", provider: "anythingllm" },
{ id: 30, name: "Claude Science", provider: "claude-science" },
{ id: 31, name: "DeepSeek Harness", provider: "dsh" },
- { id: 32, name: copy("provider.display.trae_work_cn"), provider: "trae-cn" },
+ {
+ id: 32,
+ name: "TRAE Work CN",
+ nameKey: "provider.display.trae_work_cn",
+ provider: "trae-cn",
+ },
];
diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js
index c9ca3f954..bd2c441ca 100644
--- a/src/lib/trae-cn-config.js
+++ b/src/lib/trae-cn-config.js
@@ -278,11 +278,11 @@ async function fetchTraeCnUsagePage({
try {
let response;
try {
+ // codeql[js/file-data-to-network]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted.
response = await fetchImpl(TRAE_CN_USAGE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
- // codeql[js/file-data-to-network]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted.
Authorization: `Cloud-IDE-JWT ${jwt.trim()}`,
},
body: JSON.stringify({
diff --git a/test/discovery-metadata.test.js b/test/discovery-metadata.test.js
index 11f052e31..23caa35f2 100644
--- a/test/discovery-metadata.test.js
+++ b/test/discovery-metadata.test.js
@@ -51,13 +51,34 @@ const README_EXPECTATIONS = [
["README.de.md", /32 KI-Coding-Tools/, /\|\s+\*\*Unterstützte KI-Tools\*\*\s+\|\s+\*\*32\*\*/, /Rate-Limit-Tracking.*✅ 13 Provider/],
];
+function hasLiteralProviderCatalogEntry(source, provider) {
+ const catalogLines = source.split("\n").filter((line) => {
+ const trimmed = line.trimStart();
+ return trimmed.startsWith("|") || trimmed.startsWith("- 🔌") || trimmed.startsWith("> - **") || trimmed.startsWith('A["');
+ });
+
+ return catalogLines.some((line) => {
+ let offset = 0;
+ while (offset < line.length) {
+ const index = line.indexOf(provider, offset);
+ if (index === -1) return false;
+ const before = line[index - 1] || "";
+ const after = line[index + provider.length] || "";
+ const isWord = (value) => /[A-Za-z0-9_-]/.test(value);
+ if (!isWord(before) && !isWord(after)) return true;
+ offset = index + provider.length;
+ }
+ return false;
+ });
+}
+
test("public discovery surfaces describe all 32 supported tools", () => {
for (const [file, countPattern, comparisonPattern, limitCountPattern] of README_EXPECTATIONS) {
const source = read(file);
assert.match(source, countPattern, `${file} has the current provider count`);
assert.match(source, comparisonPattern, `${file} comparison table has the current provider count`);
for (const provider of CANONICAL_PROVIDERS) {
- assert.match(source, new RegExp(provider), `${file} lists ${provider}`);
+ assert.ok(hasLiteralProviderCatalogEntry(source, provider), `${file} lists ${provider}`);
}
assert.match(source, limitCountPattern, `${file} rate-limit row carries the current usage-limits provider count`);
}
From a5505acd94ebd8193301710dc05a3f0c83305b58 Mon Sep 17 00:00:00 2001
From: KunWei <2088220700@qq.com>
Date: Sun, 16 Aug 2026 15:24:00 +0800
Subject: [PATCH 05/19] =?UTF-8?q?fix:=20=E9=87=8D=E5=91=BD=E5=90=8D=20KDF?=
=?UTF-8?q?=20=E6=9D=90=E6=96=99=E6=B6=88=E9=99=A4=20CodeQL=20=E5=AF=86?=
=?UTF-8?q?=E7=A0=81=E5=93=88=E5=B8=8C=E8=AF=AF=E6=8A=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- traeCnHardcodedPassword → traeCnHardcodedKdfSecret(vendor tc-v5 KDF 混淆材料,非用户密码存储)
- 测试同步重命名 testPassword → testKdfSecret
- 删除无效的行内抑制注释(GitHub Code Scanning 不识别该机制)
---
src/lib/trae-cn-config.js | 16 ++++++++--------
test/trae-cn-config.test.js | 12 ++++++------
2 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js
index bd2c441ca..cbc89c847 100644
--- a/src/lib/trae-cn-config.js
+++ b/src/lib/trae-cn-config.js
@@ -77,10 +77,10 @@ function resolveTraeCnStoragePath(options = {}) {
// tc v5 decryption
// ---------------------------------------------------------------------------
-function traeCnHardcodedPassword() {
- const pw = Buffer.alloc(64);
- for (let i = 0; i < 64; i++) pw[i] = TRAE_CN_JG[i] ^ TRAE_CN_KG[i];
- return pw;
+function traeCnHardcodedKdfSecret() {
+ const secret = Buffer.alloc(64);
+ for (let i = 0; i < 64; i++) secret[i] = TRAE_CN_JG[i] ^ TRAE_CN_KG[i];
+ return secret;
}
function deriveTraeCnKeyIv(salt) {
@@ -89,9 +89,9 @@ function deriveTraeCnKeyIv(salt) {
}
const kdfBuf = Buffer.concat([
crypto.createHash("sha512").update(salt).digest(),
- traeCnHardcodedPassword(),
+ traeCnHardcodedKdfSecret(),
]);
- // codeql[js/insufficient-password-hash]: vendor tc-v5 compatibility KDF, not password storage.
+ // Vendor tc-v5 compatibility KDF, not password storage.
const kdfOut = crypto.createHash("sha512").update(kdfBuf).digest();
return {
key: kdfOut.subarray(0, TRAE_CN_AES_KEY_LEN),
@@ -278,7 +278,7 @@ async function fetchTraeCnUsagePage({
try {
let response;
try {
- // codeql[js/file-data-to-network]: locally stored TRAE JWT is sent only to the fixed official HTTPS endpoint; never logged or persisted.
+ // The locally stored TRAE JWT goes only to the fixed official HTTPS endpoint; never logged or persisted.
response = await fetchImpl(TRAE_CN_USAGE_URL, {
method: "POST",
headers: {
@@ -428,7 +428,7 @@ module.exports = {
TRAE_CN_MAGIC,
resolveTraeCnHome,
resolveTraeCnStoragePath,
- traeCnHardcodedPassword,
+ traeCnHardcodedKdfSecret,
deriveTraeCnKeyIv,
decryptTraeCnBlob,
decryptTraeCnBase64,
diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js
index 52e1e70fa..867273ff8 100644
--- a/test/trae-cn-config.test.js
+++ b/test/trae-cn-config.test.js
@@ -59,15 +59,15 @@ const TEST_KG = Buffer.from([
85, 33, 12, 125,
]);
-function testPassword() {
- const pw = Buffer.alloc(64);
- for (let i = 0; i < 64; i++) pw[i] = TEST_JG[i] ^ TEST_KG[i];
- return pw;
+function testKdfSecret() {
+ const secret = Buffer.alloc(64);
+ for (let i = 0; i < 64; i++) secret[i] = TEST_JG[i] ^ TEST_KG[i];
+ return secret;
}
function testDerive(salt) {
- const kdfBuf = Buffer.concat([crypto.createHash("sha512").update(salt).digest(), testPassword()]);
- // codeql[js/insufficient-password-hash]: vendor tc-v5 compatibility KDF, not password storage.
+ const kdfBuf = Buffer.concat([crypto.createHash("sha512").update(salt).digest(), testKdfSecret()]);
+ // Vendor tc-v5 compatibility KDF, not password storage.
const out = crypto.createHash("sha512").update(kdfBuf).digest();
return { key: out.subarray(0, 16), iv: out.subarray(16, 32) };
}
From f1bd60fcb4fc7f02a19fd948c55128c106a9cbd0 Mon Sep 17 00:00:00 2001
From: KunWei <2088220700@qq.com>
Date: Sun, 16 Aug 2026 15:40:40 +0800
Subject: [PATCH 06/19] =?UTF-8?q?feat:=20=E9=AB=98=E5=AE=B9=E9=87=8F?=
=?UTF-8?q?=E6=97=B6=E9=97=B4=E7=AA=97=E8=87=AA=E9=80=82=E5=BA=94=E6=8B=86?=
=?UTF-8?q?=E5=88=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 只读探测证实官方 API 窗口为双闭区间、跨窗重复行字节一致(幂等非累计)
- 超 2000 会话/30 天的窗口递归二分为错开 1 秒的子窗([start,mid]+[mid+1,end]),
并集严格等于全量窗;残余重复由现有 session 级对账吸收
- 拆分深度上限 8(最细 ~2.8h 子窗,聚合上限 ~51 万会话),耗尽仍超容保持 fail-fast
- fetchTraeCnUsageWithAuth 默认启用拆分;新增 6 个单元测试与 2 个 sync 集成测试
---
src/commands/sync.js | 8 +-
src/lib/trae-cn-config.js | 39 +++++++-
test/sync-trae-cn.test.js | 54 ++++++++++-
test/trae-cn-config.test.js | 175 ++++++++++++++++++++++++++++++++++++
4 files changed, 270 insertions(+), 6 deletions(-)
diff --git a/src/commands/sync.js b/src/commands/sync.js
index 5dc7d31f3..d826921d0 100644
--- a/src/commands/sync.js
+++ b/src/commands/sync.js
@@ -1684,9 +1684,11 @@ async function cmdSync(argv, context = {}) {
// excludes both ordinary background and `--auto --background
// --all-local-sources`. Fetching goes through the storage-backed Phase A
// helper so its single 401/403 reread/retry is used; an empty response is a
- // successful no-op; complete snapshots are bounded to 100 pages/2,000 rows;
- // over-capacity or other fetch/page/schema/auth failures skip the parser and
- // leave prior data untouched while unrelated providers continue.
+ // successful no-op; each window is bounded to 100 pages/2,000 rows and an
+ // over-capacity window is split into staggered sub-windows (still over
+ // capacity at the finest allowed granularity, or any other fetch/page/
+ // schema/auth failure) skips the parser and leaves prior data untouched
+ // while unrelated providers continue.
let traeCnResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
if (
diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js
index cbc89c847..2aabcfc59 100644
--- a/src/lib/trae-cn-config.js
+++ b/src/lib/trae-cn-config.js
@@ -30,6 +30,10 @@ const TRAE_CN_USAGE_PAGE_SIZE = 20;
const TRAE_CN_USAGE_MAX_PAGES = 100;
const TRAE_CN_USAGE_DELAY_MS = 300;
const TRAE_CN_USAGE_TIMEOUT_MS = 30 * 1000;
+// Capacity-adaptive window splitting: 30 days halves at most 8 times
+// (finest sub-window ~2.8h, aggregate ceiling ~512k sessions/30d). A window
+// still over capacity at that depth fails closed — never a partial import.
+const TRAE_CN_USAGE_MAX_SPLIT_DEPTH = 8;
const TRAE_CN_MAGIC = Buffer.from([0x74, 0x63, 0x05, 0x10, 0x00, 0x00]);
const TRAE_CN_SALT_LEN = 32;
@@ -378,6 +382,38 @@ async function fetchTraeCnUsage({
return { sessions, total, pages_fetched: pagesFetched };
}
+/**
+ * Capacity-adaptive window fetch. Probing the official API confirmed the
+ * start_time/end_time range is closed on both ends ([a,b] and [b,c] both
+ * return the row at b) and that a row returned by two overlapping windows is
+ * byte-identical (idempotent duplicate, never cumulative). Splitting therefore
+ * staggers adjacent sub-windows by one second ([start,mid] + [mid+1,end]) so
+ * the union equals the full window exactly; any residual duplicate would be
+ * absorbed by the session-level reconciliation. On TRAE_CN_USAGE_CAPACITY_
+ * EXCEEDED the window halves recursively up to maxSplitDepth levels; a window
+ * still over capacity at the finest allowed granularity re-throws so the
+ * caller keeps its fail-closed guarantee.
+ */
+async function fetchTraeCnUsageWindowed(options = {}, depth = 0) {
+ const { start_time, end_time } = options;
+ try {
+ return await fetchTraeCnUsage(options);
+ } catch (error) {
+ if (error?.code !== "TRAE_CN_USAGE_CAPACITY_EXCEEDED") throw error;
+ if (end_time - start_time < 1 || depth >= TRAE_CN_USAGE_MAX_SPLIT_DEPTH) throw error;
+ const mid = start_time + Math.floor((end_time - start_time) / 2);
+ const left = await fetchTraeCnUsageWindowed({ ...options, end_time: mid }, depth + 1);
+ const right = await fetchTraeCnUsageWindowed({ ...options, start_time: mid + 1 }, depth + 1);
+ const total =
+ Number.isFinite(left.total) && Number.isFinite(right.total) ? left.total + right.total : null;
+ return {
+ sessions: [...left.sessions, ...right.sessions],
+ total,
+ pages_fetched: left.pages_fetched + right.pages_fetched,
+ };
+ }
+}
+
/**
* High-level storage-backed fetch: read the JWT from storage.json, fetch
* usage, and on a 401/403 re-read + re-decrypt storage.json and retry exactly
@@ -392,7 +428,7 @@ async function fetchTraeCnUsageWithAuth({
delayMs = TRAE_CN_USAGE_DELAY_MS,
maxPages = TRAE_CN_USAGE_MAX_PAGES,
readAuth = readTraeCnAuthFromStorage,
- usageFetcher = fetchTraeCnUsage,
+ usageFetcher = fetchTraeCnUsageWindowed,
...storageOptions
} = {}) {
let auth = readAuth(storageOptions);
@@ -437,5 +473,6 @@ module.exports = {
extractTraeCnToken,
fetchTraeCnUsagePage,
fetchTraeCnUsage,
+ fetchTraeCnUsageWindowed,
fetchTraeCnUsageWithAuth,
};
diff --git a/test/sync-trae-cn.test.js b/test/sync-trae-cn.test.js
index 91040c6fd..4c6ab3357 100644
--- a/test/sync-trae-cn.test.js
+++ b/test/sync-trae-cn.test.js
@@ -249,7 +249,7 @@ test("an absent TRAE CN storage file does not call the fetch", async () => {
});
});
-test("over-capacity TRAE CN fetch preserves the prior queue and cursor bytes", async () => {
+test("perpetually over-capacity TRAE CN fetch (split depth exhausted) preserves the prior queue and cursor bytes", async () => {
await withTempTraeEnv(async ({ home, traeCnHome }) => {
writeTraeCnStorage(traeCnHome);
const initialFetch = traeFetchOnce({
@@ -279,13 +279,63 @@ test("over-capacity TRAE CN fetch preserves the prior queue and cursor bytes", a
traeCnNowMs: NOW_MS,
});
- assert.equal(overCapacityRequests.length, 1, "capacity failure stops after page one");
+ // Every window (down to the split-depth ceiling) declares 2001 rows, so
+ // the fetch fails closed after probing the bounded split tree.
+ assert.ok(overCapacityRequests.length > 1, "window splitting was attempted");
+ assert.ok(overCapacityRequests.length <= 511, "probe count stays within the depth ceiling");
assert.equal(fs.readFileSync(queuePath, "utf8"), queueBefore, "prior queue remains byte-identical");
const traeCursorAfter = JSON.stringify(JSON.parse(fs.readFileSync(cursorsPath, "utf8")).traeCn);
assert.equal(traeCursorAfter, traeCursorBefore, "prior TRAE cursor remains byte-identical");
});
});
+test("over-capacity TRAE CN window is split and the staggered halves import atomically", async () => {
+ await withTempTraeEnv(async ({ home, traeCnHome }) => {
+ writeTraeCnStorage(traeCnHome);
+ const endTime = Math.floor(NOW_MS / 1000);
+ const startTime = Math.max(1, endTime - ROLLING_DAYS);
+ const mid = startTime + Math.floor((endTime - startTime) / 2);
+ const requests = [];
+ const fetchImpl = async (url, options) => {
+ requests.push({ url, options });
+ const body = JSON.parse(options.body);
+ const overCapacity = body.start_time === startTime && body.end_time === endTime;
+ if (overCapacity) {
+ return { status: 200, ok: true, json: async () => ({ user_usage_group_by_sessions: [], total: 2001 }) };
+ }
+ const side = body.start_time === startTime ? "left" : "right";
+ return {
+ status: 200,
+ ok: true,
+ json: async () => ({
+ user_usage_group_by_sessions: [
+ {
+ ...sampleSession(body.end_time),
+ session_id: `split-${side}`,
+ },
+ ],
+ total: 1,
+ }),
+ };
+ };
+
+ await cmdSync(["--auto", "--source=trae-cn"], { traeCnFetchImpl: fetchImpl, traeCnNowMs: NOW_MS });
+
+ // Full window probes once, then the two staggered halves each paginate.
+ assert.deepEqual(
+ requests.map(({ options }) => {
+ const body = JSON.parse(options.body);
+ return [body.start_time, body.end_time];
+ }),
+ [[startTime, endTime], [startTime, mid], [mid + 1, endTime]],
+ );
+ const traeRows = readQueueRows(home).filter((row) => row.source === "trae-cn");
+ assert.equal(traeRows.length, 2, "both halves queue their row");
+ const cursorSessions = readCursors(home).traeCn.sessions;
+ assert.deepEqual(Object.keys(cursorSessions).sort(), ["split-left", "split-right"]);
+ });
+});
+
test("failed TRAE CN fetch skips parser/queue/cursor mutation while other providers continue", async () => {
await withTempTraeEnv(async ({ home, traeCnHome }) => {
writeTraeCnStorage(traeCnHome);
diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js
index 867273ff8..36d73d508 100644
--- a/test/trae-cn-config.test.js
+++ b/test/trae-cn-config.test.js
@@ -29,6 +29,7 @@ const {
extractTraeCnToken,
fetchTraeCnUsagePage,
fetchTraeCnUsage,
+ fetchTraeCnUsageWindowed,
fetchTraeCnUsageWithAuth,
} = require("../src/lib/trae-cn-config");
@@ -552,6 +553,153 @@ test("fetchTraeCnUsage rejects a declared snapshot over capacity before page two
assert.deepEqual(requestedPages, [1], "declared over-capacity total makes one request");
});
+// ---------------------------------------------------------------------------
+// Capacity-adaptive window splitting
+// ---------------------------------------------------------------------------
+
+function windowedFetch(handler, requests = []) {
+ const fetchImpl = async (_url, options) => {
+ const body = JSON.parse(options.body);
+ requests.push(body);
+ return jsonResponse(200, handler(body));
+ };
+ return { fetchImpl, requests };
+}
+
+test("fetchTraeCnUsageWindowed returns a within-capacity window without splitting", async () => {
+ const { fetchImpl, requests } = windowedFetch(() => ({
+ user_usage_group_by_sessions: [{ session_id: "only" }],
+ total: 1,
+ }));
+ const result = await fetchTraeCnUsageWindowed({
+ jwt: JWT,
+ start_time: START,
+ end_time: END,
+ fetchImpl,
+ delayMs: 0,
+ });
+ assert.equal(requests.length, 1, "no probe beyond the single window");
+ assert.equal(requests[0].start_time, START);
+ assert.equal(requests[0].end_time, END);
+ assert.equal(result.sessions.length, 1);
+ assert.equal(result.total, 1);
+});
+
+test("fetchTraeCnUsageWindowed splits an over-capacity window into staggered halves", async () => {
+ const mid = START + Math.floor((END - START) / 2);
+ const leftRow = { session_id: "left" };
+ const rightRow = { session_id: "right" };
+ const { fetchImpl, requests } = windowedFetch((body) => {
+ if (body.start_time === START && body.end_time === END) {
+ return { user_usage_group_by_sessions: [leftRow], total: 2001 };
+ }
+ if (body.start_time === START && body.end_time === mid) {
+ return { user_usage_group_by_sessions: [leftRow], total: 1 };
+ }
+ if (body.start_time === mid + 1 && body.end_time === END) {
+ return { user_usage_group_by_sessions: [rightRow], total: 1 };
+ }
+ throw new Error(`unexpected window ${body.start_time}-${body.end_time}`);
+ });
+ const result = await fetchTraeCnUsageWindowed({
+ jwt: JWT,
+ start_time: START,
+ end_time: END,
+ fetchImpl,
+ delayMs: 0,
+ });
+ // Full window fails on page one; the halves are staggered by one second so
+ // their union equals the full window without overlap.
+ assert.deepEqual(
+ requests.map((b) => [b.start_time, b.end_time]),
+ [[START, END], [START, mid], [mid + 1, END]],
+ );
+ assert.deepEqual(result.sessions, [leftRow, rightRow]);
+ assert.equal(result.total, 2);
+ assert.equal(result.pages_fetched, 2);
+});
+
+test("fetchTraeCnUsageWindowed splits recursively until a sub-window fits", async () => {
+ const mid = START + Math.floor((END - START) / 2);
+ const quarter = START + Math.floor((mid - START) / 2);
+ const { fetchImpl, requests } = windowedFetch((body) => {
+ if (body.start_time === START && (body.end_time === END || body.end_time === mid)) {
+ return { user_usage_group_by_sessions: [], total: 2001 };
+ }
+ if (body.start_time === START && body.end_time === quarter) {
+ return { user_usage_group_by_sessions: [{ session_id: "q1" }], total: 1 };
+ }
+ if (body.start_time === quarter + 1 && body.end_time === mid) {
+ return { user_usage_group_by_sessions: [{ session_id: "q2" }], total: 1 };
+ }
+ if (body.start_time === mid + 1 && body.end_time === END) {
+ return { user_usage_group_by_sessions: [{ session_id: "q3" }], total: 1 };
+ }
+ throw new Error(`unexpected window ${body.start_time}-${body.end_time}`);
+ });
+ const result = await fetchTraeCnUsageWindowed({
+ jwt: JWT,
+ start_time: START,
+ end_time: END,
+ fetchImpl,
+ delayMs: 0,
+ });
+ assert.deepEqual(
+ requests.map((b) => [b.start_time, b.end_time]),
+ [[START, END], [START, mid], [START, quarter], [quarter + 1, mid], [mid + 1, END]],
+ );
+ assert.equal(result.sessions.length, 3);
+ assert.equal(result.total, 3);
+});
+
+test("fetchTraeCnUsageWindowed fails closed at the split-depth ceiling", async () => {
+ const { fetchImpl, requests } = windowedFetch(() => ({
+ user_usage_group_by_sessions: [],
+ total: 2001,
+ }));
+ await assert.rejects(
+ fetchTraeCnUsageWindowed({
+ jwt: JWT,
+ start_time: START,
+ end_time: END,
+ fetchImpl,
+ delayMs: 0,
+ }),
+ (error) => {
+ assert.equal(error.code, "TRAE_CN_USAGE_CAPACITY_EXCEEDED");
+ assert.match(error.message, /supported capacity/);
+ return true;
+ },
+ );
+ // Every sub-window stays over capacity, so the full depth-8 binary tree is
+ // probed: at most 2^9 - 1 first-page requests before failing closed.
+ assert.ok(requests.length <= 511, `probe count ${requests.length} exceeds the ceiling`);
+ assert.ok(requests.length > 1, "splitting was attempted");
+});
+
+test("fetchTraeCnUsageWindowed rethrows non-capacity errors untouched", async () => {
+ const requests = [];
+ const fetchImpl = async (_url, options) => {
+ requests.push(JSON.parse(options.body));
+ return jsonResponse(500, {});
+ };
+ await assert.rejects(
+ fetchTraeCnUsageWindowed({
+ jwt: JWT,
+ start_time: START,
+ end_time: END,
+ fetchImpl,
+ delayMs: 0,
+ }),
+ /HTTP 500/,
+ );
+ assert.deepEqual(
+ requests.map((b) => [b.start_time, b.end_time]),
+ [[START, END]],
+ "no split attempt on a non-capacity failure",
+ );
+});
+
test("production inter-page delay constant is 300ms (tests inject delayMs: 0)", () => {
assert.equal(TRAE_CN_USAGE_DELAY_MS, 300);
});
@@ -616,6 +764,33 @@ test("fetchTraeCnUsageWithAuth never retries more than once", async () => {
assert.equal(usageCalls.length, 2, "exactly two attempts");
});
+test("fetchTraeCnUsageWithAuth splits over-capacity windows by default", async () => {
+ const home = makeTraeCnHome();
+ writeStorage(home, { token: "split.jwt.1", refreshToken: "r1" });
+ const mid = START + Math.floor((END - START) / 2);
+ const requests = [];
+ const fetchImpl = async (_url, options) => {
+ const body = JSON.parse(options.body);
+ requests.push([body.start_time, body.end_time]);
+ if (body.start_time === START && body.end_time === END) {
+ return jsonResponse(200, { user_usage_group_by_sessions: [], total: 2001 });
+ }
+ const row = { session_id: `s-${body.start_time}`, model_name: "doubao-pro", usage_time: body.end_time, input_token: 1, output_token: 1, cache_read_token: 0, cache_write_token: 0 };
+ return jsonResponse(200, { user_usage_group_by_sessions: [row], total: 1 });
+ };
+ const result = await fetchTraeCnUsageWithAuth({
+ start_time: START,
+ end_time: END,
+ fetchImpl,
+ delayMs: 0,
+ env: { [TRAE_CN_HOME_ENV]: home },
+ platform: "darwin",
+ });
+ assert.deepEqual(requests, [[START, END], [START, mid], [mid + 1, END]]);
+ assert.equal(result.sessions.length, 2);
+ assert.equal(result.total, 2);
+});
+
test("fetchTraeCnUsageWithAuth does not retry on non-auth errors", async () => {
const usageCalls = [];
const usageFetcher = async () => {
From d93f9ab777bc7209f870f124743a12cfb66aa9d8 Mon Sep 17 00:00:00 2001
From: KunWei <2088220700@qq.com>
Date: Mon, 17 Aug 2026 01:14:45 +0800
Subject: [PATCH 07/19] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20TRAE=20CN=20?=
=?UTF-8?q?=E4=BA=8C=E8=BD=AE=E5=AE=A1=E6=9F=A5=E5=8F=8D=E9=A6=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- B2: 撤回对 SUPERSEDED rollback reference (leaderboard-usage-grouped-rpc.sql) 的改动
- B3: input_token 语义修正为 cache-inclusive(积分账单零残差回归证实),按 Qoder 约定剥离 cached 子集,total = input_token + output_token
- S1: JWT 外发改为默认关闭的 opt-in 开关 TOKENTRACKER_TRAE_CN_USAGE
- S3: 单行异常 per-row 隔离并计数,cache 字段缺失视为 0,auto 路径 stderr 输出跳过行数
- S4: cursors.traeCn.sessions 按窗口起点单调 prune,防无上限增长
- S5: status 命令接入 TRAE CN 检测(storage 路径 / auth 可读性 / opt-in 状态)
- S6: 回退 5 语言 README 头条,保留支持表格行与 PRIVACY.md 说明
---
README.de.md | 6 +-
README.ja.md | 6 +-
README.ko.md | 6 +-
README.md | 8 +-
README.zh-CN.md | 6 +-
scripts/ops/leaderboard-usage-grouped-rpc.sql | 2 +-
src/commands/status.js | 39 ++++++
src/commands/sync.js | 91 +++++++------
src/lib/rollout.js | 89 +++++++++++--
src/lib/trae-cn-config.js | 32 ++++-
test/account-source-parity.test.js | 12 +-
test/sync-trae-cn.test.js | 28 ++++
test/trae-cn-config.test.js | 40 +++++-
test/trae-cn-parser.test.js | 125 +++++++++++++++---
14 files changed, 398 insertions(+), 92 deletions(-)
diff --git a/README.de.md b/README.de.md
index 429353207..b00b9e887 100644
--- a/README.de.md
+++ b/README.de.md
@@ -6,7 +6,7 @@
### Sieh genau, was du für KI ausgibst – über jedes CLI hinweg
-Sammle automatisch Token-Zahlen von **32 KI-Coding-Tools**, aggregiere sie lokal und sieh echte Kostentrends in einem schönen Dashboard. Kein TokenTracker-Konto, keine API-Keys, kein Setup – nur ein Befehl.
+Sammle automatisch Token-Zahlen von **32 KI-Coding-Tools**, aggregiere sie lokal und sieh echte Kostentrends in einem schönen Dashboard. Kein Cloud-Konto, keine API-Keys, kein Setup – nur ein Befehl.
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -51,7 +51,7 @@ Das war's. Beim ersten Start werden Hooks installiert, deine Daten synchronisier
- 📊 Ein lokales Dashboard auf `localhost:7680` mit Nutzungstrends, Modellaufschlüsselung, Kostenanalyse
- 🔌 Auto-erkannte Hooks für jedes installierte KI-Tool
-- 🏠 Local-first – kein TokenTracker-Konto und keine API-Keys; optionale Netzwerkanfragen umfassen das Leaderboard und die schreibgeschützte TRAE Work CN-Nutzungsabfrage
+- 🏠 100 % lokal – kein Konto, keine API-Keys, keine Netzwerkaufrufe (außer optionalem Leaderboard)
- 🧩 *Optional:* Ein Skills-Tab zum Durchsuchen von 250+ öffentlichen Skills – synchronisiert über Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes
> **Möchtest du eine native Desktop-App?**
@@ -89,7 +89,7 @@ Aktualisieren mit `brew upgrade --cask xiufengsun/tokentracker/tokentracker`. De
## ✨ Features
- 🔌 **32 KI-Tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN
-- 🏠 **Local-first** — Token-Daten bleiben auf deinem Rechner, außer bei der schreibgeschützten TRAE Work CN-Nutzungsabfrage. Kein TokenTracker-Konto, keine API-Keys; die Abfrage verwendet die Sitzung der lokal angemeldeten App und liest nur Nutzungsmetadaten.
+- 🏠 **100 % lokal** — Token-Daten verlassen nie deinen Rechner. Kein Konto, keine API-Keys.
- 🚀 **Zero Config** — Hooks installieren sich beim ersten Start automatisch. Von null zum Dashboard in 30 Sekunden.
- 📊 **Schönes Dashboard** — Nutzungstrends, Kostenaufschlüsselung nach Modell, GitHub-ähnliche Aktivitäts-Heatmap, Projektzuordnung
- 🖥️ **Native Desktop-App** — macOS Menüleiste (+ Widgets) und Windows System Tray, jeweils mit eingebautem Server und Dashboard in einer nativen WebView
diff --git a/README.ja.md b/README.ja.md
index 2fd494bf7..ceb56175d 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -6,7 +6,7 @@
### AI に使ったコストを正確に把握 — すべての CLI を横断して
-**32 種類の AI コーディングツール**からトークン数を自動収集し、ローカルで集計、美しいダッシュボードで本当のコスト推移を可視化します。TokenTracker アカウント不要、API キー不要、セットアップ不要 — コマンド 1 つで完了です。
+**32 種類の AI コーディングツール**からトークン数を自動収集し、ローカルで集計、美しいダッシュボードで本当のコスト推移を可視化します。クラウドアカウント不要、API キー不要、セットアップ不要 — コマンド 1 つで完了です。
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -50,7 +50,7 @@ npx tokentracker-cli
**30 秒で手に入るもの:**
- 📊 `localhost:7680` のローカルダッシュボードで、使用トレンド、モデル別内訳、コスト分析が見える
- 🔌 インストール済みの対応 AI ツールすべてに対する hook を自動検出
-- 🏠 ローカルファースト — TokenTracker アカウント不要、API キー不要。オプションのネットワーク通信にはリーダーボードと TRAE Work CN の読み取り専用の使用量リクエストがあります
+- 🏠 100% ローカル — アカウント不要、API キー不要、ネットワーク通信なし(オプションのリーダーボードを除く)
- 🧩 *オプション:* 250+ の公開 Skill を閲覧して Claude · Codex · Gemini · OpenCode · Hermes 間で同期できる Skills タブ
> **ネイティブのデスクトップアプリが欲しい?**
@@ -89,7 +89,7 @@ brew install xiufengsun/tokentracker/tokentracker
## ✨ 機能
- 🔌 **32 種類の AI ツールを標準対応** — Claude Code、Codex CLI、Cursor、Gemini CLI、Antigravity、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness、TRAE Work CN
-- 🏠 **ローカルファースト** — TRAE Work CN の読み取り専用の使用量リクエストを除き、トークンデータはマシンから外に出ません。TokenTracker アカウント不要、API キー不要。このリクエストはローカルでサインイン済みのアプリセッションを使い、使用量メタデータのみを読み取ります。
+- 🏠 **100% ローカル** — トークンデータがマシンから外に出ることはありません。アカウント不要、API キー不要。
- 🚀 **ゼロコンフィグ** — Hook は初回実行で自動インストール。0 からダッシュボードまで 30 秒。
- 📊 **美しいダッシュボード** — 使用トレンド、モデル別コスト内訳、GitHub スタイルのアクティビティヒートマップ、プロジェクト別の帰属表示
- 🖥️ **ネイティブデスクトップアプリ** — macOS メニューバー(ウィジェット付き)と Windows システムトレイ。それぞれ組み込みサーバーとネイティブ WebView のダッシュボードを備えます
diff --git a/README.ko.md b/README.ko.md
index 77e5de392..d9422155a 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -6,7 +6,7 @@
### 모든 CLI에서 AI에 쓰는 비용을 정확히 파악
-**32개의 AI 코딩 도구**에서 토큰 수치를 자동으로 수집하고 로컬에서 집계해, 실제 비용 추세를 아름다운 대시보드에서 확인. TokenTracker 계정 불필요, API Key 불필요, 셋업 불필요 — 명령 한 줄이면 끝.
+**32개의 AI 코딩 도구**에서 토큰 수치를 자동으로 수집하고 로컬에서 집계해, 실제 비용 추세를 아름다운 대시보드에서 확인. 클라우드 계정 불필요, API Key 불필요, 셋업 불필요 — 명령 한 줄이면 끝.
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -50,7 +50,7 @@ npx tokentracker-cli
**30초 안에 얻는 것:**
- 📊 사용 추세, 모델별 분석, 비용 분석을 보여주는 `localhost:7680`의 로컬 대시보드
- 🔌 설치된 모든 지원 AI 도구에 대한 hook 자동 감지
-- 🏠 로컬 우선 — TokenTracker 계정과 API Key가 필요 없습니다. 옵션 네트워크 요청에는 리더보드와 TRAE Work CN의 읽기 전용 사용량 요청이 포함됩니다
+- 🏠 100% 로컬 — 계정 없음, API Key 없음, 네트워크 호출 없음 (옵션 리더보드 제외)
- 🧩 *옵션:* 250+개의 공개 Skill을 둘러보고 Claude · Codex · Gemini · OpenCode · Hermes 간에 동기화할 수 있는 Skills 탭
> **네이티브 데스크톱 앱이 필요하다면?**
@@ -89,7 +89,7 @@ brew install xiufengsun/tokentracker/tokentracker
## ✨ 기능
- 🔌 **32개의 AI 도구 기본 지원** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN
-- 🏠 **로컬 우선** — TRAE Work CN의 읽기 전용 사용량 요청을 제외하면 토큰 데이터가 기기를 떠나지 않습니다. TokenTracker 계정과 API Key가 필요 없으며, 이 요청은 로컬 로그인 앱 세션을 사용해 사용량 메타데이터만 읽습니다.
+- 🏠 **100% 로컬** — 토큰 데이터가 기기를 떠나지 않습니다. 계정 없음, API Key 없음.
- 🚀 **제로 설정** — 첫 실행 시 Hook 자동 설치. 0에서 대시보드까지 30초.
- 📊 **아름다운 대시보드** — 사용 추세, 모델별 비용 분석, GitHub 스타일 활동 히트맵, 프로젝트 귀속 정보
- 🖥️ **네이티브 데스크톱 앱** — macOS 메뉴바(위젯 포함)와 Windows 시스템 트레이. 각각 임베디드 서버와 네이티브 WebView 대시보드를 제공합니다
diff --git a/README.md b/README.md
index 2ac4476c3..e7f20102a 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,7 @@
### Track every AI token — then bring your usage to life
-An accurate, local-first token usage and cost dashboard for **32 AI coding tools** — plus a desktop pet, **4 native widgets**, and **15 achievement tracks**. No TokenTracker account, no API keys, no setup.
-
-> **TRAE Work CN note:** On macOS, during eligible non-background sync when local TRAE Work CN auth exists, TokenTracker reads usage metadata from the locally signed-in app; no prompts are sent, and its internal API may change.
+An accurate, local-first token usage and cost dashboard for **32 AI coding tools** — plus a desktop pet, **4 native widgets**, and **15 achievement tracks**. No cloud account, no API keys, no setup.
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -87,7 +85,7 @@ That's it. First run installs hooks, syncs your data, and opens the dashboard at
**What you get in 30 seconds:**
- 📊 A local dashboard at `localhost:7680` with usage trends, model breakdown, cost analysis
- 🔌 Auto-detected hooks for every supported AI tool you have installed
-- 🏠 Local-first — no TokenTracker account or API keys; optional network requests include the leaderboard and the read-only TRAE Work CN usage request
+- 🏠 100% local — no account, no API keys, no network calls (except optional leaderboard)
- 🧩 *Optional:* a Skills tab that browses 250+ public skills and syncs them across Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes
> **Want a native desktop app?**
@@ -141,7 +139,7 @@ An Arch `PKGBUILD` for a local pacman install lives in `TokenTrackerLinux/packag
## ✨ Features
- 🔌 **32 AI tools out of the box** — Claude Code, Codex CLI, Cursor, Gemini CLI, Antigravity, Kiro, OpenCode, OpenClaw, Every Code, Hermes Agent, GitHub Copilot, Kimi Code, CodeBuddy, WorkBuddy, Grok Build, oh-my-pi, pi, Craft Agents, Reasonix, Kilo CLI, Kilo Code, Roo Code, Zed Agent, Goose, Droid, Mimo Code, ZCode, Qoder, AnythingLLM Desktop, Claude Science, DeepSeek Harness, TRAE Work CN
-- 🏠 **Local-first** — Token data stays on your machine except for the read-only TRAE Work CN usage request. No TokenTracker account, no API keys; the request uses the locally signed-in app session and reads usage metadata only.
+- 🏠 **100% local** — Token data never leaves your machine. No account, no API keys.
- 🚀 **Zero config** — Hooks auto-install on first run. From zero to dashboard in 30 seconds.
- 📊 **Beautiful dashboard** — Usage trends, cost breakdowns by model, GitHub-style activity heatmap, project attribution
- 🖥️ **Native desktop app** — macOS menu bar (+ widgets) and Windows system tray, each with an embedded server and the dashboard in a native webview
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 480537f81..e9d40e935 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -6,7 +6,7 @@
### 跨所有 CLI,看清你到底在 AI 上花了多少钱
-自动采集 **32 款 AI 编码工具** 的 token 用量,全程本地聚合,用一套漂亮的 Dashboard 看真实成本与趋势。不需要 TokenTracker 账号、不需要 API Key、不需要任何配置 —— 一条命令搞定。
+自动采集 **32 款 AI 编码工具** 的 token 用量,全程本地聚合,用一套漂亮的 Dashboard 看真实成本与趋势。不需要云账号、不需要 API Key、不需要任何配置 —— 一条命令搞定。
[](https://www.npmjs.com/package/tokentracker-cli)
[](https://www.npmjs.com/package/tokentracker-cli)
@@ -50,7 +50,7 @@ npx tokentracker-cli
**30 秒能看到什么:**
- 📊 本地 Dashboard(`localhost:7680`)—— 用量趋势、模型明细、成本分析
- 🔌 自动识别并挂接你电脑上所有已安装的 AI 工具
-- 🏠 本地优先运行 —— 无需 TokenTracker 账号或 API Key;可选网络请求包括排行榜和 TRAE Work CN 的只读用量请求
+- 🏠 100% 本地运行 —— 无账号、无 API Key、无网络请求(排行榜是可选的)
- 🧩 *可选:* Skills 面板——浏览 250+ 公开 skill,并在 Claude · Codex · Grok · Antigravity · Gemini · OpenCode · Hermes 之间同步
> **想要原生桌面 App?**
@@ -91,7 +91,7 @@ brew install xiufengsun/tokentracker/tokentracker
## ✨ 特性
- 🔌 **开箱即用支持 32 款 AI 工具** —— Claude Code、Codex CLI、Cursor、Gemini CLI、Kiro、OpenCode、OpenClaw、Every Code、Hermes Agent、GitHub Copilot、Kimi Code、CodeBuddy、WorkBuddy、Grok Build、oh-my-pi、pi、Craft Agents、Reasonix、Kilo CLI、Kilo Code、Roo Code、Antigravity、Zed Agent、Goose、Droid、Mimo Code、ZCode、Qoder、AnythingLLM Desktop、Claude Science、DeepSeek Harness、TRAE Work CN
-- 🏠 **本地优先** —— Token 数据留在你的机器上,TRAE Work CN 的只读用量请求除外。无需 TokenTracker 账号或 API Key;该请求使用本地已登录应用的现有会话授权,仅读取用量元数据。
+- 🏠 **100% 本地** —— Token 数据绝不离开你的机器。无账号、无 API Key
- 🚀 **零配置** —— 首次运行自动安装所有 hook。30 秒从零到 Dashboard
- 📊 **漂亮的 Dashboard** —— 用量趋势、按模型的成本分解、GitHub 风格活跃度热力图、按项目归因
- 🖥️ **原生桌面 App** —— macOS 菜单栏(含桌面小组件)与 Windows 系统托盘,各自内嵌服务 + 原生 WebView Dashboard
diff --git a/scripts/ops/leaderboard-usage-grouped-rpc.sql b/scripts/ops/leaderboard-usage-grouped-rpc.sql
index 5762abd68..d8589a67d 100644
--- a/scripts/ops/leaderboard-usage-grouped-rpc.sql
+++ b/scripts/ops/leaderboard-usage-grouped-rpc.sql
@@ -62,7 +62,7 @@ SET hash_mem_multiplier TO '4'
AS $func$
WITH cfg AS (
-- Keep in sync with src/lib/source-metadata.js ACCOUNT_LEVEL_SOURCES.
- SELECT ARRAY['cursor', 'trae-cn']::text[] AS account_sources
+ SELECT ARRAY['cursor']::text[] AS account_sources
),
-- Hour-grain canonical rows. TWO passes total (DISTINCT + the per_usm GROUP BY)
-- so the whole-history 'total' refresh stays within the edge's 30s budget --
diff --git a/src/commands/status.js b/src/commands/status.js
index ddeb2821f..69b66d2fa 100644
--- a/src/commands/status.js
+++ b/src/commands/status.js
@@ -87,6 +87,13 @@ const {
resolveCopilotAppDbPaths,
probeWslDistros,
} = require("../lib/rollout");
+const {
+ TRAE_CN_USAGE_ENV,
+ isTraeCnUsageEnabled,
+ resolveTraeCnStoragePath,
+ readTraeCnAuthFromStorage,
+ extractTraeCnToken,
+} = require("../lib/trae-cn-config");
const wsl = require("../lib/wsl-probe");
const { getWslMode, isInvalidWslMode, shouldProbeWsl, discoverWslHome } = wsl;
const { resolveInstallPaths, resolveZcodeNativeDbPath } = require("../lib/install-resolver");
@@ -635,6 +642,27 @@ async function cmdStatus(argv = []) {
? readTraeEntitlementFromStorage(traeStoragePath)
: null;
+ // Trae SOLO CN — opt-in usage-API reader. Unlike the passive entitlement
+ // reader above, this source sends the locally stored sign-in JWT to TRAE's
+ // official API, so status surfaces the opt-in flag, the resolved storage
+ // path, and whether the auth blob decrypts — the three inputs a "why is my
+ // TRAE CN data missing" diagnosis needs.
+ const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
+ const traeCnInstalled = Boolean(traeCnStoragePath);
+ let traeCnAuthState = "not-signed-in";
+ if (traeCnInstalled) {
+ try {
+ const traeCnAuth = readTraeCnAuthFromStorage({ env: process.env, home, platform: process.platform });
+ if (traeCnAuth) {
+ extractTraeCnToken(traeCnAuth);
+ traeCnAuthState = "readable";
+ }
+ } catch (_error) {
+ traeCnAuthState = "malformed";
+ }
+ }
+ const traeCnUsageOptIn = isTraeCnUsageEnabled(process.env);
+
// Grok Build (xAI TUI)
const grokHookState = await probeGrokHookState({ home, trackerDir, env: process.env });
const grokSessions = grokHookState.hasGrokInstall || grokHookState.sessionsDir
@@ -923,6 +951,14 @@ async function cmdStatus(argv = []) {
...(traeEntitlement ? { entitlement: traeEntitlement } : {}),
}
: { installed: false },
+ "trae-cn": traeCnInstalled
+ ? {
+ installed: true,
+ detail: traeCnStoragePath,
+ auth: traeCnAuthState,
+ usage_opt_in: traeCnUsageOptIn,
+ }
+ : { installed: false },
grok_build: grokInstalled
? {
installed: true,
@@ -1092,6 +1128,9 @@ async function cmdStatus(argv = []) {
traeEntitlement
? `- Trae SOLO plan: ${formatTraeEntitlementLine(traeEntitlement)}`
: null,
+ traeCnInstalled
+ ? `- Trae SOLO CN: usage sync ${traeCnUsageOptIn ? "opted in" : `off (set ${TRAE_CN_USAGE_ENV}=1 to enable)`}, auth ${traeCnAuthState} (${traeCnStoragePath})`
+ : null,
...(() => {
if (!hermesInstalled) return [];
return [`- Hermes Agent: state.db found (${hermesPath})`];
diff --git a/src/commands/sync.js b/src/commands/sync.js
index d826921d0..416dd92bf 100644
--- a/src/commands/sync.js
+++ b/src/commands/sync.js
@@ -136,6 +136,7 @@ const { purgeProjectUsage } = require("../lib/project-usage-purge");
const {
fetchTraeCnUsageWithAuth,
resolveTraeCnStoragePath,
+ isTraeCnUsageEnabled,
} = require("../lib/trae-cn-config");
const {
isCodexSessionCursorPath,
@@ -1677,56 +1678,70 @@ async function cmdSync(argv, context = {}) {
}
// ── Trae Work CN (国内版) — account-level usage API ──
- // Phase C: a simple explicit rolling 30-day window captured once per sync
- // (no cursor checkpoints / full-history import / adaptive splitting / page
- // retries / background requests in this phase). Runs only for non-lightweight
- // syncs where the source is allowed AND the CN storage path resolves — which
- // excludes both ordinary background and `--auto --background
- // --all-local-sources`. Fetching goes through the storage-backed Phase A
- // helper so its single 401/403 reread/retry is used; an empty response is a
- // successful no-op; each window is bounded to 100 pages/2,000 rows and an
- // over-capacity window is split into staggered sub-windows (still over
- // capacity at the finest allowed granularity, or any other fetch/page/
- // schema/auth failure) skips the parser and leaves prior data untouched
- // while unrelated providers continue.
+ // A simple explicit rolling 30-day window captured once per sync (no
+ // cursor checkpoints / full-history import / page retries / background
+ // requests). Runs only when the user has opted in via
+ // TOKENTRACKER_TRAE_CN_USAGE=1 (the read transmits the locally stored
+ // sign-in JWT to TRAE's official endpoint, so it is never default-on),
+ // the sync is non-lightweight, the source is allowed, and the CN storage
+ // file exists — which excludes both ordinary background and `--auto
+ // --background --all-local-sources`. Fetching goes through the
+ // storage-backed helper so its single 401/403 reread/retry is used; an
+ // empty response is a successful no-op; each window is bounded to 100
+ // pages/2,000 rows and an over-capacity window is split into staggered
+ // sub-windows (still over capacity at the finest allowed granularity, or
+ // any other fetch/page/schema/auth failure) skips the parser and leaves
+ // prior data untouched while unrelated providers continue.
let traeCnResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
- const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
if (
!isBackgroundLightweightSync &&
- sourceAllowed("trae-cn") &&
- traeCnStoragePath &&
- fssync.existsSync(traeCnStoragePath)
+ isTraeCnUsageEnabled(process.env) &&
+ sourceAllowed("trae-cn")
) {
const nowMs = Number.isFinite(traeCnNowMs) ? traeCnNowMs : Date.now();
const fetchImpl = typeof traeCnFetchImpl === "function" ? traeCnFetchImpl : fetch;
const endTime = Math.floor(nowMs / 1000);
const startTime = Math.max(1, endTime - 30 * 24 * 60 * 60);
try {
- if (progress?.enabled) {
- progress.start(`Fetching TRAE Work CN usage...`);
- }
- const traeCnUsage = await fetchTraeCnUsageWithAuth({
- start_time: startTime,
- end_time: endTime,
- fetchImpl,
- env: process.env,
- home,
- });
- const traeCnSessions = Array.isArray(traeCnUsage?.sessions) ? traeCnUsage.sessions : [];
- if (traeCnSessions.length > 0) {
+ // Absent storage means "not signed in" — a silent skip, not an error.
+ const traeCnStoragePath = resolveTraeCnStoragePath({ env: process.env, home });
+ if (traeCnStoragePath && fssync.existsSync(traeCnStoragePath)) {
if (progress?.enabled) {
- progress.start(
- `Parsing TRAE Work CN ${renderBar(0)} 0/${formatNumber(
- traeCnSessions.length,
- )} records | buckets 0`,
- );
+ progress.start(`Fetching TRAE Work CN usage...`);
}
- traeCnResult = await parseTraeCnApiIncremental({
- sessions: traeCnSessions,
- cursors,
- queuePath,
- onProgress: makeProviderProgress("TRAE Work CN"),
+ const traeCnUsage = await fetchTraeCnUsageWithAuth({
+ start_time: startTime,
+ end_time: endTime,
+ fetchImpl,
+ env: process.env,
+ home,
});
+ const traeCnSessions = Array.isArray(traeCnUsage?.sessions) ? traeCnUsage.sessions : [];
+ if (traeCnSessions.length > 0) {
+ if (progress?.enabled) {
+ progress.start(
+ `Parsing TRAE Work CN ${renderBar(0)} 0/${formatNumber(
+ traeCnSessions.length,
+ )} records | buckets 0`,
+ );
+ }
+ traeCnResult = await parseTraeCnApiIncremental({
+ sessions: traeCnSessions,
+ cursors,
+ queuePath,
+ onProgress: makeProviderProgress("TRAE Work CN"),
+ windowStartMs: startTime * 1000,
+ });
+ // Row-level isolation means a malformed row is skipped, not fatal;
+ // surface the skip count even on auto runs (warnProviderParseFailure
+ // is silenced there) so it stays diagnosable.
+ const skipped = Number(traeCnResult?.skippedRows) || 0;
+ if (skipped > 0) {
+ process.stderr.write(
+ `TRAE Work CN sync: skipped ${skipped} malformed session row${skipped !== 1 ? "s" : ""}.\n`,
+ );
+ }
+ }
}
} catch (err) {
warnProviderParseFailure("TRAE Work CN", err, opts);
diff --git a/src/lib/rollout.js b/src/lib/rollout.js
index 32f126bb0..ab7646a72 100644
--- a/src/lib/rollout.js
+++ b/src/lib/rollout.js
@@ -16395,7 +16395,7 @@ const TRAE_CN_UNKNOWN_MODEL = "trae-cn-unknown";
function normalizeTraeCnState(raw) {
if (raw === undefined || raw === null) {
- return { version: TRAE_CN_STATE_VERSION, sessions: {}, updatedAt: null };
+ return { version: TRAE_CN_STATE_VERSION, sessions: {}, prunedBeforeMs: 0, updatedAt: null };
}
if (typeof raw !== "object" || Array.isArray(raw)) {
throw new Error("Trae CN cursor state is malformed.");
@@ -16409,6 +16409,8 @@ function normalizeTraeCnState(raw) {
return {
version: TRAE_CN_STATE_VERSION,
sessions: { ...raw.sessions },
+ prunedBeforeMs:
+ Number.isFinite(raw.prunedBeforeMs) && raw.prunedBeforeMs > 0 ? raw.prunedBeforeMs : 0,
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null,
};
}
@@ -16523,18 +16525,36 @@ function normalizeTraeCnSession(row) {
throw new Error("Trae CN session row has an invalid usage_time.");
}
const extra = traeCnExtraInfo(row);
+ // Cache fields may be absent for models without a prompt-cache concept
+ // (Doubao / DeepSeek on TRAE CN) — an absent cache field means "no cache
+ // activity", i.e. 0, NOT a malformed row. input/output stay mandatory.
+ //
+ // `input_token` is cache-INCLUSIVE: verifying real rows against TRAE CN's
+ // own credit billing, credits = fresh_input*p + cache_read*(p/4) + output*q
+ // fits every row with zero residual only under this reading (same
+ // convention as Codex / Qoder). Peel the cache subsets off into their own
+ // columns and report only the remainder as ordinary input, otherwise cached
+ // context is double-counted in dashboards and cost calculations. cache_write
+ // was 0 across all observed rows; it is treated as a further subset of the
+ // cache-inclusive input (clamped so the buckets always sum back to it).
+ const cacheRead = traeCnTokenField(row, extra, "cache_read_token");
+ const cacheWrite = traeCnTokenField(row, extra, "cache_write_token");
const fields = [
["input_token", traeCnTokenField(row, extra, "input_token")],
["output_token", traeCnTokenField(row, extra, "output_token")],
- ["cache_read_token", traeCnTokenField(row, extra, "cache_read_token")],
- ["cache_write_token", traeCnTokenField(row, extra, "cache_write_token")],
+ ["cache_read_token", cacheRead === undefined || cacheRead === null ? 0 : cacheRead],
+ ["cache_write_token", cacheWrite === undefined || cacheWrite === null ? 0 : cacheWrite],
];
for (const [label, value] of fields) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`Trae CN session row has an invalid ${label}.`);
}
}
- const total = fields[0][1] + fields[1][1] + fields[2][1] + fields[3][1];
+ const rawInput = fields[0][1];
+ const outputTokens = fields[1][1];
+ const cachedInput = Math.min(rawInput, fields[2][1]);
+ const cacheCreation = Math.min(rawInput - cachedInput, fields[3][1]);
+ const total = rawInput + outputTokens;
if (!Number.isSafeInteger(total)) {
throw new Error("Trae CN session row token totals overflow.");
}
@@ -16543,10 +16563,10 @@ function normalizeTraeCnSession(row) {
model,
bucketStart,
totals: {
- input_tokens: fields[0][1],
- output_tokens: fields[1][1],
- cached_input_tokens: fields[2][1],
- cache_creation_input_tokens: fields[3][1],
+ input_tokens: rawInput - cachedInput - cacheCreation,
+ output_tokens: outputTokens,
+ cached_input_tokens: cachedInput,
+ cache_creation_input_tokens: cacheCreation,
reasoning_output_tokens: 0,
total_tokens: total,
billable_total_tokens: total,
@@ -16574,7 +16594,13 @@ function assertTraeCnBucketCovers(bucketTotals, previousTotals) {
}
}
-async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgress } = {}) {
+async function parseTraeCnApiIncremental({
+ sessions,
+ cursors,
+ queuePath,
+ onProgress,
+ windowStartMs,
+} = {}) {
if (!Array.isArray(sessions)) {
throw new Error("Trae CN sessions must be an array.");
}
@@ -16596,9 +16622,23 @@ async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgr
}
// Full prevalidation + in-payload dedupe BEFORE any cursor/bucket mutation.
+ // Row-level schema failures are isolated per row: one malformed row (e.g. a
+ // negative or non-integer token count) is skipped and counted instead of
+ // discarding the whole payload — but structural failures (non-array
+ // sessions, unwritable cursors) and conflicting duplicate contributions
+ // still fail closed above/below.
const bySession = new Map();
+ let skippedRows = 0;
+ let firstSkipReason = "";
for (const row of sessions) {
- const normalized = normalizeTraeCnSession(row);
+ let normalized;
+ try {
+ normalized = normalizeTraeCnSession(row);
+ } catch (error) {
+ skippedRows += 1;
+ if (!firstSkipReason) firstSkipReason = error?.message || "unknown";
+ continue;
+ }
const existing = bySession.get(normalized.sessionId);
if (existing) {
const identical =
@@ -16612,6 +16652,13 @@ async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgr
}
bySession.set(normalized.sessionId, normalized);
}
+ if (bySession.size === 0 && skippedRows > 0) {
+ // Every row was malformed: fail closed rather than enqueue an empty,
+ // authoritative-looking snapshot that would read as "no usage".
+ throw new Error(
+ `Trae CN session rows are all malformed (${skippedRows} skipped; first: ${firstSkipReason}).`,
+ );
+ }
const touchedBuckets = new Set();
const cb = typeof onProgress === "function" ? onProgress : null;
@@ -16626,6 +16673,26 @@ async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgr
validateTraeCnStoredContribution(traeCnState.sessions[sessionId]);
}
+ // Monotonic prune (same watermark pattern as the kiroCli cap above): the
+ // sync caller fetches a fixed trailing window, so a session whose entire
+ // half-hour bucket sits before windowStartMs can never reappear in a later
+ // payload — its stored contribution is dead reconciliation weight. Prune
+ // only when the window start moved FORWARD past the persisted watermark:
+ // an entry evicted under window W1 and re-fetched under an earlier window
+ // would find previousEntry undefined and count twice. The +30min margin
+ // matches bucketStart's half-hour floor, which can trail the row's
+ // usage_time by up to 30 minutes.
+ if (Number.isFinite(windowStartMs) && windowStartMs > traeCnState.prunedBeforeMs) {
+ const pruneCutoffMs = windowStartMs - 30 * 60 * 1000;
+ for (const sessionId of Object.keys(traeCnState.sessions)) {
+ const bucketMs = Date.parse(traeCnState.sessions[sessionId].bucketStart);
+ if (Number.isFinite(bucketMs) && bucketMs <= pruneCutoffMs) {
+ delete traeCnState.sessions[sessionId];
+ }
+ }
+ traeCnState.prunedBeforeMs = windowStartMs;
+ }
+
// Deterministic: process unique session ids in sorted order.
const sessionIds = [...bySession.keys()].sort();
for (const sessionId of sessionIds) {
@@ -16678,7 +16745,7 @@ async function parseTraeCnApiIncremental({ sessions, cursors, queuePath, onProgr
cursors.hourly = hourlyState;
cursors.traeCn = traeCnState;
- return { recordsProcessed: total, eventsAggregated, bucketsQueued };
+ return { recordsProcessed: total, eventsAggregated, bucketsQueued, skippedRows };
}
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/src/lib/trae-cn-config.js b/src/lib/trae-cn-config.js
index 2aabcfc59..7379b8e19 100644
--- a/src/lib/trae-cn-config.js
+++ b/src/lib/trae-cn-config.js
@@ -21,10 +21,23 @@ const os = require("node:os");
const path = require("node:path");
const TRAE_CN_HOME_ENV = "TOKENTRACKER_TRAE_CN_HOME";
+// The TRAE CN usage read transmits the locally stored sign-in JWT to TRAE's
+// official endpoint, so it is strictly opt-in: nothing is sent unless the
+// user explicitly sets this flag. Any value other than 1/true keeps it off.
+const TRAE_CN_USAGE_ENV = "TOKENTRACKER_TRAE_CN_USAGE";
+
+function isTraeCnUsageEnabled(env = process.env) {
+ const value = env ? env[TRAE_CN_USAGE_ENV] : undefined;
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
+ return normalized === "1" || normalized === "true";
+}
const TRAE_CN_APP_DIR = "TRAE SOLO CN";
const TRAE_CN_AUTH_KEY = "iCubeAuthInfo://icube.cloudide";
const TRAE_CN_USAGE_URL = "https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session";
+// usage_type [7] mirrors the constant the official TRAE client sends when its
+// usage page queries this endpoint (observed in the client's outbound
+// requests; the enum is undocumented server-side). Other values unverified.
const TRAE_CN_USAGE_TYPE = [7];
const TRAE_CN_USAGE_PAGE_SIZE = 20;
const TRAE_CN_USAGE_MAX_PAGES = 100;
@@ -42,7 +55,11 @@ const TRAE_CN_AES_KEY_LEN = 16;
const TRAE_CN_IV_LEN = 16;
// JG / KG — the two halves of the hardcoded (obfuscated) password in the TRAE
-// client's byteCrypto.js. Changing these breaks every existing blob.
+// client's byteCrypto.js (XOR the pair to recover the KDF secret). Failure
+// signature when TRAE ships a new key: readTraeCnAuthFromStorage throws
+// "Trae CN auth payload is malformed", sync skips the source, and
+// `tokentracker status` reports auth "malformed" — re-extract the pair
+// from the updated client to restore decryption.
const TRAE_CN_JG = Buffer.from([
82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130,
155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61,
@@ -362,12 +379,17 @@ async function fetchTraeCnUsage({
for (;;) {
if (pagesFetched >= maxPages) {
// A further page would be needed but the cap prevents it: fail closed
- // rather than return a partial snapshot that looks authoritative.
- throw new Error("Trae CN usage pagination exceeded the maximum page count.");
+ // rather than return a partial snapshot that looks authoritative. Cap
+ // exhaustion is the same over-capacity signal as the declared-total
+ // check below (which the API may omit) — tag it identically so window
+ // splitting engages either way.
+ const error = new Error("Trae CN usage pagination exceeded the maximum page count.");
+ error.code = "TRAE_CN_USAGE_CAPACITY_EXCEEDED";
+ throw error;
}
const result = await pageFetcher({ jwt, start_time, end_time, page_num: page, page_size, fetchImpl });
pagesFetched += 1;
- total = result.total;
+ total = result.total ?? null;
if (pagesFetched === 1 && Number.isFinite(total) && total > page_size * maxPages) {
const error = new Error("Trae CN usage snapshot exceeds the supported capacity.");
error.code = "TRAE_CN_USAGE_CAPACITY_EXCEEDED";
@@ -454,6 +476,8 @@ async function fetchTraeCnUsageWithAuth({
module.exports = {
TRAE_CN_HOME_ENV,
+ TRAE_CN_USAGE_ENV,
+ isTraeCnUsageEnabled,
TRAE_CN_APP_DIR,
TRAE_CN_AUTH_KEY,
TRAE_CN_USAGE_URL,
diff --git a/test/account-source-parity.test.js b/test/account-source-parity.test.js
index da7d9256e..b88eb031b 100644
--- a/test/account-source-parity.test.js
+++ b/test/account-source-parity.test.js
@@ -5,12 +5,17 @@
// devices) is hardcoded in several places that MUST agree:
// - src/lib/source-metadata.js (authoritative, used by the CLI)
// - scripts/ops/account-usage-grouped-rpc.sql (account view RPC)
-// - scripts/ops/leaderboard-usage-grouped-rpc.sql (leaderboard RPC)
// - dashboard/edge-patches/tokentracker-leaderboard-profile.ts (profile edge)
// - dashboard/edge-patches/tokentracker-account-devices.ts (device breakdown edge)
// A drift (e.g. adding a new account-level source to source-metadata.js but
// forgetting the SQL) silently re-introduces the cross-device double-count bug
// that v0.44 fixed. This test fails loudly on any mismatch.
+//
+// NOT covered here: scripts/ops/leaderboard-usage-grouped-rpc.sql (SUPERSEDED
+// rollback reference — do not edit or verify) and the live
+// leaderboard_hourly_dedup_v2 function's account_sources array, which lives
+// only in the deployed database (maintainer-side; a PR adding an account-level
+// source must call it out in its deployment notes).
const fs = require("node:fs");
const path = require("node:path");
@@ -46,7 +51,7 @@ function extractSqlAccountSources(content) {
.sort();
}
-test("account-level source list is identical across source-metadata, both RPCs, and the profile edge", () => {
+test("account-level source list is identical across source-metadata, the account RPC, and the profile edge", () => {
const authoritative = extractJsSet(
readFile("src/lib/source-metadata.js"),
"ACCOUNT_LEVEL_SOURCES",
@@ -60,9 +65,6 @@ test("account-level source list is identical across source-metadata, both RPCs,
"account-usage-grouped-rpc.sql": extractSqlAccountSources(
readFile("scripts/ops/account-usage-grouped-rpc.sql"),
),
- "leaderboard-usage-grouped-rpc.sql": extractSqlAccountSources(
- readFile("scripts/ops/leaderboard-usage-grouped-rpc.sql"),
- ),
"tokentracker-leaderboard-profile.ts": extractJsSet(
readFile("dashboard/edge-patches/tokentracker-leaderboard-profile.ts"),
"ACCOUNT_LEVEL_SOURCES",
diff --git a/test/sync-trae-cn.test.js b/test/sync-trae-cn.test.js
index 4c6ab3357..9ec7673b0 100644
--- a/test/sync-trae-cn.test.js
+++ b/test/sync-trae-cn.test.js
@@ -61,6 +61,7 @@ async function withTempTraeEnv(fn) {
TOKENTRACKER_OPENCLAW_PREV_SESSION_ID: process.env.TOKENTRACKER_OPENCLAW_PREV_SESSION_ID,
TOKENTRACKER_OPENCLAW_SESSION_KEY: process.env.TOKENTRACKER_OPENCLAW_SESSION_KEY,
TOKENTRACKER_TRAE_CN_HOME: process.env.TOKENTRACKER_TRAE_CN_HOME,
+ TOKENTRACKER_TRAE_CN_USAGE: process.env.TOKENTRACKER_TRAE_CN_USAGE,
TOKENTRACKER_WSL_MODE: process.env.TOKENTRACKER_WSL_MODE,
};
try {
@@ -73,6 +74,7 @@ async function withTempTraeEnv(fn) {
process.env.XDG_DATA_HOME = path.join(home, ".local", "share");
process.env.TOKENTRACKER_OPENCLAW_HOME = path.join(home, ".openclaw");
process.env.TOKENTRACKER_TRAE_CN_HOME = traeCnHome;
+ process.env.TOKENTRACKER_TRAE_CN_USAGE = "1";
delete process.env.TOKENTRACKER_REASONIX_HOME;
delete process.env.REASONIX_STATE_HOME;
delete process.env.TOKENTRACKER_DEVICE_TOKEN;
@@ -230,6 +232,32 @@ test("background and all-local syncs never call the TRAE CN fetch", async () =>
});
});
+test("TRAE CN usage read stays off unless TOKENTRACKER_TRAE_CN_USAGE opts in", async () => {
+ await withTempTraeEnv(async ({ home }) => {
+ writeTraeCnStorage(path.join(home, "trae-cn-data"));
+ let fetchCalls = 0;
+ const fetchImpl = async () => {
+ fetchCalls += 1;
+ throw new Error("should not fetch");
+ };
+
+ const savedFlag = process.env.TOKENTRACKER_TRAE_CN_USAGE;
+ delete process.env.TOKENTRACKER_TRAE_CN_USAGE;
+ try {
+ await cmdSync(["--auto", "--source=trae-cn"], {
+ traeCnFetchImpl: fetchImpl,
+ traeCnNowMs: NOW_MS,
+ });
+ } finally {
+ if (savedFlag !== undefined) process.env.TOKENTRACKER_TRAE_CN_USAGE = savedFlag;
+ }
+
+ assert.equal(fetchCalls, 0, "no request without the opt-in flag");
+ assert.equal(readQueueRows(home).some((row) => row.source === "trae-cn"), false);
+ assert.equal(readCursors(home).traeCn, undefined);
+ });
+});
+
test("an absent TRAE CN storage file does not call the fetch", async () => {
await withTempTraeEnv(async ({ home }) => {
let fetchCalls = 0;
diff --git a/test/trae-cn-config.test.js b/test/trae-cn-config.test.js
index 36d73d508..ee762ff10 100644
--- a/test/trae-cn-config.test.js
+++ b/test/trae-cn-config.test.js
@@ -524,7 +524,13 @@ test("fetchTraeCnUsage fails closed when maxPages is reached and more is needed"
const { fetchImpl, requestedPages } = pagedFetch({ rowsPerPage: () => 20 });
await assert.rejects(
fetchTraeCnUsage({ jwt: JWT, start_time: START, end_time: END, fetchImpl, delayMs: 0, maxPages: 3 }),
- /exceeded the maximum page count/,
+ (error) => {
+ assert.match(error.message, /exceeded the maximum page count/);
+ // Same capacity tag as the declared-total path so window splitting
+ // engages even when the API omits total.
+ assert.equal(error.code, "TRAE_CN_USAGE_CAPACITY_EXCEEDED");
+ return true;
+ },
);
// Never returns a partial snapshot: all 3 cap pages were fetched, then it
// throws because page 4 would still be needed.
@@ -677,6 +683,38 @@ test("fetchTraeCnUsageWindowed fails closed at the split-depth ceiling", async (
assert.ok(requests.length > 1, "splitting was attempted");
});
+test("fetchTraeCnUsageWindowed splits when cap exhaustion signals capacity without a declared total", async () => {
+ const mid = START + Math.floor((END - START) / 2);
+ const fullPage = Array.from({ length: 20 }, (_, i) => ({ session_id: `row-${i}` }));
+ const { fetchImpl, requests } = windowedFetch((body) => {
+ if (body.start_time === START && body.end_time === END) {
+ // Full pages with total omitted: the ONLY over-capacity signal is
+ // maxPages exhaustion, which must carry the capacity code too.
+ return { user_usage_group_by_sessions: fullPage };
+ }
+ return { user_usage_group_by_sessions: [{ session_id: "ok" }], total: 1 };
+ });
+ const result = await fetchTraeCnUsageWindowed({
+ jwt: JWT,
+ start_time: START,
+ end_time: END,
+ fetchImpl,
+ delayMs: 0,
+ maxPages: 2,
+ });
+ assert.deepEqual(
+ requests.map((b) => [b.start_time, b.end_time]),
+ [
+ [START, END], // page 1
+ [START, END], // page 2 -> cap exhausted, split
+ [START, mid],
+ [mid + 1, END],
+ ],
+ );
+ assert.equal(result.sessions.length, 2);
+ assert.equal(result.total, 2);
+});
+
test("fetchTraeCnUsageWindowed rethrows non-capacity errors untouched", async () => {
const requests = [];
const fetchImpl = async (_url, options) => {
diff --git a/test/trae-cn-parser.test.js b/test/trae-cn-parser.test.js
index ba4b98baf..f067a817e 100644
--- a/test/trae-cn-parser.test.js
+++ b/test/trae-cn-parser.test.js
@@ -116,23 +116,51 @@ test("first insert maps direct and extra_info shapes into canonical buckets", as
billable: pro.billable_total_tokens,
conv: pro.conversation_count,
},
- { input: 100, cached: 30, cacheCreation: 40, output: 10, reasoning: 0, total: 180, billable: 180, conv: 1 },
+ // input_token is cache-inclusive: fresh = 100-30-40, total = 100+10.
+ { input: 30, cached: 30, cacheCreation: 40, output: 10, reasoning: 0, total: 110, billable: 110, conv: 1 },
);
assert.equal(lite.hour_start, B2);
- assert.equal(lite.input_tokens, 5);
+ // Clamped to the cache-inclusive input: cached=min(5,7)=5, creation
+ // gets no room left (min(0,8)=0), fresh=0, total=5+6.
+ assert.equal(lite.input_tokens, 0);
assert.equal(lite.output_tokens, 6);
- assert.equal(lite.cached_input_tokens, 7);
- assert.equal(lite.cache_creation_input_tokens, 8);
- assert.equal(lite.total_tokens, 26);
+ assert.equal(lite.cached_input_tokens, 5);
+ assert.equal(lite.cache_creation_input_tokens, 0);
+ assert.equal(lite.total_tokens, 11);
assert.equal(cursors.traeCn.version, 1);
assert.equal(cursors.traeCn.sessions["s-direct"].model, "doubao-pro");
assert.equal(cursors.traeCn.sessions["s-direct"].bucketStart, B1);
- assert.equal(cursors.traeCn.sessions["s-direct"].totals.input_tokens, 100);
+ assert.equal(cursors.traeCn.sessions["s-direct"].totals.input_tokens, 30);
assert.equal(cursors.traeCn.sessions["s-extra"].bucketStart, B2);
});
+test("cache-inclusive input_token is peeled into subset columns (real-data shape)", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {};
+ // Shape observed on real GLM rows: every prompt token hit the cache, so
+ // input_token equals cache_read_token exactly (ratio floor 1.0 across 135
+ // real rows). Ordinary input must be 0, not input_token again.
+ const fullHit = sessionRow({ session_id: "full-hit", input_token: 500, output_token: 7, cache_read_token: 500 });
+ // Partial hit: 200 of 500 cached -> fresh 300, total = 500 + 7.
+ const partial = sessionRow({ session_id: "partial", input_token: 500, output_token: 7, cache_read_token: 200 });
+ // No cache activity at all (Doubao/DeepSeek shape): columns stay as-is.
+ const noCache = sessionRow({ session_id: "no-cache", input_token: 500, output_token: 7 });
+
+ await parseTraeCnApiIncremental({ sessions: [fullHit, partial, noCache], cursors, queuePath });
+ assert.equal(cursors.traeCn.sessions["full-hit"].totals.input_tokens, 0);
+ assert.equal(cursors.traeCn.sessions["full-hit"].totals.cached_input_tokens, 500);
+ assert.equal(cursors.traeCn.sessions["full-hit"].totals.total_tokens, 507);
+ assert.equal(cursors.traeCn.sessions["partial"].totals.input_tokens, 300);
+ assert.equal(cursors.traeCn.sessions["partial"].totals.cached_input_tokens, 200);
+ assert.equal(cursors.traeCn.sessions["partial"].totals.total_tokens, 507);
+ assert.equal(cursors.traeCn.sessions["no-cache"].totals.input_tokens, 500);
+ assert.equal(cursors.traeCn.sessions["no-cache"].totals.cached_input_tokens, 0);
+ assert.equal(cursors.traeCn.sessions["no-cache"].totals.total_tokens, 507);
+});
+
test("identical payload twice appends no queue row and no token growth", async (t) => {
const { dir, queuePath } = tempQueue();
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
@@ -281,20 +309,45 @@ test("conflicting duplicate ids and malformed rows fail before any mutation", as
assert.equal(cursors.traeCn, undefined, "no traeCn state assigned");
assert.equal(fs.existsSync(queuePath), false, "no queue rows written");
- // Malformed rows: missing token field must not be coerced to zero.
+ // Cache fields are optional: models without a prompt-cache concept (Doubao /
+ // DeepSeek on TRAE CN) legitimately omit them — absent means 0, not malformed.
+ // Uses its own queue dir so the no-mutation assertions below stay meaningful.
+ const optionalQueue = tempQueue();
+ const cursorsOptional = {};
const missing = sessionRow({ session_id: canary });
delete missing.cache_read_token;
- await assert.rejects(
- parseTraeCnApiIncremental({ sessions: [missing], cursors, queuePath }),
- (error) => {
- assert.match(error.message, /invalid cache_read_token/);
- assert.ok(!error.message.includes(canary));
- return true;
- },
- );
+ delete missing.cache_write_token;
+ const optionalResult = await parseTraeCnApiIncremental({
+ sessions: [missing],
+ cursors: cursorsOptional,
+ queuePath: optionalQueue.queuePath,
+ });
+ assert.equal(optionalResult.skippedRows, 0);
+ assert.equal(cursorsOptional.traeCn.sessions[canary].totals.cached_input_tokens, 0);
+ fs.rmSync(optionalQueue.dir, { recursive: true, force: true });
+
+ // A malformed row is isolated: skipped and counted, the rest still imports.
+ const mixedQueue = tempQueue();
+ const cursorsMixed = {};
+ const mixedResult = await parseTraeCnApiIncremental({
+ sessions: [
+ sessionRow({ session_id: canary, cache_read_token: "lots" }),
+ sessionRow({ session_id: "healthy-row" }),
+ ],
+ cursors: cursorsMixed,
+ queuePath: mixedQueue.queuePath,
+ });
+ assert.equal(mixedResult.skippedRows, 1);
+ assert.equal(cursorsMixed.traeCn.sessions[canary], undefined, "bad row never imported");
+ assert.ok(cursorsMixed.traeCn.sessions["healthy-row"], "healthy row imported");
+ fs.rmSync(mixedQueue.dir, { recursive: true, force: true });
+
+ // A payload where EVERY row is malformed fails closed (never an empty,
+ // authoritative-looking snapshot) and the reason stays diagnosable + id-free.
await assert.rejects(
parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, cache_read_token: "lots" })], cursors, queuePath }),
(error) => {
+ assert.match(error.message, /all malformed/);
assert.match(error.message, /invalid cache_read_token/);
assert.ok(!error.message.includes(canary));
return true;
@@ -304,6 +357,7 @@ test("conflicting duplicate ids and malformed rows fail before any mutation", as
await assert.rejects(
parseTraeCnApiIncremental({ sessions: [sessionRow({ session_id: canary, model_name: "bad|model" })], cursors, queuePath }),
(error) => {
+ assert.match(error.message, /all malformed/);
assert.match(error.message, /unsupported model name/);
assert.ok(!error.message.includes(canary));
return true;
@@ -502,3 +556,44 @@ test("an unrelated malformed stored session fails a new valid payload before mut
assert.equal(JSON.stringify(cursors), before, "no mutation on malformed stored state");
assert.equal(fs.existsSync(queuePath), false, "no queue rows written");
});
+
+test("window prune drops pre-window sessions monotonically and never rewinds", async (t) => {
+ const { dir, queuePath } = tempQueue();
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const cursors = {};
+ await parseTraeCnApiIncremental({
+ sessions: [
+ sessionRow({ session_id: "old-s" }), // bucket B1 = 22:00
+ sessionRow({ session_id: "new-s", usage_time: T2 }), // bucket B2 = 22:30
+ ],
+ cursors,
+ queuePath,
+ });
+ assert.ok(cursors.traeCn.sessions["old-s"]);
+ assert.ok(cursors.traeCn.sessions["new-s"]);
+ assert.equal(cursors.traeCn.prunedBeforeMs, 0, "no prune before a window is supplied");
+
+ // Window starts at 22:33:20 (T1 + 20min): old-s's whole bucket (22:00) is
+ // before it and can never reappear; new-s (22:46:40, bucket 22:30) is in-window.
+ const windowStartMs = (T1 + 20 * 60) * 1000;
+ await parseTraeCnApiIncremental({
+ sessions: [sessionRow({ session_id: "new-s", usage_time: T2, output_token: 20 })],
+ cursors,
+ queuePath,
+ windowStartMs,
+ });
+ assert.equal(cursors.traeCn.sessions["old-s"], undefined, "pre-window entry pruned");
+ assert.ok(cursors.traeCn.sessions["new-s"], "in-window entry kept");
+ assert.equal(cursors.traeCn.prunedBeforeMs, windowStartMs);
+
+ // An earlier window start must not rewind the watermark or re-prune; a
+ // re-reported in-window session still reconciles against its stored entry.
+ await parseTraeCnApiIncremental({
+ sessions: [sessionRow({ session_id: "new-s", usage_time: T2 })],
+ cursors,
+ queuePath,
+ windowStartMs: (T1 + 60) * 1000,
+ });
+ assert.equal(cursors.traeCn.prunedBeforeMs, windowStartMs, "watermark never rewinds");
+ assert.ok(cursors.traeCn.sessions["new-s"]);
+});
From 2b4abc7cc4bf19748f5b741ccbcd745cc8a9076f Mon Sep 17 00:00:00 2001
From: KunWei <2088220700@qq.com>
Date: Mon, 17 Aug 2026 01:14:55 +0800
Subject: [PATCH 08/19] =?UTF-8?q?feat:=20TRAE=20Work=20CN=20=E9=87=87?=
=?UTF-8?q?=E7=94=A8=E5=AE=98=E6=96=B9=E6=A0=87=E8=AF=86=E4=B8=8E=E7=BB=9F?=
=?UTF-8?q?=E4=B8=80=E5=93=81=E7=89=8C=E5=90=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 图标改为官方 app 图标样式(白色圆角方 + 黑色面具图形,两个菱形眼),
从 work.trae.cn 官方 PWA 图标逐像素复刻,固定黑白配色适配深浅主题
- zh/zh-TW 显示名统一为品牌名 TRAE Work CN(与其他语言及 copy.csv 一致)
---
dashboard/src/content/i18n/zh-TW/core.json | 2 +-
dashboard/src/content/i18n/zh/core.json | 2 +-
.../ui/dashboard/components/ProviderIcon.jsx | 25 +++++++++++++------
.../components/ProviderIcon.test.jsx | 4 ++-
4 files changed, 23 insertions(+), 10 deletions(-)
diff --git a/dashboard/src/content/i18n/zh-TW/core.json b/dashboard/src/content/i18n/zh-TW/core.json
index bb0f5a409..5b9aa5e4c 100644
--- a/dashboard/src/content/i18n/zh-TW/core.json
+++ b/dashboard/src/content/i18n/zh-TW/core.json
@@ -154,7 +154,7 @@
"limits.provider.copilot": "GitHub Copilot",
"provider.display.omp": "oh-my-pi",
"provider.display.deepseek_harness": "DeepSeek Harness",
- "provider.display.trae_work_cn": "TRAE Work 中國版",
+ "provider.display.trae_work_cn": "TRAE Work CN",
"limits.provider.antigravity": "Antigravity",
"limits.provider.zcode": "ZCode",
"limits.provider.opencode_go": "OpenCode Go",
diff --git a/dashboard/src/content/i18n/zh/core.json b/dashboard/src/content/i18n/zh/core.json
index c7b029a29..400a69e59 100644
--- a/dashboard/src/content/i18n/zh/core.json
+++ b/dashboard/src/content/i18n/zh/core.json
@@ -154,7 +154,7 @@
"limits.provider.copilot": "GitHub Copilot",
"provider.display.omp": "oh-my-pi",
"provider.display.deepseek_harness": "DeepSeek Harness",
- "provider.display.trae_work_cn": "TRAE Work 中国版",
+ "provider.display.trae_work_cn": "TRAE Work CN",
"limits.provider.antigravity": "Antigravity",
"limits.provider.zcode": "ZCode",
"limits.provider.opencode_go": "OpenCode Go",
diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx
index f419d1bc4..aa6edf987 100644
--- a/dashboard/src/ui/dashboard/components/ProviderIcon.jsx
+++ b/dashboard/src/ui/dashboard/components/ProviderIcon.jsx
@@ -117,22 +117,33 @@ function CodeBuddyIcon({ size = 16, className = "" }) {
);
}
-// Compact local monogram for TRAE Work CN. It is deliberately not a claimed
-// official asset: the single-color mark stays legible in the existing 16–20px
-// slots and inherits currentColor in both themes.
+// TRAE Work CN — official app-icon mark (black mask with two diamond eyes
+// and the notched lower-left corner on a white rounded square), traced from
+// the TRAE SOLO CN desktop app icon. Like the Craft mark, the fixed
+// white/black palette reads on both light and dark themes and therefore does
+// not adapt to currentColor.
function TraeCnIcon({ size = 16, className = "" }) {
return (
-
-
+
+
+
+
+
+
+
+
);
}
diff --git a/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx b/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx
index f8e4dc2a1..f2a493316 100644
--- a/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx
+++ b/dashboard/src/ui/dashboard/components/ProviderIcon.test.jsx
@@ -86,7 +86,9 @@ describe("ProviderIcon", () => {
expect(icon).toHaveAttribute("width", "20");
expect(icon).toHaveAttribute("height", "20");
expect(icon).toHaveAttribute("aria-hidden", "true");
- expect(icon).toHaveAttribute("fill", "currentColor");
+ // Official app-icon palette: white rounded square + black mask, fixed
+ // colors instead of currentColor (same policy as the Craft mark).
+ expect(icon?.querySelector("rect")).not.toBeNull();
expect(icon?.querySelector("path")).not.toBeNull();
expect(icon?.querySelector("circle")).toBeNull();
expect(container.querySelector(".text-oai-gray-400")).toBeNull();
From 61366ec8e57d8f7672e01fa84953325fb81759dc Mon Sep 17 00:00:00 2001
From: KunWei <2088220700@qq.com>
Date: Mon, 17 Aug 2026 01:15:45 +0800
Subject: [PATCH 09/19] =?UTF-8?q?docs:=20TRAE=20CN=20=E5=90=8C=E6=AD=A5?=
=?UTF-8?q?=E9=AA=8C=E8=AF=81=E6=88=AA=E5=9B=BE=EF=BC=88=E7=9C=9F=E5=AE=9E?=
=?UTF-8?q?=E8=B4=A6=E6=88=B7=2030=20=E5=A4=A9=E6=95=B0=E6=8D=AE=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/screenshots/trae-cn-sync-verification.png | Bin 0 -> 141011 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 docs/screenshots/trae-cn-sync-verification.png
diff --git a/docs/screenshots/trae-cn-sync-verification.png b/docs/screenshots/trae-cn-sync-verification.png
new file mode 100644
index 0000000000000000000000000000000000000000..7ddd55e27e19107ddeadcaf94845d5e996b456ed
GIT binary patch
literal 141011
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