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…

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.

Each fact gets its own chip:
| Chip | What it tells you |
|---|---|
● All clear | The 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 9 | Shell 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 --hard | Risky actions so far, and the latest one |
5-hour 76% ▰▰▰▰▰▱ · resets in 2h 40m | How much of your 5-hour plan limit is used, and when it resets |
· full in 47m at this pace | Appears only when, at your recent pace, you'd hit the limit before it resets |
Weekly 58% … | The same for the weekly limit |
Context 81% full | How full the conversation's context window is |
API cost $12.80 | What 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.
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.
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.
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
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.
/resume or a fresh install, it rebuilds the trail from the conversation's recent messages.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
MIT © 2026 Berşan Kayra Korkmaz
Not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic.
hooks/register.js 169 lines1// 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}
169lib/rules.js 145 lines1// 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}
145lib/bar.js 292 lines1// 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