Skip to content
Productivity
Hook

Hooks

What cc-statusline runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
cc-statusline
2651 command6 hooks
Install
> /plugin marketplace add NYCU-Chung/cc-statusline
> /plugin install cc-statusline@cc-statusline

Ships with cc-statusline. Installing the plugin gets these hooks.

What fires, and when

SubagentStart

  • Matches.*node "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker.js"

SubagentStop

  • Matches.*node "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker.js"

PreCompact

  • Matches.*node "${CLAUDE_PLUGIN_ROOT}/hooks/compact-monitor.js"

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • node "${CLAUDE_PLUGIN_ROOT}/hooks/message-tracker.js"node "${CLAUDE_PLUGIN_ROOT}/hooks/summary-updater.js"node "${CLAUDE_PLUGIN_ROOT}/hooks/active-time-tracker.js"

Stop

  • Matches.*node "${CLAUDE_PLUGIN_ROOT}/hooks/message-tracker.js"node "${CLAUDE_PLUGIN_ROOT}/hooks/active-time-tracker.js"

PostToolUse

  • MatchesWrite|Editnode "${CLAUDE_PLUGIN_ROOT}/hooks/file-tracker.js"
Read hooks/hooks.json

In the plugin's words

How cc-statusline describes its own hook set.

cc-statusline supporting hooks: subagent tracker, compact monitor, file tracker, message history, session summary updater.

