Forwards Claude Code session events to the CC-Track tracker from inside Claude Code, replacing the settings-hook forwarder.

A Next.js 16 + Supabase app that records everything you do in Claude Code: every session, the project it ran in, the plans you create, the tasks that get executed, tool usage, prompts, tokens and estimated cost, plus workflow analytics over all of it.
┌────────────────┐ hooks (stdin JSON) ┌──────────────┐ service key ┌───────────┐
│ Claude Code │ ──▶ claude-tracker.mjs ─▶│ Next.js API │ ──────────────▶│ Supabase |
│ (your mac/pc) │ ──▶ cctrack CLI ────────▶│ /api/ingest │ └───────────┘
└────────────────┘ └──────────────┘
Data model: projects (auto-created per working directory) → sessions (PK = Claude's session UUID) → plans → tasks, plus a raw events log (prompts, tool calls, TodoWrite syncs) used for the analytics.
Overview: totals, 14-day activity, recent sessions, active plans and open tasks at a glance. ![]()
Tasks: filter by project/status; the Attend button queues a remote run for the local agent to pick up. ![]()
Live: real-time feed of task runs and session events, two lanes, filterable and pausable. ![]()
Analytics: activity, tokens & cost per day, tool usage, session durations, hour-of-day prompting. ![]()
Sessions: every session captured by the hooks, with tokens, cost and status. ![]()
Setting this up on a new machine? SETUP_GUIDE.md walks through Supabase, the three required keys, running the app and a tour of every route, in more depth than the quick-start below.
supabase/schema.sql → Run.sb_secret_..., labeled service_role on older projects).cp .env.example .env.local # fill in the three values
npm install
npm run dev # http://localhost:3000
Check http://localhost:3000/api/health: both flags should be true. The /setup page shows live status + all instructions in-app.
./start.sh)If you'd rather not leave a dev server running, ./start.sh runs next start and shuts it down when no open+visible browser tab has been seen for IDLE_TIMEOUT seconds. The root layout pings /api/heartbeat every 10s while document.visibilityState === "visible", so a closed tab, a switched-away tab, or a minimized window all count as idle. Once the hooks are wired (section 3), you rarely have to run this manually: the SessionStart / UserPromptSubmit hook probes /api/health and boots start-hidden.vbs for you when the tracker is down, and every hook fire touches .heartbeat client-side so the idle timer resets on agent activity even with no browser tab open.
npm run build
./start.sh # uses IDLE_TIMEOUT from .env.local, default 60s
IDLE_TIMEOUT=300 ./start.sh # per-run override wins over .env.local
Set IDLE_TIMEOUT in .env.local (see .env.example) to change the default.
Windows: double-click to run invisibly. start-hidden.vbs launches ./start.sh through Git Bash with no window at all. Double-click it (or pin/shortcut it) and the server runs in the background; it exits on its own when IDLE_TIMEOUT is reached. If your Git for Windows lives somewhere other than C:\Program Files\Git\, edit the one path inside the .vbs. To stop it manually before it idles out, kill the node.exe process in Task Manager.
Note on tab behavior. When the server exits the browser tab stays open (browsers block page JS from closing user-opened tabs, by design); you'll just see ERR_CONNECTION_REFUSED on the next request. Re-run ./start.sh or double-click the .vbs and refresh.
node hooks/install.mjs --url http://localhost:3000 --key <CC_TRACKER_API_KEY>
This writes ~/.cc-track/config.json and prints a snippet to merge into ~/.claude/settings.json:
{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "node /ABS/PATH/cc-track/hooks/claude-tracker.mjs", "async": true } ] }
],
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "node /ABS/PATH/cc-track/hooks/claude-tracker.mjs", "async": true } ] }
],
"PostToolUse": [
{ "matcher": "*", "hooks": [ { "type": "command", "command": "node /ABS/PATH/cc-track/hooks/claude-tracker.mjs", "async": true } ] }
],
"Stop": [
{ "hooks": [ { "type": "command", "command": "node /ABS/PATH/cc-track/hooks/claude-tracker.mjs", "async": true } ] }
],
"StopFailure": [
{ "hooks": [ { "type": "command", "command": "node /ABS/PATH/cc-track/hooks/claude-tracker.mjs", "async": true } ] }
],
"SubagentStart": [
{ "hooks": [ { "type": "command", "command": "node /ABS/PATH/cc-track/hooks/claude-tracker.mjs", "async": true } ] }
],
"SubagentStop": [
{ "hooks": [ { "type": "command", "command": "node /ABS/PATH/cc-track/hooks/claude-tracker.mjs", "async": true } ] }
],
"Notification": [
{ "hooks": [ { "type": "command", "command": "node /ABS/PATH/cc-track/hooks/claude-tracker.mjs", "async": true } ] }
],
"SessionEnd": [
{ "hooks": [ { "type": "command", "command": "node /ABS/PATH/cc-track/hooks/claude-tracker.mjs", "async": true } ] }
]
}
}
This is exactly what node hooks/install.mjs prints, so running the installer (above) is the recommended way to get this snippet, rather than copying it from here by hand. All nine events are wired by default (async: true so none of them block Claude Code), and every one is written to the events table. Any hook event not in the table below (or a future one Claude Code adds) still gets logged, generically, through a catch-all case.
What gets captured automatically:
| Hook | Captured |
|---|---|
SessionStart | new session row, project (from cwd), source (startup/resume/clear), git branch, repo written onto the project |
UserPromptSubmit | prompt count incremented, session title set from the first prompt, raw prompt event (truncated to 4000 chars) |
PostToolUse | every tool call (name + trimmed input/response + tool_use_id), running tool-use count and per-tool breakdown; TodoWrite calls sync into tasks instead of being logged as a plain tool event |
Stop | transcript summary: model, input/output/cache-read/cache-creation tokens, tool-use count + breakdown, estimated cost, last assistant message (truncated), session marked ended |
StopFailure | error type + message, recorded both as the session's last error and as a stop_failure event |
SubagentStart | agent_type and agent_id of the spawned subagent |
SubagentStop | agent_type, agent_id, and the subagent's last assistant message (truncated to 4000 chars) |
Notification | notification type and message text |
SessionEnd | session status set to ended, ended_at timestamp, end reason |
The hook script fails silently and exits 0; it can never block Claude Code.
cc-track mod (no settings hooks)On Claude Code builds with function-hook plugins ("mods"), mods/cc-track does the forwarder's job from inside Claude Code: the engine hands it each event's payload (the same JSON a settings hook gets on stdin) and it POSTs to /api/ingest/hook. No node process is spawned per event, events arrive in order, and it reuses ~/.cc-track/config.json.
It also sends one thing settings hooks cannot: a SessionUsage event after any turn that moved the session's cost or a rate-limit window (5-hour / 7-day %), stored as a session_usage event. Stop payloads also carry usage (cost_usd, rate_limits). The newest reading shows at the foot of the sidebar (and the mobile menu): 5h / 7d limit bars with reset times and the session cost, muted once it is over 6 hours old. Nothing shows until the mod has sent one.
One command does the setup below (backs up settings.json first):
node hooks/install.mjs --mod # reuses ~/.cc-track/config.json; add --url/--key on a fresh machine
By hand: load it from this checkout, so the .heartbeat / start-hidden.vbs paths resolve. In ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/ABS/PATH/cc-track/mods/cc-track" } }
(or claude --plugin-dir /ABS/PATH/cc-track/mods/cc-track for one session). Then remove the claude-tracker.mjs entries from your settings hooks; keep the HITL PreToolUse one. While claude-tracker.mjs is still wired, the mod stays idle and shows a toast, so events are never double-counted. Tests: claude plugin test mods/cc-track.
cctrack CLInpm link # exposes `cctrack` globally (from this folder)
cctrack plan add --title "Refactor auth to JWT" --desc "optional context"
cctrack task add --plan <plan-id> --content "Write migration"
cctrack task start <task-id>
cctrack task done <task-id>
cctrack plan done <plan-id>
cctrack session current # session the hooks last saw
cctrack session end
The CLI targets the current session automatically (hooks keep ~/.cc-track/current-session.json in sync), or pass --session <uuid>.
Let Claude do the bookkeeping: paste CLAUDE.md.snippet into your project's CLAUDE.md and Claude will create a plan at the start of each piece of work and keep task statuses updated as it executes them. This repo's own CLAUDE.md codifies the same expectation as a binding rule for any agent working inside cc-track, so /plans and /tasks stay in sync with what Claude actually does here.
Every task on /tasks has an Attend button; clicking it queues a task_runs row with a prompt built from the task (plus its plan and its project's path). Runs only execute once you also have a local agent process running:
npm run agent # node --env-file=.env.local --import tsx bin/agent.mts
This process polls for queued runs, claims one, and shells out to claude -p <prompt> with cwd set to the project's path on disk, so the child's own hooks still feed the dashboard. As it runs, the row's status moves from claimed to running to done, error or cancelled, visible live on /tasks and /live. When it finishes, the run records the child's Claude session id, cost and token usage; if the project is a git repo, a short verifier pass then grades the diff and stores a verdict (pass, fail, needs review), and the task is auto-completed unless the verdict says otherwise.
/api/ingest/* requires x-api-key: $CC_TRACKER_API_KEY.npm run test:hooks # transcript parser unit tests (node)
npx tsx tests/lib.test.mts # aggregation helper tests
npm run build # typecheck + production build
Licensed under the Apache License 2.0. See LICENSE.
hooks/register.ts 197 lines1// CC-Track forwarder as a Claude Code mod.
2//
3// Does what hooks/claude-tracker.mjs does, but in-process: the engine raises
4// each classic hook event here (same stdin payload a settings hook gets), and
5// we POST it to /api/ingest/hook. No node process per event, no settings.json
6// wiring. Posts are chained so the tracker sees events in order, and never
7// block Claude Code: only SessionEnd waits, so the queue drains before exit.
8//
9// Config is shared with the settings hooks: CC_TRACK_URL / CC_TRACK_KEY, else
10// ~/.cc-track/config.json (written by hooks/install.mjs).
11
12import type { EngineInterface, Register } from 'claude-code'
13import { summarizeTranscriptText } from './transcript.mjs'
14
15type Engine = EngineInterface
16type Config = { url: string; key: string }
17type GitInfo = { git_branch: string | null; repo: string | null }
18type Payload = Record<string, unknown> & {
19 hook_event_name: string
20 session_id?: string
21 cwd?: string
22 transcript_path?: string
23}
24
25const FORWARDED = new Set([
26 'SessionStart',
27 'UserPromptSubmit',
28 'PostToolUse',
29 'Stop',
30 'StopFailure',
31 'SubagentStart',
32 'SubagentStop',
33 'Notification',
34 'SessionEnd',
35])
36// Re-probe the tracker (and boot it) where the settings hook does.
37const PROBED = new Set(['SessionStart', 'UserPromptSubmit'])
38
39const join = (...parts: string[]) => parts.join('/').replace(/[\\/]+/g, '/')
40
41// Module state: a hot reload starts it over, which is what we want.
42let config: Promise<Config | null> | undefined
43let duplicate: Promise<boolean> | undefined
44const git = new Map<string, Promise<GitInfo>>()
45let queue: Promise<void> = Promise.resolve()
46
47async function home($: Engine) {
48 return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
49}
50
51async function loadConfig($: Engine): Promise<Config | null> {
52 let url = await $.env.get('CC_TRACK_URL')
53 let key = await $.env.get('CC_TRACK_KEY')
54 if (!url || !key) {
55 try {
56 const file = JSON.parse(await $.fs.read(join(await home($), '.cc-track', 'config.json')))
57 url ??= file.url
58 key ??= file.key
59 } catch { /* no config file */ }
60 }
61 return url && key ? { url, key } : null
62}
63
64// The settings-hook forwarder still wired in settings.json would send every
65// event twice: stand down and say so once.
66async function settingsForwarderWired($: Engine) {
67 try {
68 const text = JSON.stringify((await $.settings.read()).hooks ?? {})
69 if (!text.includes('claude-tracker.mjs')) return false
70 $.ui.toast('cc-track: claude-tracker.mjs is still in settings.json hooks; the mod is idle until you remove it.')
71 return true
72 } catch {
73 return false
74 }
75}
76
77async function gitOut($: Engine, cwd: string, args: string[]) {
78 try {
79 const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 1500 })
80 return r.exitCode === 0 ? r.stdout.trim() || null : null
81 } catch {
82 return null
83 }
84}
85
86async function gitInfo($: Engine, cwd: string): Promise<GitInfo> {
87 return {
88 git_branch: await gitOut($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD']),
89 repo: await gitOut($, cwd, ['remote', 'get-url', 'origin']),
90 }
91}
92
93// The repo's .heartbeat keeps start.sh's idle timer alive. Only meaningful
94// when the mod is loaded from the repo checkout (mods/cc-track).
95function repoRoot($: Engine) {
96 return join($.plugin.root, '..', '..')
97}
98
99async function touchHeartbeat($: Engine) {
100 try {
101 await $.fs.write(join(repoRoot($), '.heartbeat'), '')
102 } catch { /* best effort */ }
103}
104
105async function ensureTrackerUp($: Engine, cfg: Config) {
106 try {
107 if ((await $.http.fetch(new URL('/api/health', cfg.url).toString())).ok) return
108 } catch { /* down, boot below */ }
109 // Windows-first, like the settings hook: start-hidden.vbs boots start.sh.
110 if ((await $.env.get('OS')) !== 'Windows_NT') return
111 const vbs = join(repoRoot($), 'start-hidden.vbs')
112 if (!(await $.fs.exists(vbs))) return
113 try {
114 await $.process.run(['wscript.exe', vbs], { cwd: repoRoot($), timeoutMs: 5000 })
115 } catch { /* best effort */ }
116}
117
118async function forward($: Engine, payload: Payload) {
119 config ??= loadConfig($)
120 duplicate ??= settingsForwarderWired($)
121 const cfg = await config
122 if (!cfg || (await duplicate)) return
123
124 await touchHeartbeat($)
125 if (PROBED.has(payload.hook_event_name)) await ensureTrackerUp($, cfg)
126
127 const cwd = payload.cwd || (await $.session.cwd())
128 payload.cwd = cwd
129 if (!git.has(cwd)) git.set(cwd, gitInfo($, cwd))
130 Object.assign(payload, await git.get(cwd))
131
132 if (payload.hook_event_name === 'Stop') {
133 if (payload.transcript_path) {
134 try {
135 payload.summary = summarizeTranscriptText(await $.fs.read(payload.transcript_path))
136 } catch { /* unreadable, or over the 4 MiB read cap */ }
137 }
138 // Engine-side figures the transcript lacks: /cost total and the account's
139 // rate-limit windows. Ignored by the tracker until it stores them.
140 try {
141 const { cost, rateLimits } = await $.session.usage()
142 payload.usage = { cost_usd: cost?.usd ?? null, rate_limits: rateLimits }
143 } catch { /* no ledger */ }
144 }
145
146 // Let the `cctrack` CLI target the current session.
147 if (payload.session_id) {
148 try {
149 await $.fs.write(
150 join(await home($), '.cc-track', 'current-session.json'),
151 JSON.stringify({ session_id: payload.session_id, cwd, updated_at: new Date().toISOString() }),
152 )
153 } catch { /* ignore */ }
154 }
155
156 try {
157 await $.http.fetch(new URL('/api/ingest/hook', cfg.url).toString(), {
158 method: 'POST',
159 headers: { 'content-type': 'application/json', 'x-api-key': cfg.key },
160 body: JSON.stringify(payload),
161 })
162 } catch { /* tracker unreachable: never surface it */ }
163}
164
165function enqueue($: Engine, payload: Payload) {
166 queue = queue.then(() => forward($, payload)).catch(() => {})
167 return queue
168}
169
170export const register: Register = on => {
171 on('classic.*', ($, e, next) => {
172 const name = (e as { hook_event_name?: string }).hook_event_name
173 if (!name || !FORWARDED.has(name)) return next(e)
174 const drained = enqueue($, { ...(e as object), hook_event_name: name } as Payload)
175 return name === 'SessionEnd' ? drained.then(() => next(e)) : next(e)
176 })
177
178 // Mod-only: the engine pushes its usage figures after each turn. Forward the
179 // ones that matter for spend (cost grew, a rate-limit window moved), not
180 // every context-fill change.
181 on('session.measure', async ($, e, next) => {
182 if (e.changed.includes('cost') || e.changed.includes('rateLimits')) {
183 const { tokens, window, percent } = e.context
184 void enqueue($, {
185 hook_event_name: 'SessionUsage',
186 session_id: await $.session.id(),
187 usage: {
188 cost_usd: e.cost?.usd ?? null,
189 rate_limits: e.rateLimits,
190 context: { tokens, window, percent },
191 },
192 })
193 }
194 return next(e)
195 })
196}
197hooks/transcript.mjs 70 lines1// Pure transcript-summary logic, kept dependency-free and importable so it
2// can be unit-tested (see hooks/test.mjs). Must stay Node-free: the cc-track
3// mod loads it in the hooks sandbox (no fs, no process).
4
5/**
6 * Summarize a Claude Code JSONL transcript.
7 * @param {string} text raw JSONL file contents
8 * @returns {object} summary sent to the tracker on the Stop hook
9 */
10export function summarizeTranscriptText(text) {
11 const out = {
12 input_tokens: 0,
13 output_tokens: 0,
14 cache_read_tokens: 0,
15 cache_creation_tokens: 0,
16 prompt_count: 0,
17 tool_use_count: 0,
18 tools: {},
19 model: null,
20 };
21 const modelCounts = {};
22
23 for (const line of text.split("\n")) {
24 const trimmed = line.trim();
25 if (!trimmed) continue;
26 let row;
27 try {
28 row = JSON.parse(trimmed);
29 } catch {
30 continue;
31 }
32 const msg = row?.message;
33 if (!msg) continue;
34 const isSidechain = row.isSidechain === true;
35
36 if (row.type === "assistant") {
37 const u = msg.usage;
38 if (u) {
39 out.input_tokens += u.input_tokens ?? 0;
40 out.output_tokens += u.output_tokens ?? 0;
41 out.cache_read_tokens += u.cache_read_input_tokens ?? 0;
42 out.cache_creation_tokens += u.cache_creation_input_tokens ?? 0;
43 }
44 if (msg.model) modelCounts[msg.model] = (modelCounts[msg.model] ?? 0) + 1;
45 if (Array.isArray(msg.content) && !isSidechain) {
46 for (const block of msg.content) {
47 if (block?.type === "tool_use" && block.name) {
48 out.tool_use_count++;
49 out.tools[block.name] = (out.tools[block.name] ?? 0) + 1;
50 }
51 }
52 }
53 } else if (row.type === "user" && !isSidechain && !row.isMeta) {
54 if (typeof msg.content === "string" && msg.content.trim()) out.prompt_count++;
55 }
56 }
57
58 // most frequently used model wins
59 let best = null;
60 let bestN = -1;
61 for (const [m, n] of Object.entries(modelCounts)) {
62 if (n > bestN) {
63 best = m;
64 bestN = n;
65 }
66 }
67 out.model = best;
68 return out;
69}
70