SLOPSHOPPER

cc-track

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

newtoastprocessnetwork
★ 3v0.1.0Apache-2.0updated 2026-10-05AUCB21/CC-Tracker/mods/cc-track
A shopper browsing a rack in a slop shop
README

CC·Track: Claude Code session, plan & task tracker

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.

Screenshots

Overview: totals, 14-day activity, recent sessions, active plans and open tasks at a glance. Overview dashboard

Tasks: filter by project/status; the Attend button queues a remote run for the local agent to pick up. Tasks page

Live: real-time feed of task runs and session events, two lanes, filterable and pausable. Live feed

Analytics: activity, tokens & cost per day, tool usage, session durations, hour-of-day prompting. Analytics

Sessions: every session captured by the hooks, with tokens, cost and status. Sessions list

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.

1 · Supabase

  1. Create (or open) a Supabase project.
  2. SQL Editor → New query → paste supabase/schema.sql → Run.
  3. Settings → API: copy the Project URL and the secret key (sb_secret_..., labeled service_role on older projects).

2 · Configure & run the app

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.

Prod server with auto-shutdown (./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.

3 · Wire Claude Code (auto ingestion)

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:

HookCaptured
SessionStartnew session row, project (from cwd), source (startup/resume/clear), git branch, repo written onto the project
UserPromptSubmitprompt count incremented, session title set from the first prompt, raw prompt event (truncated to 4000 chars)
PostToolUseevery 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
Stoptranscript summary: model, input/output/cache-read/cache-creation tokens, tool-use count + breakdown, estimated cost, last assistant message (truncated), session marked ended
StopFailureerror type + message, recorded both as the session's last error and as a stop_failure event
SubagentStartagent_type and agent_id of the spawned subagent
SubagentStopagent_type, agent_id, and the subagent's last assistant message (truncated to 4000 chars)
Notificationnotification type and message text
SessionEndsession status set to ended, ended_at timestamp, end reason

The hook script fails silently and exits 0; it can never block Claude Code.

Alternative: the 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.

4 · Plans & tasks: cctrack CLI

npm 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.

5 · Remote task runs: Attend

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.

6 · The dashboard

  • Overview: totals (sessions, plans, tasks, prompts, tokens, cost), 14-day activity, recent sessions, active plans, open tasks
  • Projects: auto-detected from cwd; per-project sessions, tokens, cost, task progress
  • Plans: grouped by status with task checklists and progress bars
  • Tasks: the task list, filterable by project and status; the Attend button queues a remote run (see "Remote task runs" above) and shows its status inline as it executes
  • HITL: human-in-the-loop approvals queue; pending tool calls a run wants to make wait here until you approve or deny them
  • Sessions: table of every session; detail view has tool usage chart, plan/task lists and a full event timeline
  • Live: real-time feed of remote task runs and session events in two lanes, newest at top; filter either lane by project or session, pause a lane to stop auto-scroll
  • Prompts: the captured prompt library, grouped into families by name and project; edit, version, send, or delete a whole prompt family
  • Analytics: activity, tokens & cost per day, tool usage ranking, task completion, sessions by model, session durations, hour-of-day prompting
  • Setup: live env status + copy-paste instructions

Security notes (single-user setup)

  • All DB access is server-side with the SUPABASE_SECRET key (the legacy service_role key also works, as a fallback); RLS is enabled with no policies, so the anon key can't read anything.
  • /api/ingest/* requires x-api-key: $CC_TRACKER_API_KEY.
  • If you expose this app beyond localhost, put it behind auth/VPN; there are no login screens by design.

Development

npm run test:hooks   # transcript parser unit tests (node)
npx tsx tests/lib.test.mts   # aggregation helper tests
npm run build        # typecheck + production build

License

Licensed under the Apache License 2.0. See LICENSE.

Source 2 files
hooks/register.ts 197 lines
1// 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}
197
hooks/transcript.mjs 70 lines
1// 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