SLOPSHOPPER

korkmaz-trail

An audit trail for your AI agent, above the Claude Code prompt: commands, file changes, outbound calls and risky actions next to your plan limits, context and…

newbandguardcommandtimer
★ 1v0.3.4MITupdated 2026-10-03BersanKayraKorkmaz/korkmaz-trail
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · korkmaz-trail
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /korkmaz-trail ⎿ korkmaz-trail: what the agent did this session, and your limits ⎿ korkmaz-trail: ● Status: green when all is clear; amber or red with the reason, such as "Context filling up" or "5-hour limit ⎿ korkmaz-trail: Commands · Files · Internet: shell commands run, files created or edited, calls that left your machine ⎿ korkmaz-trail: ⚠ risky: actions worth a second look, such as git push --force or reading .env, and the latest one ⎿ korkmaz-trail: 5-hour · Weekly: how much of your plan limit is used and when it resets; "full in … at this pace" appears only ⎿ korkmaz-trail: Context: how full the conversation's context window is ● │ C 4 · F 3 · I 1 │ ⚠︎ 2 risky │ Context 49% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● │ C 4 · F 3 · I 1 │ ⚠︎ 2 risky │ Context 49% ⟨Claude Code's own drawing⟩
README

korkmaz-trail

English · Türkçe

An audit trail for your AI agent, above the Claude Code prompt.

Claude Code runs commands, edits files and reaches out to the web on your behalf. korkmaz-trail keeps a running record of what the agent actually did this session (commands, files changed, outbound calls, risky actions) and shows it next to the numbers you check anyway: your 5-hour and weekly plan limits, context and cost.

It stays quiet while everything is nominal and speaks up when something deserves a human look. It's a Claude Code mod, so it draws in the Desktop app's Code tab and in the terminal alike.

korkmaz-trail in three states: all clear, 5-hour limit approaching, 5-hour limit reached

Reading the bar

Each fact gets its own chip:

ChipWhat it tells you
● All clearThe overall state in words: green when all is clear, amber or red with the most pressing reason, such as Context filling up, 5-hour limit approaching or High-risk action
Commands 118 · Files 21 · Internet 9Shell commands Claude ran this session, distinct files it created or edited, and calls that left your machine: web fetches and searches, remote URLs, network commands such as git push, npm install or curl
⚠ 2 risky: git reset --hardRisky actions so far, and the latest one
5-hour 76% ▰▰▰▰▰▱ · resets in 2h 40mHow much of your 5-hour plan limit is used, and when it resets
· full in 47m at this paceAppears only when, at your recent pace, you'd hit the limit before it resets
Weekly 58% …The same for the weekly limit
Context 81% fullHow full the conversation's context window is
API cost $12.80What this session would cost at API list prices, as Claude Code estimates it. On a Pro or Max plan you aren't billed this.

The bar starts in your system's language: Turkish on a Turkish system, English otherwise. Type /korkmaz-trail in Claude Code to see this legend, and /korkmaz-trail tr or /korkmaz-trail en to switch the language; the choice is remembered. The chips follow your theme. When the window is narrow they drop the weekly reset time and the cost first, then the gauges, then the words.

What counts as risky

These are heuristics, not a sandbox: korkmaz-trail flags actions worth a second look. It never blocks or approves anything; every tool call passes through unchanged.

High: recursive deletes aimed at /, ~, $HOME, * or ..; git push --force; piping a download into a shell (curl … | sh, irm … | iex); disk formatting; DROP TABLE; terraform destroy, kubectl delete; a credential pasted into a command; reading SSH private keys, .key/.p12 files or cloud CLI credentials.

Medium: git reset --hard, git clean -f, --no-verify; sudo; chmod 777; publishing (npm publish, docker push…); firewall, Defender or execution-policy changes; shutdowns; reading or editing .env, secrets.* or .pem files.

To keep false alarms down, heredoc bodies count as file contents rather than commands, and .env.example, globs (ls .env*) and flags (--exclude=.env) are ignored. A high-severity action turns the status red for 15 minutes, then amber for the rest of the session.

Install

You need Claude Code with mods (v2.1.287 or later). Plan limits appear for Claude Pro and Max subscribers.

In a Claude Code session, add this repository as a plugin marketplace and install the plugin:

/plugin marketplace add BersanKayraKorkmaz/korkmaz-trail
/plugin install korkmaz-trail@korkmaz-trail

The same from your shell:

claude plugin marketplace add BersanKayraKorkmaz/korkmaz-trail
claude plugin install korkmaz-trail@korkmaz-trail

Start a new session, or run /reload-plugins, and the bar appears above the prompt. To remove it, run /plugin uninstall korkmaz-trail@korkmaz-trail.

To try it from a clone without installing, start Claude Code with claude --plugin-dir ./korkmaz-trail.

Privacy and safety

korkmaz-trail runs inside Claude Code, so it's worth knowing exactly what it touches. claude plugin validate lists every event a mod handles and every call it makes; for korkmaz-trail that's:

hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command=korkmaz-trail}, tool.call, session.measure, ui.render{component=AbovePrompt}
calls: $.clock.every, $.clock.now, $.command.register, $.session.messages, $.session.usage, $.store.get, $.store.set, $.ui.invalidate, $.ui.resolve
  • No file access, network requests, processes or model calls, and no dependencies.
  • The counters live in memory for the session. The only thing it saves is your language choice, in Claude Code's own plugin store.
  • Detected credentials are never displayed. Everything it shows from a tool call is stripped of control characters, escape sequences and bidirectional overrides, so a crafted command can't repaint your screen.

