Skip to content

Commit b2d1bba

Browse files
committed
feat: v5.8.7 — per-session VS Code extension, fairer efficiency, self-healing dashboard
VS Code extension: resolve the status bar to this window's workspace session (no cross-session bleed), compute context fill from transcript tokens ÷ the real context window (correct for 200k and 1M sessions), add an expanded click-to-open status panel, brighter grey for secondary info, honest no-folder/warming-up states, and drop the Regime indicator. Efficiency metric: a brand-new session no longer scores low for having no decisions yet — decision density stays neutral until there's enough conversation to judge, so a fresh session reads high instead of mid-B. Established low-density sessions are still scored normally. Dashboard daemon: self-heal a stale tombstone instead of staying dead until re-setup, and regenerate the dashboard in the background when it's opened stale, so the localhost view reflects recent activity. Status line: show 5h/7d usage limits with reset times, brighter grey, regime removed.
1 parent 4f06c6f commit b2d1bba

17 files changed

Lines changed: 522 additions & 62 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"name": "token-optimizer",
1414
"source": "./",
1515
"description": "Audit, fix, and monitor Claude Code context window usage. Find the ghost tokens.",
16-
"version": "5.8.6",
16+
"version": "5.8.7",
1717
"author": {
1818
"name": "Alex Greenshpun",
1919
"url": "https://linkedin.com/in/alexgreensh"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
},
88
"homepage": "https://github.com/alexgreensh/token-optimizer",
99
"repository": "https://github.com/alexgreensh/token-optimizer",
10-
"version": "5.8.6",
10+
"version": "5.8.7",
1111
"license": "PolyForm-Noncommercial-1.0.0",
1212
"keywords": ["token", "optimization", "context", "audit", "cost", "coach"]
1313
}