Where it lives

  • hooks/active-time-tracker.jsRunsGitHub
    Read the script
    // UserPromptSubmit + Stop hook: maintain "active session time" — the
    // total wall-clock time Claude was actually working on your turns. Slice
    // per turn: from UserPromptSubmit until the matching Stop. Inter-turn
    // idle is excluded automatically because it falls outside any open turn.
    // No idle threshold required.
    //
    // State lives in its OWN file `claude-active-<sid>.json`, NOT the shared
    // `claude-cum-<sid>.json`. This isolation is intentional: when this hook
    // fires before statusline has rendered a brand-new sid, it must not
    // accidentally write a partial cum file (which would erase cost / dur /
    // tokens accumulated by future statusline writes).
    const fs = require('fs');
    const os = require('os');
    const path = require('path');
    const atomicWrite = (f, data) => {
      const tmp = `${f}.${process.pid}.${Date.now()}.tmp`;
      try { fs.writeFileSync(tmp, data); fs.renameSync(tmp, f); }
      catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} }
    };
    let d = '';
    process.stdin.on('data', c => d += c);
    process.stdin.on('end', () => {
      try {
        const i = JSON.parse(d);
        const event = i.hook_event_name;
        if (event !== 'UserPromptSubmit' && event !== 'Stop') {
          process.stdout.write(d); return;
        }
    
        // Sid keyed by transcript filename UUID — invariant for the logical session.
        // Falls back to i.session_id when no transcript path is available.
        let _logicalSid = i.session_id;
        try {
          if (i.transcript_path) {
            const m = path.basename(i.transcript_path).match(/^([0-9a-fA-F-]+)\.jsonl$/);
            if (m) _logicalSid = m[1];
          }
        } catch (e) {}
        const sid = (_logicalSid || 'default').replace(/[^a-zA-Z0-9]/g, '').toLowerCase().slice(0, 24);
        try { fs.mkdirSync(path.join(os.homedir(), '.claude', 'cc-statusline'), { recursive: true }); } catch (e) {}
        const activePath = path.join(os.homedir(), '.claude', 'cc-statusline', `active-${sid}.json`);
    
        let active = {};
        try { active = JSON.parse(fs.readFileSync(activePath, 'utf8')); } catch (e) {}
    
        const NOW_MS = Date.now();
    
        // Bootstrap once: scan transcript JSONL and sum (assistant.ts - user.ts)
        // for each turn pair. Same turn-bounded model as the live path so no
        // threshold is needed.
        if (active.activeMs === undefined) {
          active.activeMs = 0;
          if (i.transcript_path) {
            try {
              const raw = fs.readFileSync(i.transcript_path, 'utf8');
              let lastUserTs = null;
              for (const line of raw.split('\n')) {
                if (!line) continue;
                try {
                  const entry = JSON.parse(line);
                  const t = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
                  if (!t) continue;
                  if (entry.type === 'user') {
                    lastUserTs = t;
                  } else if (entry.type === 'assistant' && lastUserTs) {
                    if (t > lastUserTs) active.activeMs += t - lastUserTs;
                    lastUserTs = null;
                  }
                } catch (e) {}
              }
            } catch (e) {}
          }
        }
    
        if (event === 'UserPromptSubmit') {
          // Turn opens — stamp when the prompt was submitted.
          active.turnStartAt = NOW_MS;
        } else {
          // Stop — turn closes; add the elapsed slice if a turn was open.
          // Sanity cap: drop slices >24 h. If Claude Code crashed without firing
          // Stop, the next Stop after a resume could otherwise close a turn
          // started days ago and inflate active by the entire wall-clock gap.
          if (active.turnStartAt) {
            const dur = NOW_MS - active.turnStartAt;
            if (dur > 0 && dur < 24 * 60 * 60 * 1000) active.activeMs += dur;
            active.turnStartAt = null;
          }
        }
    
        atomicWrite(activePath, JSON.stringify(active));
      } catch (e) {}
      process.stdout.write(d);
    });
    
  • hooks/compact-monitor.jsRunsGitHub
    Read the script
    const fs = require('fs');
    const os = require('os');
    const path = require('path');
    const atomicWrite = (f, data) => {
      const tmp = `${f}.${process.pid}.${Date.now()}.tmp`;
      try { fs.writeFileSync(tmp, data); fs.renameSync(tmp, f); }
      catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} }
    };
    let d = '';
    process.stdin.on('data', c => d += c);
    process.stdin.on('end', () => {
      try {
        const i = JSON.parse(d);
        // Sid keyed by transcript filename UUID — invariant for the logical session.
        // Falls back to i.session_id when no transcript path is available.
        let _logicalSid = i.session_id;
        try {
          if (i.transcript_path) {
            const m = path.basename(i.transcript_path).match(/^([0-9a-fA-F-]+)\.jsonl$/);
            if (m) _logicalSid = m[1];
          }
        } catch (e) {}
        const sid = (_logicalSid || 'default').replace(/[^a-zA-Z0-9]/g, '').toLowerCase().slice(0, 24);
        try { fs.mkdirSync(path.join(os.homedir(), '.claude', 'cc-statusline'), { recursive: true }); } catch (e) {}
        const file = path.join(os.homedir(), '.claude', 'cc-statusline', `compacts-${sid}.json`);
        let state = { count: 0, last: null };
        try { state = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
        state.count++;
        state.last = Date.now();
        atomicWrite(file, JSON.stringify(state));
      } catch (e) {}
      process.stdout.write(d);
    });
    
  • hooks/file-tracker.jsRunsGitHub
    Read the script
    const fs = require('fs');
    const os = require('os');
    const path = require('path');
    const atomicWrite = (f, data) => {
      const tmp = `${f}.${process.pid}.${Date.now()}.tmp`;
      try { fs.writeFileSync(tmp, data); fs.renameSync(tmp, f); }
      catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} }
    };
    let d = '';
    process.stdin.on('data', c => d += c);
    process.stdin.on('end', () => {
      try {
        const i = JSON.parse(d);
        const fp = i.tool_input?.file_path;
        if (!fp) { process.stdout.write(d); return; }
        // Sid keyed by transcript filename UUID — invariant for the logical session.
        // Falls back to i.session_id when no transcript path is available.
        let _logicalSid = i.session_id;
        try {
          if (i.transcript_path) {
            const m = path.basename(i.transcript_path).match(/^([0-9a-fA-F-]+)\.jsonl$/);
            if (m) _logicalSid = m[1];
          }
        } catch (e) {}
        const sid = (_logicalSid || 'default').replace(/[^a-zA-Z0-9]/g, '').toLowerCase().slice(0, 24);
        try { fs.mkdirSync(path.join(os.homedir(), '.claude', 'cc-statusline'), { recursive: true }); } catch (e) {}
        const file = path.join(os.homedir(), '.claude', 'cc-statusline', `files-${sid}.json`);
        let files = [];
        try { files = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
        const name = path.basename(fp);
        files = [name, ...files.filter(f => f !== name)].slice(0, 8);
        atomicWrite(file, JSON.stringify(files));
      } catch (e) {}
      process.stdout.write(d);
    });
    
  • hooks/mcp-status-refresh.jsGitHub
  • hooks/message-tracker.jsRunsGitHub
    Read the script
    // UserPromptSubmit + Stop: cache recent messages for statusline
    const fs = require('fs');
    const os = require('os');
    const path = require('path');
    const atomicWrite = (f, data) => {
      const tmp = `${f}.${process.pid}.${Date.now()}.tmp`;
      try { fs.writeFileSync(tmp, data); fs.renameSync(tmp, f); }
      catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} }
    };
    // CAS merge: Stop fires multiple times per turn and two hooks can race.
    // Retry until our appended entry is visible at the tail of a fresh read.
    const casMerge = (file, mutate, verify, maxRetries = 10) => {
      for (let attempt = 0; attempt < maxRetries; attempt++) {
        let cur = [];
        try { cur = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
        const next = mutate(cur);
        if (next === null) return false; // dedup rejected — no write needed
        atomicWrite(file, JSON.stringify(next));
        let after = [];
        try { after = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
        if (verify(after)) return true;
      }
      return false;
    };
    let d = '';
    process.stdin.on('data', c => d += c);
    process.stdin.on('end', () => {
      try {
        const i = JSON.parse(d);
        // Sid keyed by transcript filename UUID — invariant for the logical session.
        // Falls back to i.session_id when no transcript path is available.
        let _logicalSid = i.session_id;
        try {
          if (i.transcript_path) {
            const m = path.basename(i.transcript_path).match(/^([0-9a-fA-F-]+)\.jsonl$/);
            if (m) _logicalSid = m[1];
          }
        } catch (e) {}
        const sid = (_logicalSid || 'default').replace(/[^a-zA-Z0-9]/g, '').toLowerCase().slice(0, 24);
        try { fs.mkdirSync(path.join(os.homedir(), '.claude', 'cc-statusline'), { recursive: true }); } catch (e) {}
        const file = path.join(os.homedir(), '.claude', 'cc-statusline', `msgs-${sid}.json`);
    
        // Dedup: Stop can fire multiple times per assistant turn, and duplicate user prompts
        // can happen if the same text is submitted twice. CAS wraps read-check-write
        // so two concurrent hook processes can't both pass the dedup gate and then
        // overwrite each other's append.
        const pushUnique = (r, t) => casMerge(file,
          (msgs) => {
            const last = msgs[msgs.length - 1];
            if (last && last.r === r && last.t === t) return null; // dedup reject
            msgs.push({ r, t });
            return msgs.slice(-30);
          },
          (after) => {
            const last = after[after.length - 1];
            return last && last.r === r && last.t === t;
          }
        );
    
        if (i.hook_event_name === 'UserPromptSubmit' && i.prompt) {
          const text = i.prompt.replace(/\n/g, ' ').trim();
          if (text.length > 2) pushUnique('u', text);
        } else if (i.hook_event_name === 'Stop') {
          // Read last assistant message from transcript tail
          const tp = i.transcript_path;
          if (tp && fs.existsSync(tp)) {
            const stat = fs.statSync(tp);
            // Assistant entries can be very long (tool_results + long markdown).
            // Read a generous tail so we definitely capture the full last assistant line.
            const readSize = Math.min(stat.size, 500000);
            const buf = Buffer.alloc(readSize);
            const fd = fs.openSync(tp, 'r');
            fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
            fs.closeSync(fd);
            const lines = buf.toString('utf8').split('\n');
            // Walk backwards through assistant entries until we find one with real text content.
            // Multi-step responses split into alternating text/tool_use entries — the very last
            // line is often a pure tool_use with no text.
            for (let j = lines.length - 1; j >= 0; j--) {
              try {
                const entry = JSON.parse(lines[j]);
                if (entry.type !== 'assistant') continue;
                const c = entry.message?.content;
                let text = '';
                if (Array.isArray(c)) text = c.filter(b => b.type === 'text').map(b => b.text).join(' ');
                else if (typeof c === 'string') text = c;
                text = text.replace(/\n/g, ' ').trim();
                if (text.length > 5) {
                  pushUnique('a', text);
                  break;
                }
                // else keep looking — this assistant entry had only tool_use blocks
              } catch (e) {}
            }
          }
        }
      } catch (e) {}
      process.stdout.write(d);
    });
    
  • hooks/subagent-tracker.jsRunsGitHub
    Read the script
    const fs = require('fs');
    const os = require('os');
    const path = require('path');
    const atomicWrite = (f, data) => {
      const tmp = `${f}.${process.pid}.${Date.now()}.tmp`;
      try { fs.writeFileSync(tmp, data); fs.renameSync(tmp, f); }
      catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} }
    };
    // CAS merge: retry until our change is visible in a fresh re-read. Guards
    // against two concurrent SubagentStop hooks overwriting each other's entry.
    const casMerge = (file, mutate, verify, maxRetries = 10) => {
      for (let attempt = 0; attempt < maxRetries; attempt++) {
        let cur = {};
        try { cur = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
        mutate(cur);
        atomicWrite(file, JSON.stringify(cur));
        let after = {};
        try { after = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
        if (verify(after)) return;
      }
    };
    let d = '';
    process.stdin.on('data', c => d += c);
    process.stdin.on('end', () => {
      try {
        const i = JSON.parse(d);
        const event = i.hook_event_name;
        if (event !== 'SubagentStart' && event !== 'SubagentStop') { process.stdout.write(d); return; }
        // Sid keyed by transcript filename UUID — invariant for the logical session.
        // Falls back to i.session_id when no transcript path is available.
        let _logicalSid = i.session_id;
        try {
          if (i.transcript_path) {
            const m = path.basename(i.transcript_path).match(/^([0-9a-fA-F-]+)\.jsonl$/);
            if (m) _logicalSid = m[1];
          }
        } catch (e) {}
        const sid = (_logicalSid || 'default').replace(/[^a-zA-Z0-9]/g, '').toLowerCase().slice(0, 24);
        try { fs.mkdirSync(path.join(os.homedir(), '.claude', 'cc-statusline'), { recursive: true }); } catch (e) {}
        const file = path.join(os.homedir(), '.claude', 'cc-statusline', `agents-${sid}.json`);
    
        let name = i.agent_type;
        if (!name && typeof i.agent_id === 'string') {
          if (i.agent_id.startsWith('acompact-')) name = 'compact';
        }
        if (!name) { process.stdout.write(d); return; }
    
        // Per-instance key: prefer agent_id (stable), fallback to name + timestamp to avoid collision
        const key = (typeof i.agent_id === 'string' && i.agent_id) ? i.agent_id : `${name}-${Date.now()}`;
        const myStamp = Date.now();
    
        casMerge(file,
          (state) => {
            if (event === 'SubagentStart') {
              state[key] = { name, status: 'running', started: myStamp };
            } else {
              const prev = state[key] || {};
              state[key] = { name, status: 'done', started: prev.started, finished: myStamp };
            }
            // Prune: keep running entries + last 20 finished
            const entries = Object.entries(state);
            const running = entries.filter(([_, v]) => v.status === 'running');
            const done = entries.filter(([_, v]) => v.status === 'done').sort((a, b) => b[1].finished - a[1].finished).slice(0, 20);
            for (const k of Object.keys(state)) delete state[k];
            for (const [k, v] of [...running, ...done]) state[k] = v;
          },
          (after) => {
            const e = after[key];
            if (!e) return false;
            return event === 'SubagentStart' ? e.started === myStamp : e.finished === myStamp;
          }
        );
      } catch (e) {}
      process.stdout.write(d);
    });
    
  • hooks/summary-updater.jsRunsGitHub
    Read the script
    // UserPromptSubmit: every N user messages, nudge Claude to update session
    // summary. N defaults to 10 and can be overridden via `summaryInterval` in
    // ~/.claude/cc-statusline-rows.json.
    // Also on every trigger, sync the latest summary into the transcript as a
    // `custom-title` entry so /resume picker shows a meaningful name instead of
    // "first user message" fallback.
    const fs = require('fs');
    const os = require('os');
    const path = require('path');
    const atomicWrite = (f, data) => {
      const tmp = `${f}.${process.pid}.${Date.now()}.tmp`;
      try { fs.writeFileSync(tmp, data); fs.renameSync(tmp, f); }
      catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} }
    };
    let d = '';
    process.stdin.on('data', c => d += c);
    process.stdin.on('end', () => {
      try {
        const i = JSON.parse(d);
        // Sid keyed by transcript filename UUID — invariant for the logical session.
        // Falls back to i.session_id when no transcript path is available.
        let _logicalSid = i.session_id;
        try {
          if (i.transcript_path) {
            const m = path.basename(i.transcript_path).match(/^([0-9a-fA-F-]+)\.jsonl$/);
            if (m) _logicalSid = m[1];
          }
        } catch (e) {}
        const sid = (_logicalSid || 'default').replace(/[^a-zA-Z0-9]/g, '').toLowerCase().slice(0, 24);
        try { fs.mkdirSync(path.join(os.homedir(), '.claude', 'cc-statusline'), { recursive: true }); } catch (e) {}
        const countFile = path.join(os.homedir(), '.claude', 'cc-statusline', `msgcount-${sid}`);
        const summaryFile = path.join(os.homedir(), '.claude', 'cc-statusline', `summary-${sid}.txt`);
    
        // Sync current summary → transcript custom-title (takes effect in /resume picker).
        // Skip if no transcript path or no summary yet. Only rewrite when it would change,
        // so we don't bloat the transcript with duplicate entries.
        try {
          if (i.transcript_path && fs.existsSync(i.transcript_path) && fs.existsSync(summaryFile)) {
            const sumRaw = fs.readFileSync(summaryFile, 'utf8').trim().split('\n')[0];
            const title = sumRaw.length > 40 ? sumRaw.slice(0, 39) + '\u2026' : sumRaw;
            if (title && _logicalSid) {
              // Read only the tail (256 KB) of the transcript instead of the
              // whole file \u2014 a long session can have a 100 MB+ transcript and
              // re-reading it on every UserPromptSubmit was the dominant per-
              // turn cost. Custom-title entries always land in the tail.
              const stat = fs.statSync(i.transcript_path);
              const tailSize = Math.min(stat.size, 256 * 1024);
              const buf = Buffer.alloc(tailSize);
              const fd = fs.openSync(i.transcript_path, 'r');
              fs.readSync(fd, buf, 0, tailSize, stat.size - tailSize);
              fs.closeSync(fd);
              const raw = buf.toString('utf8');
              let lastTitle = null;
              const idx = raw.lastIndexOf('"type":"custom-title"');
              if (idx >= 0) {
                const lineStart = raw.lastIndexOf('\n', idx) + 1;
                const lineEnd = raw.indexOf('\n', idx);
                const line = lineEnd > 0 ? raw.slice(lineStart, lineEnd) : raw.slice(lineStart);
                try { lastTitle = JSON.parse(line).customTitle; } catch (e) {}
              }
              if (lastTitle !== title) {
                // Use the logical sid (transcript-derived) so the /resume picker
                // matches the same key statusline and other hooks use.
                const entry = JSON.stringify({ type: 'custom-title', customTitle: title, sessionId: _logicalSid });
                fs.appendFileSync(i.transcript_path, entry + '\n');
              }
            }
          }
        } catch (e) {}
    
        let count = 0;
        try { count = parseInt(fs.readFileSync(countFile, 'utf8').trim(), 10) || 0; } catch (e) {}
        count++;
        atomicWrite(countFile, String(count));
    
        // User-configurable update interval. Clamp to >=1 so we never hit a
        // divide-by-zero in the modulo check, and Math.floor coerces fractional
        // values (e.g. 3.7 → 3) rather than silently rejecting them.
        let interval = 10;
        try {
          const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'cc-statusline-rows.json'), 'utf8'));
          if (typeof cfg.summaryInterval === 'number' && cfg.summaryInterval >= 1) {
            interval = Math.floor(cfg.summaryInterval);
          }
        } catch (e) {}
    
        if (count % interval === 0) {
          const output = {
            hookSpecificOutput: {
              hookEventName: "UserPromptSubmit",
              additionalContext: `[Session summary update] Update the WHOLE-SESSION summary in ${summaryFile} using the Write tool.
    
    A session-spanning summary, not just the most recent topic. Capture the session's overall trajectory from start to now.
    
    Steps:
    1. Read ${summaryFile} (may not exist).
    2. Mentally add the new topic(s) from recent activity.
    3. Rewrite so the result stays within HARD LIMIT 120 characters, single line, comma-separated phrases.
    
    Compression rules when adding would exceed 120 chars (MANDATORY — this is not optional):
    - Merge related sub-topics into a broader theme (e.g. "A 修正, A 優化, A 測試" → "A 全面整理")
    - Drop the least-significant older item (small tweaks, minor fixes) to make room for the new one
    - Keep at least ONE earlier theme to preserve trajectory — do NOT collapse into just-the-latest
    - The most recent meaningful topic MUST appear
    
    Format: one line, comma-separated phrases, ≤120 chars. User's language. Write tool, silent — do not mention this in chat.`
            }
          };
          process.stdout.write(JSON.stringify(output));
          return;
        }
      } catch (e) {}
      process.stdout.write(d);
    });
    

All 7 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withcc-statusline

A comprehensive statusline dashboard for Claude Code. See everything at a glance — no slash commands needed.

Get the whole plugin
Stats
265
Stars
30
Forks
Maintained
Maintenance
JavaScript
Language
MIT
License
5mo ago
Last commit
6mo ago
Created
3d ago
Added

Repo: NYCU-Chung/cc-statusline