How the forecast works

Claude Code reports your plan usage after every turn and whenever a limit moves. korkmaz-trail keeps those readings in memory: the 5-hour forecast uses the burn rate over the last 30 minutes, the weekly one the last 6 hours, and with too little history it falls back to the window's average rate. Nothing is shown unless the projection crosses 100% before the reset.

Known gaps

  • When the mod joins a session late, after /resume or a fresh install, it rebuilds the trail from the conversation's recent messages.
  • Commands that run inside MCP servers or other tools aren't seen.
  • Pattern matching misses some risky actions and occasionally flags harmless ones. Use permission rules, hooks and sandboxing for enforcement; korkmaz-trail is for awareness.
  • Mods are new, and their events can change between releases. Tested with Claude Code 2.1.286 on Windows, where mods were already rolling out.

Development

npm test                 # rules and layout, with node:test
claude plugin test       # the mod itself, drawn for the terminal and the Desktop app
claude plugin validate .claude-plugin/plugin.json
npm run preview          # assets/preview.html and preview-tr.html, for screenshots

License

MIT © 2026 Berşan Kayra Korkmaz

Not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic.

Source 3 files
hooks/register.js 169 lines
1// korkmaz-trail: an audit trail for your AI agent, drawn above the Claude Code prompt.
2//
3// Counts the commands, file changes and outbound calls Claude makes, flags risky
4// ones, and shows them next to your plan limits, context and cost. It only
5// watches: every tool call is passed on unchanged. Nothing leaves your machine.
6import { classifyToolUse } from '../lib/rules.js'
7import { STRINGS, WINDOWS, buildModel, chipLine, fit, legend, merge, pushSample, systemLanguage } from '../lib/bar.js'
8
9// Text colours come from the theme, so they follow the app's light or dark mode.
10const TONES = {
11  ok: { color: 'success' },
12  watch: { color: 'warning' },
13  act: { color: 'error' },
14  dim: { dimColor: true },
15}
16// Chip backgrounds are translucent, so they read on light and dark themes alike.
17const TINTS = {
18  ok: 'rgba(47, 163, 107, 0.16)',
19  watch: 'rgba(217, 106, 28, 0.18)',
20  act: 'rgba(224, 68, 62, 0.18)',
21  neutral: 'rgba(127, 127, 127, 0.13)',
22}
23const MAX_RISKS = 50
24const REFRESH_MS = 30000 // keeps the reset countdowns current while idle
25
26const freshTrail = () => ({ cmds: 0, net: 0, files: new Set(), riskCount: 0, risks: [] })
27
28let trail = freshTrail()
29let usage = null
30let samples = { five_hour: [], seven_day: [] }
31let lang = systemLanguage()
32
33// Windows paths are case-insensitive; count C:\a.ts and c:/A.ts as one file.
34const normPath = (p) => {
35  const s = String(p).replace(/\\/g, '/')
36  return /^[A-Za-z]:\//.test(s) ? s.toLowerCase() : s
37}
38
39function note(name, input, t) {
40  const r = classifyToolUse({ name, input })
41  if (r.cmd) trail.cmds += 1
42  if (r.net) trail.net += 1
43  if (r.edit) trail.files.add(normPath(r.edit))
44  if (r.risk) {
45    trail.riskCount += 1
46    trail.risks.push({ t, ...r.risk })
47    if (trail.risks.length > MAX_RISKS) trail.risks.shift()
48  }
49}
50
51// Rebuild the trail from the conversation so far, for a session the mod joins
52// late: a resumed one, or one where the mod was just installed or reloaded.
53async function seed($) {
54  trail = freshTrail()
55  let messages = []
56  try {
57    messages = await $.session.messages()
58  } catch {
59    return
60  }
61  for (const message of Array.isArray(messages) ? messages : []) {
62    for (const use of Array.isArray(message?.toolUses) ? message.toolUses : []) {
63      const name = use?.tool ?? use?.name
64      if (typeof name !== 'string') continue
65      note(name, use.input && typeof use.input === 'object' ? use.input : use, 0)
66    }
67  }
68}
69
70async function measure($) {
71  try {
72    usage = await $.session.usage()
73  } catch {
74    return
75  }
76  const now = Math.floor((await $.clock.now()) / 1000)
77  for (const limit of Array.isArray(usage?.rateLimits) ? usage.rateLimits : []) {
78    const spec = WINDOWS[limit?.kind]
79    if (!spec || !Number.isFinite(limit.percentUsed)) continue
80    samples[limit.kind] = pushSample(samples[limit.kind], now, limit.percentUsed, spec)
81  }
82}
83
84// The language /korkmaz-trail tr|en saved, else the system's.
85async function loadLanguage($) {
86  const saved = await $.store.get('language').catch(() => undefined)
87  lang = STRINGS[saved] ? saved : systemLanguage()
88}
89
90export function register(on) {
91  on('session.start', async ($, e, next) => {
92    await $.command.register({
93      name: 'korkmaz-trail',
94      description: 'Explain the bar above the prompt, or switch its language: /korkmaz-trail tr | en',
95      immediate: true,
96    })
97    await loadLanguage($)
98    await seed($)
99    await measure($)
100    $.clock.every(REFRESH_MS, () => $.ui.invalidate('ui.render'))
101    return next(e)
102  })
103
104  // /clear, /resume and /branch start a different conversation.
105  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
106    await seed($)
107    $.ui.invalidate('ui.render')
108    return next(e)
109  })
110
111  on('command.run', { command: 'korkmaz-trail' }, async ($, e) => {
112    const arg = String(e.args ?? '').trim().toLowerCase()
113    if (STRINGS[arg]) {
114      lang = arg
115      await $.store.set('language', arg)
116      $.ui.invalidate('ui.render')
117      return { text: STRINGS[arg].langSet }
118    }
119    return { text: legend(lang) }
120  })
121
122  on('tool.call', async ($, e, next) => {
123    note(e.tool, e, Math.floor((await $.clock.now()) / 1000))
124    $.ui.invalidate('ui.render')
125    return next(e)
126  })
127
128  // After each turn, and whenever a plan limit's percentage moves.
129  on('session.measure', async ($, e, next) => {
130    await measure($)
131    $.ui.invalidate('ui.render')
132    return next(e)
133  })
134
135  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136    if (e.props.hasSurvey) return next(e)
137    const now = Math.floor((await $.clock.now()) / 1000)
138    const model = buildModel({ trail: { ...trail, files: trail.files.size }, usage, samples, now })
139    const { Box, Text } = $.ui.resolve(e)
140    const text = (s) => Text({ ...(s.tone ? TONES[s.tone] : {}), ...(s.bold ? { bold: true } : {}), children: [s.text] })
141
142    // The Desktop app gets one chip per fact, wrapping onto more rows when
143    // narrow; the terminal gets the same chips as one line of text.
144    const isDesktop = e.surface === 'desktop'
145    const list = fit(model, e.props.bodyColumns || 0, lang, isDesktop ? 3 : 1)
146    const row = isDesktop
147      ? Box({
148          key: 'korkmaz-trail',
149          flexDirection: 'row',
150          flexWrap: 'wrap',
151          gap: 1,
152          children: list.map((c) =>
153            Box({
154              key: `korkmaz-trail-${c.key}`,
155              flexDirection: 'row',
156              backgroundColor: TINTS[c.tint || 'neutral'],
157              paddingX: 1,
158              children: merge(c.parts).map(text),
159            }),
160          ),
161        })
162      : Box({ key: 'korkmaz-trail', flexDirection: 'row', children: merge(chipLine(list)).map(text) })
163
164    // Keep whatever the mods after this one draw in the band.
165    const theirs = await next(e)
166    return Box({ flexDirection: 'column', children: theirs ? [row, theirs] : [row] })
167  })
168}
169
lib/rules.js 145 lines
1// What a tool call was: a command, a file change, an outbound call, a risky action.
2// Heuristic signals, not a sandbox: they flag actions worth a human look.
3// Pure functions, no imports: shared by the mod and the Node tests.
4
5const cp = (code) => String.fromCodePoint(code);
6
7// Control characters, zero-width marks and bidirectional overrides, built from code
8// points so none of them has to appear in this file.
9const INVISIBLE = new RegExp(
10  `[${[[0x00, 0x1f], [0x7f, 0x9f], [0x200b, 0x200f], [0x202a, 0x202e], [0x2066, 0x2069], [0xfeff, 0xfeff]]
11    .map(([from, to]) => `${cp(from)}-${cp(to)}`)
12    .join('')}]`,
13  'g',
14);
15
16/** Everything korkmaz-trail shows from a tool call passes through here. */
17export function sanitize(text, max = 32) {
18  const clean = String(text).replace(INVISIBLE, ' ').replace(/\s+/g, ' ').trim();
19  return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
20}
21
22const SHELL_RULES = [
23  // Recursive delete aimed at /, ~, $HOME, * or .. (relative build dirs are fine)
24  { sev: 'high', re: /(?<!git\s)\brm(?=[^;&|\n]*\s-[a-zA-Z]*[rR][a-zA-Z]*(?=\s|$)|[^;&|\n]*\s--recursive\b)(?=[^;&|\n]*\s["']?(?:\/\*?|~\/?\*?|\$HOME\/?\*?|\*|\.\.?\/?\*?)["']?(?=\s|$|[;&|)]))[^;&|\n]*/ },
25  { sev: 'high', re: /\b(?:Remove-Item|ri|rd|rmdir|del)\b(?=[^;|\n]*(?:-Recurse|\/s)\b)(?=[^;|\n]*\s["']?(?:[A-Za-z]:\\?\*?|~[\\/]?\*?|\$HOME[\\/]?\*?|\$env:USERPROFILE[\\/]?\*?|\\\*?)["']?(?=\s|$|[;|)]))[^;|\n]*/i },
26  { sev: 'high', re: /\bgit\s+push\b[^;&|\n]*?\s(?:--force(?!-with-lease)|-f)\b[^;&|\n]*/ },
27  { sev: 'high', re: /\b(?:curl|wget|iwr|irm|Invoke-WebRequest|Invoke-RestMethod)\b[^;\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|python3?|node|perl|ruby|iex|Invoke-Expression)\b/i },
28  { sev: 'high', re: /\b(?:iex|Invoke-Expression)\b[^;\n]*\b(?:irm|iwr|Invoke-RestMethod|Invoke-WebRequest|DownloadString)\b[^;\n]*/i },
29  { sev: 'high', re: /\b(?:ba|z)?sh\s+<\(\s*(?:curl|wget)\b[^)\n]*\)?/ },
30  { sev: 'high', re: /\b(?:mkfs(?:\.\w+)?|diskpart|wipefs)\b[^;&|\n]*|\bformat\s+[A-Za-z]:[^;&|\n]*|\bdd\b[^;\n]*\bof=\/dev\/(?:sd|nvme|disk|hd)\w*/i },
31  { sev: 'high', re: /\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b[^;\n"']*/i },
32  { sev: 'high', re: /\b(?:terraform\s+destroy|pulumi\s+destroy|kubectl\s+delete|helm\s+(?:uninstall|delete)|aws\s+s3\s+rm\b[^;\n]*--recursive|aws\s+s3\s+rb\b)[^;&|\n]*/i },
33  { sev: 'high', secret: true, re: /\b(?:sk-ant-[\w-]{16,}|sk-(?:proj-)?[A-Za-z0-9_-]{32,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_\w{40,}|(?:AKIA|ASIA)[0-9A-Z]{16}|xox[abposr]-[\w-]{10,}|AIza[\w-]{35}|glpat-[\w-]{20,})/ },
34  { sev: 'medium', re: /\bgit\s+(?:reset\s+--hard|clean\s+-[a-zA-Z]*f[a-zA-Z]*|checkout\s+(?:--\s+)?\.(?=\s|$)|restore\s+(?:--\S+\s+)*\.(?=\s|$)|stash\s+(?:drop|clear)|branch\s+-D)[^;&|\n]*/ },
35  { sev: 'medium', re: /\bgit\s+(?:commit|push|merge|rebase)\b[^;&|\n]*\s--no-verify\b[^;&|\n]*/ },
36  { sev: 'medium', re: /\bchmod\s+(?:-R\s+)?0?777\b[^;&|\n]*|\bicacls\b[^;&|\n]*\bEveryone:\(?F\)?/i },
37  { sev: 'medium', re: /(?:^|(?<=[\s;&|(]))sudo\s+[^;&|\n]*/ },
38  { sev: 'medium', re: /\b(?:npm|pnpm|yarn)\s+publish\b[^;&|\n]*|\btwine\s+upload\b[^;&|\n]*|\bcargo\s+publish\b|\bgh\s+release\s+create\b[^;&|\n]*|\bdocker\s+push\b[^;&|\n]*|\bgem\s+push\b[^;&|\n]*|\bvsce\s+publish\b/ },
39  { sev: 'medium', re: /\bSet-ExecutionPolicy\s+(?:Unrestricted|Bypass)\b[^;|\n]*|\breg(?:\.exe)?\s+(?:add|delete)\s+HK(?:LM|EY_LOCAL_MACHINE)\b[^;&|\n]*|\bschtasks\s+\/create\b[^;&|\n]*|\bcrontab\s+-r\b|\bsystemctl\s+(?:disable|mask)\b[^;&|\n]*|\bnetsh\s+advfirewall\b[^;&|\n]*|\bSet-MpPreference\b[^;|\n]*|\bufw\s+disable\b|\bsetenforce\s+0\b/i },
40  { sev: 'medium', re: /\b(?:shutdown(?:\.exe)?\s+[-/][rsh]\b|Restart-Computer|Stop-Computer|reboot)\b[^;&|\n]*/i },
41  { sev: 'medium', secret: true, re: /\b[A-Z0-9_]*(?:API_?KEY|SECRET|TOKEN|PASSWORD|PASSWD)[A-Z0-9_]*\s*=\s*["']?[^\s"'$]{12,}/ },
42];
43
44// Files whose contents should not end up in a model's context.
45const SENSITIVE = [
46  { sev: 'high', re: /(?:^|[\\/\s"'=])(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)(?![\w.-])/ },
47  { sev: 'high', re: /[\w.-]+\.(?:key|p12|pfx|jks|keystore|ppk|kdbx)(?![\w.-])/i },
48  { sev: 'high', re: /\.aws[\\/](?:credentials|config)\b|\.azure[\\/]|application_default_credentials\.json|\.kube[\\/]config\b|\.docker[\\/]config\.json|\.git-credentials\b|(?:^|[\\/\s"'])\.(?:netrc|npmrc|pypirc)\b|\.claude[\\/]\.credentials\.json|\.ssh[\\/](?!known_hosts\b|config\b|authorized_keys\b)(?![\w.-]*\.pub\b)[\w.-]+/i },
49  { sev: 'medium', re: /[\w.-]+\.pem(?![\w.-])/i }, // often a private key, sometimes just a certificate
50  { sev: 'medium', re: /(?:^|[\\/\s"'=])\.env(?:\.(?!example\b|sample\b|template\b|dist\b|defaults?\b)[\w.-]+)?(?![\w.-])/ },
51  { sev: 'medium', re: /(?:^|[\\/\s"'=])(?:secrets?|credentials?)\.(?:json|ya?ml|toml|ini|txt|env)(?![\w.-])/i },
52];
53
54// Commands that read a file's contents (so its secrets reach the model's context).
55const READERS = /^(?:cat|type|Get-Content|gc|less|more|head|tail|bat|cp|copy|Copy-Item|scp|base64|xxd|strings|source|\.|grep|rg|findstr|Select-String|sed|awk|openssl)$/i;
56const NET_CMD = /\b(?:curl|wget|Invoke-WebRequest|Invoke-RestMethod|iwr|irm|ssh|scp|sftp|rsync|ftp|telnet|ncat)\b|\bgit\s+(?:push|pull|fetch|clone|ls-remote)\b|\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|ci|publish|update|upgrade)\b|\bnpx\s|\bpip3?\s+install\b|\b(?:gh|aws|az|gcloud)\s|\bdocker\s+(?:push|pull|login)\b/i;
57const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
58const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
59const READ_TOOLS = new Set(['Read', 'NotebookRead']);
60
61const isShellTool = (name) => SHELL_TOOLS.has(name) || /(?:^|__)(?:run_in_terminal|run_command|execute_command)$/.test(name);
62const baseName = (p) => String(p).replace(/["']/g, '').split(/[\\/]/).filter(Boolean).pop() || String(p);
63
64export function isRemoteUrl(u) {
65  if (typeof u !== 'string' || !u.trim()) return false;
66  let url;
67  try { url = new URL(/^[a-z][\w+.-]*:\/\//i.test(u) ? u : `https://${u}`); } catch { return false; }
68  if (!/^(?:https?|wss?|ftp):$/.test(url.protocol)) return false;
69  const h = url.hostname.replace(/^\[|\]$/g, '');
70  return !(h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.test') || h === '::1' || h === '0.0.0.0' || /^127\./.test(h));
71}
72
73function isNetCommand(cmd) {
74  const urls = cmd.match(/\b(?:https?|wss?|ftp):\/\/[^\s"'<>)\]]+/gi) || [];
75  if (urls.some(isRemoteUrl)) return true;
76  if (urls.length) return false; // only local URLs: a dev server, not egress
77  return NET_CMD.test(cmd);
78}
79
80/** Heredoc and PowerShell here-string bodies are file contents, not commands. */
81export function stripHeredocs(cmd) {
82  return cmd
83    .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n[\t ]*\2[\t ]*(?=\n|$)/g, '<<heredoc')
84    .replace(/@'[\s\S]*?'@|@"[\s\S]*?"@/g, "''");
85}
86
87function sensitivity(text) {
88  for (const rule of SENSITIVE) if (rule.re.test(text)) return rule.sev;
89  return null;
90}
91
92/** A shell segment that starts with a reader verb and names a concrete secret file. */
93function shellSecretRead(command) {
94  for (const segment of command.split(/&&|\|\||[;|\n]/)) {
95    const words = segment.trim().split(/\s+/).filter(Boolean);
96    while (words.length && /^(?:sudo|env|[A-Za-z_]\w*=\S*)$/.test(words[0])) words.shift();
97    if (!words.length || !READERS.test(words[0])) continue;
98    for (const word of words.slice(1)) {
99      const t = word.replace(/^["']+|["']+$/g, '');
100      if (!t || t.startsWith('-') || /[*?]/.test(t)) continue; // flags and globs are not files
101      const sev = sensitivity(t);
102      if (sev) return { sev, file: baseName(t) };
103    }
104  }
105  return null;
106}
107
108/**
109 * Classify one tool call. `name` is the tool, `input` its arguments, such as
110 * `{ command }` for Bash or `{ file_path }` for Edit.
111 */
112export function classifyToolUse({ name, input } = {}) {
113  const tool = String(name || '');
114  const args = input && typeof input === 'object' ? input : {};
115  const out = { cmd: false, edit: null, net: false, risk: null };
116  const flag = (sev, kind, arg = '') => {
117    if (!out.risk || (sev === 'high' && out.risk.sev !== 'high')) out.risk = { sev, kind, arg: sanitize(arg, 48) };
118  };
119
120  if (isShellTool(tool)) {
121    out.cmd = true;
122    const command = stripHeredocs(String(args.command ?? ''));
123    for (const rule of SHELL_RULES) {
124      const m = command.match(rule.re);
125      if (m) flag(rule.sev, rule.secret ? 'secret' : 'cmd', rule.secret ? '' : m[0]);
126    }
127    out.net = isNetCommand(command);
128    const read = shellSecretRead(command);
129    if (read) flag(read.sev, 'read', read.file);
130  } else if (EDIT_TOOLS.has(tool)) {
131    const p = String(args.file_path || args.notebook_path || '');
132    if (p) {
133      out.edit = p;
134      const sev = sensitivity(p);
135      if (sev) flag(sev, 'edited', baseName(p));
136    }
137  } else if (READ_TOOLS.has(tool)) {
138    const p = String(args.file_path || args.notebook_path || '');
139    const sev = p && sensitivity(p);
140    if (sev) flag(sev, 'read', baseName(p));
141  }
142  if (tool === 'WebSearch' || isRemoteUrl(args.url) || isRemoteUrl(args.uri)) out.net = true;
143  return out;
144}
145
lib/bar.js 292 lines
1// What the bar shows, as plain data: the model built from the trail and Claude
2// Code's usage numbers, and the labelled chips drawn from it. No mods API calls,
3// so the Node tests and the preview script use it too.
4import { sanitize } from './rules.js';
5
6const VS15 = String.fromCodePoint(0xfe0e); // keeps the warning sign a text glyph, not an emoji
7
8export const STRINGS = {
9  en: {
10    // What the status chip says: the most pressing reason, in plain words
11    status: {
12      ok: () => 'All clear',
13      limitReached: (w) => `${w} limit reached`,
14      riskHigh: () => 'High-risk action',
15      limitSoon: (w) => `${w} limit approaching`,
16      ctxFull: () => 'Context almost full',
17      ctxHigh: () => 'Context filling up',
18      risk: () => 'Risky action seen',
19    },
20    cmds: 'Commands', files: 'Files', net: 'Internet',
21    risky: (n) => `${n} risky`,
22    five_hour: '5-hour', seven_day: 'Weekly',
23    pct: (n) => `${n}%`,
24    fullIn: (d) => `full in ${d} at this pace`,
25    reached: 'limit reached',
26    resetsIn: (d) => `resets in ${d}`,
27    ctx: 'Context', ctxFull: 'full',
28    cost: 'API cost',
29    dur: { lt1m: '<1m', m: (m) => `${m}m`, hm: (h, m) => (m ? `${h}h ${m}m` : `${h}h`), dh: (d, h) => (h ? `${d}d ${h}h` : `${d}d`) },
30    read: (f) => `read ${f}`, edited: (f) => `edited ${f}`, secret: 'secret in command',
31    legend: [
32      'what the agent did this session, and your limits',
33      '● Status: green when all is clear; amber or red with the reason, such as "Context filling up" or "5-hour limit approaching"',
34      'Commands · Files · Internet: shell commands run, files created or edited, calls that left your machine',
35      '⚠ risky: actions worth a second look, such as git push --force or reading .env, and the latest one',
36      '5-hour · Weekly: how much of your plan limit is used and when it resets; "full in … at this pace" appears only if you would hit it first',
37      'Context: how full the conversation\'s context window is',
38      'API cost: what this session would cost at API list prices, as Claude Code estimates it; on a Pro or Max plan you are not billed this',
39      'Language: /korkmaz-trail en · /korkmaz-trail tr',
40    ],
41    langSet: 'Language: English',
42  },
43  tr: {
44    status: {
45      ok: () => 'Her şey yolunda',
46      limitReached: (w) => `${w} limit doldu`,
47      riskHigh: () => 'Yüksek riskli işlem',
48      limitSoon: (w) => `${w} limit yaklaşıyor`,
49      ctxFull: () => 'Bağlam neredeyse dolu',
50      ctxHigh: () => 'Bağlam doluyor',
51      risk: () => 'Riskli işlem var',
52    },
53    cmds: 'Komut', files: 'Dosya', net: 'İnternet',
54    risky: (n) => `${n} riskli`,
55    five_hour: '5 saatlik', seven_day: 'Haftalık',
56    pct: (n) => `%${n}`,
57    fullIn: (d) => `bu hızla ${d} içinde dolar`,
58    reached: 'limit doldu',
59    resetsIn: (d) => `sıfırlanmaya ${d}`,
60    ctx: 'Bağlam', ctxFull: 'dolu',
61    cost: 'API maliyeti',
62    dur: { lt1m: '<1 dk', m: (m) => `${m} dk`, hm: (h, m) => (m ? `${h} sa ${m} dk` : `${h} sa`), dh: (d, h) => (h ? `${d} g ${h} sa` : `${d} g`) },
63    read: (f) => `${f} okundu`, edited: (f) => `${f} düzenlendi`, secret: 'komutta gizli anahtar',
64    legend: [
65      'bu oturumda ajanın yaptıkları ve limitlerin',
66      '● Durum: her şey yolundaysa yeşil; değilse turuncu ya da kırmızı ve sebebiyle, ör. "Bağlam doluyor", "5 saatlik limit yaklaşıyor"',
67      'Komut · Dosya · İnternet: çalıştırılan komutlar, oluşturulan ya da değiştirilen dosyalar, bilgisayarından dışarı çıkan çağrılar',
68      '⚠ riskli: dikkat isteyen işlemler (ör. git push --force, .env okuma) ve sonuncusu',
69      '5 saatlik · Haftalık: plan limitinin ne kadarının kullanıldığı ve ne zaman sıfırlanacağı; "bu hızla … içinde dolar" yalnızca sıfırlanmadan önce dolacaksan çıkar',
70      'Bağlam: konuşmanın bağlam penceresinin ne kadar dolu olduğu',
71      'API maliyeti: bu oturumun API liste fiyatıyla tutarı (Claude Code\'un tahmini); Pro veya Max planında bu tutar senden çekilmez',
72      'Dil: /korkmaz-trail en · /korkmaz-trail tr',
73    ],
74    langSet: 'Dil: Türkçe',
75  },
76};
77
78export const GLYPHS = {
79  unicode: { dot: '●', warn: `⚠${VS15}`, on: '▰', off: '▱', sep: '│' },
80  ascii: { dot: '*', warn: '!', on: '=', off: '-', sep: '|' },
81};
82
83const RANK = { ok: 0, watch: 1, act: 2 };
84
85export function fmtDuration(seconds, L = STRINGS.en) {
86  const m = Math.floor(Math.max(0, seconds) / 60);
87  if (m < 1) return L.dur.lt1m;
88  const d = Math.floor(m / 1440);
89  const h = Math.floor((m % 1440) / 60);
90  if (d) return L.dur.dh(d, h);
91  if (h) return L.dur.hm(h, m % 60);
92  return L.dur.m(m);
93}
94
95/** The bar's language for a locale such as tr-TR: Turkish on a Turkish system, English otherwise. */
96export function systemLanguage(locale = Intl.DateTimeFormat().resolvedOptions().locale) {
97  const code = String(locale || '').toLowerCase().split(/[-_]/)[0];
98  return STRINGS[code] ? code : 'en';
99}
100
101export const legend = (lang = 'en') => {
102  const L = STRINGS[lang] || STRINGS.en;
103  return L.legend.join('\n');
104};
105
106// ------------------------------------------------------------------ limits --
107
108export const WINDOWS = {
109  five_hour: { span: 5 * 3600, lookback: 30 * 60, minSpan: 5 * 60, minElapsed: 15 * 60, urgent: 30 * 60 },
110  seven_day: { span: 7 * 86400, lookback: 6 * 3600, minSpan: 3600, minElapsed: 12 * 3600, urgent: 6 * 3600 },
111};
112
113/** Project usage at the reset from the recent burn rate, or the window's average. Seconds throughout. */
114export function forecast(pct, resetsAt, now, samples, spec) {
115  const timeLeft = Math.max(0, resetsAt - now);
116  const elapsed = spec.span - timeLeft;
117  let rate = null;
118  const recent = samples.filter(([t]) => now - t <= spec.lookback);
119  if (recent.length >= 2) {
120    const [t0, p0] = recent[0];
121    const [t1, p1] = recent[recent.length - 1];
122    if (t1 - t0 >= spec.minSpan) rate = Math.max(0, (p1 - p0) / (t1 - t0));
123  }
124  if (rate === null && elapsed >= spec.minElapsed) rate = Math.max(0, pct / elapsed);
125  if (rate === null) return { timeLeft, projected: null, eta: null };
126  const projected = pct + rate * timeLeft;
127  const eta = projected >= 100 && rate > 0 ? Math.max(0, (100 - pct) / rate) : null;
128  return { timeLeft, projected, eta };
129}
130
131/** Add a reading to a window's samples; a drop in usage means the window reset. */
132export function pushSample(samples, now, pct, spec) {
133  const last = samples[samples.length - 1];
134  let list = last && pct < last[1] - 0.5 ? [] : samples.slice();
135  const prev = list[list.length - 1];
136  if (!prev || pct !== prev[1] || now - prev[0] >= 60) list.push([now, pct]);
137  list = list.filter(([t]) => now - t <= spec.lookback * 2);
138  return list.slice(-300);
139}
140
141// ------------------------------------------------------------------- model --
142
143function windowSev(w, spec) {
144  if (w.pct >= 100) return 'act';
145  if (w.eta !== null && w.eta < w.timeLeft) return w.eta <= spec.urgent ? 'act' : 'watch';
146  return w.pct >= 90 ? 'watch' : 'ok';
147}
148
149/**
150 * trail:   { cmds, files, net, riskCount, risks: [{ t, sev, kind, arg }] }
151 * usage:   what $.session.usage() returns: { context: { percent }, rateLimits: [{ kind, percentUsed, resetsAt }], cost: { usd } }
152 * samples: { five_hour: [[t, pct]], seven_day: [...] }, t in seconds
153 */
154export function buildModel({ trail = null, usage = null, samples = {}, now }) {
155  let audit = null;
156  if (trail) {
157    const risks = trail.risks || [];
158    const recentHigh = [...risks].reverse().find((r) => r.sev === 'high');
159    const top = recentHigh || risks[risks.length - 1] || null;
160    let sev = 'ok';
161    // A high-severity action turns the status red for 15 minutes, then amber.
162    if (trail.riskCount > 0) sev = recentHigh && recentHigh.t > 0 && now - recentHigh.t <= 15 * 60 ? 'act' : 'watch';
163    audit = { cmds: trail.cmds, files: trail.files, net: trail.net, riskCount: trail.riskCount, top, sev };
164  }
165
166  const windows = [];
167  for (const key of Object.keys(WINDOWS)) {
168    const r = (usage?.rateLimits || []).find((x) => x?.kind === key);
169    const pct = Number(r?.percentUsed);
170    const resetsAt = Math.floor(Date.parse(r?.resetsAt) / 1000);
171    if (!r || !Number.isFinite(pct) || !Number.isFinite(resetsAt)) continue;
172    const w = { key, pct, ...forecast(pct, resetsAt, now, samples[key] || [], WINDOWS[key]) };
173    windows.push({ ...w, sev: windowSev(w, WINDOWS[key]) });
174  }
175
176  const p = Number(usage?.context?.percent);
177  const ctx = usage?.context?.percent != null && Number.isFinite(p) ? { pct: Math.round(p), sev: p >= 90 ? 'act' : p >= 75 ? 'watch' : 'ok' } : null;
178  const usd = Number(usage?.cost?.usd);
179  const cost = usage?.cost?.usd != null && Number.isFinite(usd) ? usd : null;
180
181  // The status chip names the most pressing reason; among equal severities, the lower rank wins.
182  const causes = [];
183  for (const w of windows) {
184    if (w.pct >= 100) causes.push({ sev: 'act', rank: 0, key: 'limitReached', win: w.key });
185    else if (w.sev !== 'ok') causes.push({ sev: w.sev, rank: 2, key: 'limitSoon', win: w.key });
186  }
187  if (audit?.sev === 'act') causes.push({ sev: 'act', rank: 1, key: 'riskHigh' });
188  else if (audit?.sev === 'watch') causes.push({ sev: 'watch', rank: 5, key: 'risk' });
189  if (ctx?.sev === 'act') causes.push({ sev: 'act', rank: 3, key: 'ctxFull' });
190  else if (ctx?.sev === 'watch') causes.push({ sev: 'watch', rank: 4, key: 'ctxHigh' });
191  causes.sort((a, b) => RANK[b.sev] - RANK[a.sev] || a.rank - b.rank);
192  const reason = causes[0] || { sev: 'ok', key: 'ok' };
193  return { audit, windows, ctx, cost, sev: reason.sev, reason };
194}
195
196// ------------------------------------------------------------------- chips --
197
198function riskText(risk, L) {
199  if (!risk) return '';
200  if (risk.kind === 'secret') return L.secret;
201  if (risk.kind === 'read') return L.read(risk.arg);
202  if (risk.kind === 'edited') return L.edited(risk.arg);
203  return risk.arg;
204}
205
206const part = (text, tone = null, bold = false) => ({ text, tone, bold });
207
208/**
209 * The bar as labelled chips: `{ key, tint, parts: [{ text, tone, bold }] }`.
210 * `tint` and `tone` are ok, watch, act, dim or null. Level 0 says everything;
211 * level 1 drops the weekly reset time and the cost, level 2 the gauges too,
212 * and level 3 keeps only the numbers. The gauges go last because they read fastest.
213 */
214export function chips(m, lang = 'en', level = 0, ascii = false) {
215  const L = STRINGS[lang] || STRINGS.en;
216  const G = ascii ? GLYPHS.ascii : GLYPHS.unicode;
217  const out = [];
218  const chip = (key, tint, parts) => out.push({ key, tint, parts: parts.filter((p) => p.text) });
219  const alarm = (sev) => (sev === 'ok' ? null : sev);
220
221  const reason = m.reason || { key: 'ok' };
222  const said = L.status[reason.key](reason.win ? L[reason.win] : '');
223  chip('status', m.sev, level >= 3 ? [part(G.dot, m.sev)] : [part(G.dot, m.sev), part(` ${said}`, alarm(m.sev), m.sev !== 'ok')]);
224
225  if (m.audit) {
226    const a = m.audit;
227    const count = (label, n) => (level >= 3 ? [part(`${label[0]} `, 'dim'), part(String(n), null, true)] : [part(`${label} `, 'dim'), part(String(n), null, true)]);
228    chip('trail', null, [...count(L.cmds, a.cmds), part(' · ', 'dim'), ...count(L.files, a.files), part(' · ', 'dim'), ...count(L.net, a.net)]);
229    if (a.riskCount) {
230      const tone = a.sev === 'ok' ? 'watch' : a.sev;
231      const what = level < 3 && a.top ? `: ${sanitize(riskText(a.top, L), level === 0 ? 34 : 20)}` : '';
232      chip('risk', tone, [part(`${G.warn} ${L.risky(a.riskCount)}`, tone, true), part(what, tone)]);
233    }
234  }
235
236  for (const w of m.windows) {
237    if (level >= 3 && w.key !== 'five_hour') continue;
238    const parts = [part(`${L[w.key]} `, 'dim'), part(L.pct(Math.round(w.pct)), alarm(w.sev), true)];
239    if (level <= 1) {
240      const n = Math.max(0, Math.min(6, Math.round((w.pct / 100) * 6)));
241      parts.push(part(' '), part(G.on.repeat(n), w.sev), part(G.off.repeat(6 - n), 'dim'));
242    }
243    if (w.pct >= 100) parts.push(part(` · ${L.reached}`, 'act', true));
244    else if (w.eta !== null && w.eta < w.timeLeft) parts.push(part(` · ${L.fullIn(fmtDuration(w.eta, L))}`, w.sev));
245    if (level === 0 || (level <= 2 && w.key === 'five_hour')) parts.push(part(` · ${L.resetsIn(fmtDuration(w.timeLeft, L))}`, 'dim'));
246    chip(w.key, alarm(w.sev), parts);
247  }
248
249  if (m.ctx) chip('ctx', alarm(m.ctx.sev), [part(`${L.ctx} `, 'dim'), part(L.pct(m.ctx.pct), alarm(m.ctx.sev), true), part(level < 3 ? ` ${L.ctxFull}` : '', 'dim')]);
250  if (m.cost !== null && level === 0) chip('cost', null, [part(`${L.cost} `, 'dim'), part(`$${m.cost.toFixed(2)}`, null, true)]);
251  return out;
252}
253
254/** Chips as one line of text, for the terminal. */
255export function chipLine(list, ascii = false) {
256  const G = ascii ? GLYPHS.ascii : GLYPHS.unicode;
257  const segs = [];
258  list.forEach((c, i) => {
259    if (i) segs.push(part(` ${G.sep} `, 'dim'));
260    segs.push(...c.parts);
261  });
262  return segs;
263}
264
265export const widthOf = (segs) => segs.reduce((n, s) => n + Array.from(s.text).filter((ch) => ch !== VS15).length, 0);
266
267/**
268 * The most detailed chips that fit `columns` across `rows` rows (0 columns = no
269 * limit). Each chip costs two extra columns for its padding.
270 */
271export function fit(model, columns = 0, lang = 'en', rows = 1, ascii = false) {
272  for (let level = 0; level <= 3; level += 1) {
273    const list = chips(model, lang, level, ascii);
274    const width = widthOf(chipLine(list, ascii)) + list.length * 2;
275    if (!columns || width <= (columns - 2) * rows || level === 3) return list;
276  }
277  return [];
278}
279
280/** Join neighbouring parts that share a style, so the drawing needs fewer elements. */
281export function merge(parts) {
282  const out = [];
283  for (const s of parts) {
284    const last = out[out.length - 1];
285    if (last && last.tone === s.tone && last.bold === s.bold) last.text += s.text;
286    else out.push({ ...s });
287  }
288  return out;
289}
290
291export const plainText = (segs) => segs.map((s) => s.text).join('');
292