.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "token-optimizer",
3-
"version": "5.8.6",
3+
"version": "5.8.7",
44
"description": "Audit, monitor, and reduce Codex context waste with continuity helpers and explicit outline tools.",
55
"skills": "./skills/",
66
"interface": {

skills/token-optimizer/scripts/measure.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9876,7 +9876,7 @@ def setup_hook(dry_run=False):
98769876

98779877
# ========== Persistent Dashboard Daemon ==========
98789878

9879-
TOKEN_OPTIMIZER_VERSION = "5.8.6" # Keep in sync with plugin.json + marketplace.json
9879+
TOKEN_OPTIMIZER_VERSION = "5.8.7" # Keep in sync with plugin.json + marketplace.json
98809880
_DASHBOARD_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
98819881
_DAEMON_RUNTIME = detect_runtime()
98829882
_DAEMON_RUNTIME_SUFFIX = "codex" if _DAEMON_RUNTIME == "codex" else "claude"
@@ -10018,6 +10018,12 @@ def _generate_daemon_script():
1001810018
# respawning us. Uninstall also writes this tombstone directly.
1001910019
THRASH_LIMIT = 3
1002010020

10021+
# Freshness-on-open: if the cached dashboard HTML is older than this when a GET
10022+
# arrives, kick off one background regen (throttled by the same interval) and
10023+
# serve the current file immediately, so the next open reflects recent activity.
10024+
DASHBOARD_FRESH_SECONDS = 120
10025+
_last_regen = 0.0
10026+
1002110027

1002210028
def _read_token():
1002310029
"""Read per-install CSRF token from disk. Empty string if missing/unreadable."""
@@ -10144,11 +10150,36 @@ def _serve_or_redirect(self, method):
1014410150
if not self._require_localhost():
1014510151
return
1014610152
if self._is_dashboard_request():
10153+
if method == "do_GET":
10154+
self._maybe_refresh_dashboard()
1014710155
self.path = "/" + os.path.basename(DASHBOARD)
1014810156
getattr(super(), method)()
1014910157
else:
1015010158
self.send_error(403, "Forbidden")
1015110159

10160+
def _maybe_refresh_dashboard(self):
10161+
# Stale-while-revalidate: serve the current HTML now, and if it's stale
10162+
# kick off ONE throttled background regen so the next open is fresh. Never
10163+
# blocks the request; failures are silently ignored (best-effort).
10164+
global _last_regen
10165+
try:
10166+
mtime = os.path.getmtime(DASHBOARD)
10167+
except OSError:
10168+
return
10169+
now = time.time()
10170+
if now - mtime <= DASHBOARD_FRESH_SECONDS or now - _last_regen <= DASHBOARD_FRESH_SECONDS:
10171+
return
10172+
_last_regen = now
10173+
try:
10174+
import subprocess
10175+
subprocess.Popen(
10176+
[sys.executable, {measure_py_literal}, "dashboard", "--quiet"],
10177+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
10178+
stdin=subprocess.DEVNULL, close_fds=True,
10179+
)
10180+
except (OSError, ValueError):
10181+
pass
10182+
1015210183
def do_OPTIONS(self):
1015310184
self.send_response(200)
1015410185
origin = self.headers.get("Origin", "")
@@ -10238,7 +10269,22 @@ def _thrash_check_and_update():
1023810269
size = 0
1023910270
# Size 0 = uninstall tombstone. >0 = thrash counter; process it below.
1024010271
if size == 0:
10241-
return False
10272+
# Honor it only if FRESH (an uninstall genuinely in progress). A real
10273+
# uninstall removes the plist + this very script within milliseconds,
10274+
# so a stale 0-byte tombstone sitting next to a healthy dashboard with
10275+
# this daemon still running is a stuck state, not an uninstall — clear
10276+
# it and serve. This self-heals daemons wrongly tombstoned by an
10277+
# update-window thrash, instead of staying dead until setup-daemon.
10278+
try:
10279+
age = time.time() - os.path.getmtime(THRASH_PATH)
10280+
except OSError:
10281+
age = 0 # unreadable mtime: treat as fresh, preserve uninstall safety
10282+
if age < 60:
10283+
return False
10284+
try:
10285+
os.unlink(THRASH_PATH)
10286+
except OSError:
10287+
pass
1024210288

1024310289
if os.path.exists(DASHBOARD):
1024410290
# Healthy start: clear any thrash counter.
@@ -11979,6 +12025,10 @@ def _float_env(key: str, default: float) -> float:
1197912025
# use only the last N operations to prevent denominator-expansion bias where
1198012026
# scores climb as the session progresses even though context health is degrading.
1198112027
_QUALITY_ROLLING_WINDOW = _int_env("TOKEN_OPTIMIZER_QUALITY_WINDOW", 20)
12028+
# Below this many messages a session is too new to judge decision density — a
12029+
# brand-new session hasn't accumulated decisions yet, so scoring it 0 would
12030+
# wrongly drag SessionEfficiency down ~20 points. Stay neutral instead.
12031+
_DENSITY_MIN_MESSAGES = _int_env("TOKEN_OPTIMIZER_DENSITY_MIN_MESSAGES", 6)
1198212032

1198312033
# Fill-based warning thresholds that fire independently of the composite score.
1198412034
# These cannot be masked by improving ratio signals.
@@ -12528,12 +12578,14 @@ def compute_quality_score(quality_data, session_id=None):
1252812578
window_messages = all_messages[-_QUALITY_ROLLING_WINDOW:]
1252912579
substantive = sum(1 for _, _, _, s in window_messages if s)
1253012580
window_msg_count = len(window_messages)
12531-
if window_msg_count > 0:
12581+
if window_msg_count >= _DENSITY_MIN_MESSAGES:
1253212582
density_ratio = substantive / window_msg_count
1253312583
density_score = min(100, density_ratio * 200) # 50% substantive = 100
1253412584
else:
12535-
density_ratio = 0
12536-
density_score = 50
12585+
# Too few messages to judge decision density (brand-new session). Stay
12586+
# neutral instead of penalizing — mirrors agent_efficiency's no-data 80.
12587+
density_ratio = substantive / window_msg_count if window_msg_count else 0
12588+
density_score = 80
1253712589

1253812590
# 6. Agent efficiency: rolling window over last N dispatches.
1253912591
all_dispatches = quality_data["agent_dispatches"]

skills/token-optimizer/scripts/statusline.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ process.stdin.on('end', () => {
2929
// status line, persisted nowhere else, so we bridge it to a sidecar file
3030
// for the VS Code companion (rate limits are account-wide, not per-session).
3131
const rateLimits = data.rate_limits || null;
32-
const DIM = '\x1b[2m';
32+
// A readable medium grey instead of ANSI faint (\x1b[2m), which renders too
33+
// low-contrast for secondary info like session time on most terminals.
34+
const DIM = '\x1b[38;5;245m';
3335
const RESET = '\x1b[0m';
3436
const SEP = ` ${DIM}|${RESET} `;
3537
const gradeFor = (s) => s >= 90 ? 'S' : s >= 80 ? 'A' : s >= 70 ? 'B' : s >= 55 ? 'C' : s >= 40 ? 'D' : 'F';
@@ -199,8 +201,6 @@ process.stdin.on('end', () => {
199201
} else if (fw.level === 'WARNING') {
200202
row2Parts.push(`\x1b[33mFill:${Math.round(fw.fill_pct)}%${RESET}`);
201203
}
202-
} else if (q.regime_change) {
203-
row2Parts.push(`\x1b[33mRegime:${Math.round(q.regime_change.fill_pct)}%${RESET}`);
204204
}
205205

206206
// Tool call fatigue warning

vscode-extension/CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
# Changelog
22

3+
## 0.1.8
4+
5+
- Hardening pass: runtime-allowlist the panel's actions, dedupe fill clamping, and make warnings array-based (no join/re-split). No behavior change.
6+
7+
## 0.1.7
8+
9+
- Improve: brighter, higher-contrast grey for secondary info (session time, labels, reset times) in the panel and terminal status line — the old faint grey was hard to read.
10+
11+
## 0.1.6
12+
13+
- Remove: the "Regime" indicator (obscure context-fill-shift signal that confused more than it helped). The underlying detection stays in the plugin; it's just off the status line now.
14+
15+
## 0.1.5
16+
17+
- Fix: context fill is now computed from the transcript's own token count divided by the real context window (from the quality-cache's `model_context_window`). This is the only source that's correct for both 200k and 1M-context sessions, and it ignores a 0% fill the plugin sometimes writes when it can't attribute fill.
18+
19+
## 0.1.4
20+
21+
- Fix: context fill is now read from the per-session quality-cache's authoritative `fill_pct` (computed against the real context window), fixing badly-wrong fill on 1M-context sessions. Priority: matched live-fill → quality-cache → transcript tail.
22+
- Honest: with no folder open, the status bar can't scope to this window's session, so it now says so plainly (in tooltip and panel) instead of showing volatile global data.
23+
- Note: VS Code shows one status bar per window, so multiple Claude tabs in the same window reflect the most-recently-active session — a platform limitation, now documented.
24+
25+
## 0.1.3
26+
27+
- New: clicking the status bar opens an **expanded status panel** — a focused, theme-matched view of context fill, ContextQ, Eff, warnings, compactions, duration, agents, and 5h/7d limits, live-updating. The full browser dashboard is now an explicit "Open full dashboard" button.
28+
29+
## 0.1.2
30+
31+
- Fix: context fill is now read per-session. The shared `live-fill.json` was leaking one session's fill into other windows; the status bar now uses it only when it matches this window's session, falling back to the transcript otherwise.
32+
- Improve: the 7-day usage limit now shows the reset **date and days remaining** (e.g. "Jun 4, 8:00pm · in 4d"), not just a time-of-day.
33+
- Improve: a fresh session with no quality scores yet shows "warming up…" instead of an empty gap.
34+
35+
## 0.1.1
36+
37+
- Fix: the status bar now reflects the Claude Code session for **this** window (scoped to the workspace folder), instead of whichever session was most recently active anywhere. Resolves wrong scores showing in one window when another session is running.
38+
339
## 0.1.0
440

541
- Initial release: Token Optimizer status line in the VS Code status bar.

vscode-extension/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "token-optimizer-statusline",
33
"displayName": "Token Optimizer",
44
"description": "See your Claude Code context health, efficiency, and usage limits in the VS Code status bar.",
5-
"version": "0.1.0",
5+
"version": "0.1.8",
66
"publisher": "alexgreensh",
77
"license": "PolyForm-Noncommercial-1.0.0",
88
"icon": "icon.png",
@@ -21,6 +21,10 @@
2121
"activationEvents": ["onStartupFinished"],
2222
"contributes": {
2323
"commands": [
24+
{
25+
"command": "tokenOptimizer.showStatus",
26+
"title": "Token Optimizer: Show Status Panel"
27+
},
2428
{
2529
"command": "tokenOptimizer.openDashboard",
2630
"title": "Token Optimizer: Open Dashboard"

vscode-extension/src/cacheReader.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@
33
// unit-testable; the actual fs reads live in dataSource.
44
import { Snapshot, RateLimits, AgentInfo, emptySnapshot } from './types';
55
import { parseRateWindow } from './rateWindow';
6+
import { sanitizeSessionId } from './paths';
7+
import { windowForModel } from './jsonlTail';
68

79
const STALE_QUALITY_SECONDS = 300; // mirror statusline.js: score older than 5min => stale
810

911
export interface RawInputs {
1012
qualityJson: string | null;
1113
liveFillJson: string | null;
1214
rateLimitsJson: string | null;
13-
jsonlFill: number | null;
15+
jsonlTokens: number | null; // raw context tokens from the transcript tail
1416
jsonlModel: string | null;
1517
effort: string | null;
1618
sessionId: string | null; // to confirm the cache belongs to the active session
19+
scoped: boolean; // resolved via the window's workspace folder?
1720
nowMs: number;
1821
staleAfterSeconds: number; // for rate-limit staleness labeling
1922
}
@@ -64,15 +67,54 @@ export function buildSnapshot(inputs: RawInputs): Snapshot {
6467

6568
snap.model = inputs.jsonlModel;
6669
snap.effort = inputs.effort;
67-
68-
// ---- Context fill: live-fill.json wins (authoritative from statusline),
69-
// else JSONL tail. ----
70-
if (live && typeof live.used_percentage === 'number') {
71-
snap.fillPct = Math.max(0, Math.min(100, Math.round(live.used_percentage)));
70+
snap.scoped = inputs.scoped;
71+
72+
// ---- Context fill ----
73+
// The only fully reliable per-session fill = the transcript's own token count
74+
// divided by the REAL context window. The token count comes from the JSONL
75+
// tail (accurate per-session); the window size comes from the quality-cache's
76+
// `model_context_window` (so 1M-context sessions aren't mis-scored against
77+
// 200k). We do NOT trust:
78+
// - the global live-fill.json unless its session_id matches (it's the last
79+
// terminal's fill, leaks across windows), and
80+
// - the quality-cache's own fill_pct, which measure.py can write as 0 when it
81+
// can't attribute fill (same global-leak problem on the plugin side).
82+
// Order: matched live-fill (authoritative) → JSONL tokens ÷ window → qc.fill_pct.
83+
const liveSessionId =
84+
live && live.session_id ? sanitizeSessionId(String(live.session_id)) : null;
85+
const liveMatchesSession =
86+
live &&
87+
typeof live.used_percentage === 'number' &&
88+
!!liveSessionId &&
89+
!!inputs.sessionId &&
90+
liveSessionId === inputs.sessionId;
91+
const windowTokens =
92+
isPlainObject(q) &&
93+
typeof q.model_context_window === 'number' &&
94+
Number.isFinite(q.model_context_window) &&
95+
q.model_context_window > 0
96+
? q.model_context_window
97+
: windowForModel(inputs.jsonlModel);
98+
const jsonlFill =
99+
inputs.jsonlTokens != null && Number.isFinite(inputs.jsonlTokens)
100+
? clampScore((inputs.jsonlTokens / windowTokens) * 100)
101+
: null;
102+
// Only trust a quality-cache fill that rounds above 0 — measure.py writes 0
103+
// when it couldn't attribute fill, which we'd rather skip than display.
104+
const qcFillRounded =
105+
isPlainObject(q) && typeof q.fill_pct === 'number' && Number.isFinite(q.fill_pct)
106+
? clampScore(q.fill_pct)
107+
: null;
108+
const qcFill = qcFillRounded != null && qcFillRounded > 0 ? qcFillRounded : null;
109+
if (liveMatchesSession) {
110+
snap.fillPct = clampScore(live.used_percentage);
72111
snap.fillSource = 'live-fill';
73-
} else if (inputs.jsonlFill != null) {
74-
snap.fillPct = inputs.jsonlFill;
112+
} else if (jsonlFill != null) {
113+
snap.fillPct = jsonlFill;
75114
snap.fillSource = 'jsonl';
115+
} else if (qcFill != null) {
116+
snap.fillPct = qcFill;
117+
snap.fillSource = 'quality';
76118
}
77119

78120
// ---- Quality scores ----
@@ -108,8 +150,6 @@ export function buildSnapshot(inputs: RawInputs): Snapshot {
108150
level: q.fill_warning.level,
109151
value: Math.round(q.fill_warning.fill_pct || 0),
110152
};
111-
} else if (q.regime_change && typeof q.regime_change.fill_pct === 'number') {
112-
snap.regimeChangeFillPct = Math.round(q.regime_change.fill_pct);
113153
}
114154

115155
if (q.tool_call_warning && q.tool_call_warning.level) {

vscode-extension/src/dataSource.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const EFFORT_MAP: Record<string, string> = { low: 'lo', medium: 'med', high: 'hi
1919
export class DataSource {
2020
private watcher: vscode.FileSystemWatcher | undefined;
2121
private focusSub: vscode.Disposable | undefined;
22+
private workspaceSub: vscode.Disposable | undefined;
2223
private timer: NodeJS.Timeout | undefined;
2324
private debounce: NodeJS.Timeout | undefined;
2425
private tailer: JsonlTailer | undefined;
@@ -67,11 +68,29 @@ export class DataSource {
6768
} catch {
6869
// window state API unavailable — timer + watcher still cover us.
6970
}
71+
try {
72+
this.workspaceSub = vscode.workspace.onDidChangeWorkspaceFolders(() => {
73+
this.needsRescan = true;
74+
this.refresh();
75+
});
76+
} catch {
77+
// ignore — folder rarely changes mid-session.
78+
}
7079
// Defer the first (synchronous, fs-walking) refresh off the activation path
7180
// so activate() returns immediately and never trips VS Code's >500ms watchdog.
7281
setImmediate(() => this.refresh());
7382
}
7483

84+
// The window's workspace folder, used to scope session resolution to this
85+
// window. First folder when a multi-root workspace; undefined if none open.
86+
private workspaceDir(): string | null {
87+
try {
88+
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? null;
89+
} catch {
90+
return null;
91+
}
92+
}
93+
7594
private isFocused(): boolean {
7695
try {
7796
return vscode.window.state.focused;
@@ -99,19 +118,21 @@ export class DataSource {
99118

100119
private buildFromDisk(): Snapshot {
101120
if (this.needsRescan) {
102-
this.cachedSession = findActiveSession(this.paths.projectsDir);
121+
this.cachedSession = findActiveSession(this.paths.projectsDir, {
122+
workspaceDir: this.workspaceDir(),
123+
});
103124
this.cachedEffort = this.readEffort();
104125
this.needsRescan = false;
105126
}
106127
const session = this.cachedSession;
107128

108-
let jsonlFill: number | null = null;
129+
let jsonlTokens: number | null = null;
109130
let jsonlModel: string | null = null;
110131
if (session) {
111132
if (!this.tailer) this.tailer = new JsonlTailer(session.jsonlPath);
112133
else this.tailer.setPath(session.jsonlPath);
113134
const tail = this.tailer.read();
114-
jsonlFill = tail.fillPct;
135+
jsonlTokens = tail.tokens;
115136
jsonlModel = tail.model;
116137
} else {
117138
// No session: drop the tailer so a future session starts from offset 0.
@@ -122,10 +143,11 @@ export class DataSource {
122143
qualityJson: session ? readIfExists(this.paths.qualityCache(session.sessionId)) : null,
123144
liveFillJson: readIfExists(this.paths.liveFill),
124145
rateLimitsJson: readIfExists(this.paths.rateLimits),
125-
jsonlFill,
146+
jsonlTokens,
126147
jsonlModel,
127148
effort: this.cachedEffort,
128149
sessionId: session ? session.sessionId : null,
150+
scoped: this.workspaceDir() != null,
129151
nowMs: Date.now(),
130152
staleAfterSeconds: this.getStaleAfterSeconds(),
131153
});
@@ -148,6 +170,7 @@ export class DataSource {
148170
if (this.timer) clearInterval(this.timer);
149171
this.watcher?.dispose();
150172
this.focusSub?.dispose();
173+
this.workspaceSub?.dispose();
151174
}
152175
}
153176

0 commit comments

Comments
 (0)