lumira 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,15 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Real-time statusline HUD for Claude Code and Qwen Code — analytics, quota projection, themes, powerline",
8
- "version": "1.9.0",
9
- "homepage": "https://github.com/cativo23/lumira"
8
+ "version": "1.10.0"
10
9
  },
11
10
  "plugins": [
12
11
  {
13
12
  "name": "lumira",
14
13
  "source": "./",
15
14
  "description": "Real-time statusline HUD for Claude Code and Qwen Code. Session analytics, API latency widget, 7-day quota projection, auto-compact warnings, 7 themes, powerline support. Zero runtime dependencies. Run /lumira:setup after install to activate.",
16
- "version": "1.9.0",
15
+ "version": "1.10.0",
17
16
  "author": {
18
17
  "name": "Carlos Cativo"
19
18
  },
@@ -33,5 +32,5 @@
33
32
  ]
34
33
  }
35
34
  ],
36
- "version": "1.9.0"
35
+ "version": "1.10.0"
37
36
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumira",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Real-time statusline HUD for Claude Code and Qwen Code — session analytics, API latency, 7-day quota projection, auto-compact warnings, 7 themes, powerline. Zero runtime deps.",
5
5
  "author": {
6
6
  "name": "Carlos Cativo"
package/README.md CHANGED
@@ -8,6 +8,17 @@ Real-time statusline plugin for [Claude Code](https://code.claude.com) and Qwen
8
8
 
9
9
  ## Quick start
10
10
 
11
+ **Via Claude Code plugin (recommended):**
12
+
13
+ ```
14
+ /plugin marketplace add cativo23/lumira
15
+ /lumira:setup
16
+ ```
17
+
18
+ No npm required. The `/lumira:setup` skill writes `statusLine.command` automatically. Restart Claude Code when done.
19
+
20
+ **Via npm:**
21
+
11
22
  ```bash
12
23
  npx lumira install
13
24
  ```
@@ -24,13 +35,12 @@ Interactive wizard — preset, theme, icons — previewed live before write.
24
35
  ![Claude Code](https://img.shields.io/badge/Claude_Code-compatible-2d3748?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+PHBhdGggZD0iTTY0IDEyOEMzNS44IDEyOCAxMyAxMDUuMiAxMyA3N0MxMyA0OC44IDM1LjggMjYgNjQgMjZjMjguMiAwIDUxIDIyLjggNTEgNTFzLTIyLjggNTEtNTEgNTF6IiBmaWxsPSIjMjQyNTJGIi8+PC9zdmc+)
25
36
  ![Qwen Code](https://img.shields.io/badge/Qwen_Code-compatible-6156FF)
26
37
 
27
- > 3,400+ monthly downloads, zero marketing. Try it for one session `npx lumira install`.
28
-
29
- > **What's new in v1.8.2:** the installer now writes a fast per-render command — it runs the compiled `lumira` binary directly (~10× faster than `npx lumira@latest`, which hit the npm registry on every render) and migrates existing setups automatically. v1.8.1 brought the GSD widget to parity with get-shit-done (GSD)'s own statusline — phase/milestone lifecycle, a milestone progress bar, and `⬆ /gsd:update` / `⚠ stale hooks` indicators that show in any project (on by default, self-gating). Earlier releases added the compaction counter `⊙ N` (v1.8.0), added-dirs badge + worktree breadcrumb (v1.7.0), [`lumira stats` CLI](#stats-cli) (v1.5), `API N%` latency widget (v1.4.0), 7-day quota projection (v1.3.0), and the auto-compact proximity glyph ⚠ (v1.4.1).
38
+ > **What's new in v1.9.0:** lumira is now a **Claude Code plugin** — install with `/plugin marketplace add cativo23/lumira`, no npm required. Run `/lumira:setup` to activate. v1.8.2 made the installer write a fast per-render command (~10× faster). v1.8.1 brought the GSD widget to parity with GSD 1.42.3. Earlier: compaction counter `⊙ N` (v1.8.0), added-dirs badge + worktree breadcrumb (v1.7.0), [`lumira stats` CLI](#stats-cli) (v1.5), `API N%` latency widget (v1.4.0), 7-day quota projection (v1.3.0).
30
39
 
31
40
  ## Table of contents
32
41
 
33
42
  - [Why lumira?](#why-lumira)
43
+ - [How lumira compares](#how-lumira-compares)
34
44
  - [Requirements](#requirements)
35
45
  - [Features](#features)
36
46
  - [Install](#install)
@@ -54,7 +64,25 @@ Claude Code's default statusline shows the model name and current directory. Tha
54
64
  - **Active tools, agents, and todo progress** — parsed from the live transcript, updated every render.
55
65
  - **Cross-platform** — same config drives Claude Code and Qwen Code; Qwen sessions auto-collapse to single-line.
56
66
 
57
- Inspired by [claude-hud](https://github.com/jarrodwatts/claude-hud); takes a different stance on opt-in powerline rendering, theme contrast guarantees, and Qwen Code compatibility.
67
+ Inspired by [GSD's](https://github.com/open-gsd/gsd-core) statusline; takes its own stance on opt-in powerline rendering, theme contrast guarantees, and Qwen Code compatibility.
68
+
69
+ ## How lumira compares
70
+
71
+ The Claude Code statusline space has several good tools. Here's an honest head-to-head on **features** against the other true statuslines (feature claims checked against each tool's current README, 2026-06-14):
72
+
73
+ | Tool | Runtime / deps | Distribution | Platforms | Config UX | Powerline + themes | Session-intel widgets |
74
+ |---|---|---|---|---|---|---|
75
+ | **lumira** | TS / **0 runtime deps** | npm + npx + plugin (+ Qwen skill) | **Claude Code + Qwen Code** | Wizard + JSON + CLI flags | Yes (7 styles) + 7 themes, **WCAG-AA guard** | **Quota projection, pace delta, API-latency, auto-compact glyph + counter, cache, agents, MCP, todos, tools** + `stats` CLI |
76
+ | ccstatusline | TS / bundled | npm + npx | Claude Code | **Ink TUI** (live preview) | Yes + themes | Context, cost, usage %, block timer, compaction count, git; no quota/pace/latency |
77
+ | claude-hud | JS / Node 18+ | Plugin marketplace | Claude Code (v1.0.80+) | Guided `/configure` + JSON | No / no themes | Context, 5h/7d usage, cost, git, tools, agents, todos, cache TTL; no quota ETA/pace/latency |
78
+ | CCometixLine | Rust binary | npm + binary + source | Claude Code | TUI (TOML) | Yes + themes | Model, dir, git, context %, usage, cost, time, output-style |
79
+ | claude-pace | Bash + jq | curl + plugin + npx | Claude Code 2.1.80+ | JSON block | No / no | 5h+7d %, pace delta, reset countdown, git diff; ~10ms (lightest) |
80
+ | cship | Rust binary | binary / script / cargo | Claude Code | TOML (Starship-style) | Yes (Starship) + themes | Cost, context bar, usage limits, model, effort, agent, session, peak-time |
81
+ | starship-claude | Shell / needs Starship | Plugin + manual | Claude Code (no tmux) | Wizard + TOML | Via Starship + palettes | Context bar, model, session |
82
+
83
+ **Where lumira leads:** breadth of session-intelligence widgets — sole owner of an API-latency widget, a 7-day quota *projection ETA*, an MCP-server count, and a bundled `stats` analytics CLI, on top of the full pace / agents / todos / cache set. Plus zero runtime deps, dual-platform (Claude Code **and** Qwen Code), and WCAG-AA contrast enforced in CI on every theme. **Where it doesn't:** no Ink-style interactive widget builder — config is a wizard + JSON, not a live drag-and-drop TUI.
84
+
85
+ See [`docs/competitive-comparison.md`](docs/competitive-comparison.md) for the full per-widget matrix, config-UX detail, and distribution breakdown across every tool.
58
86
 
59
87
  ## Requirements
60
88
 
@@ -95,15 +123,26 @@ Inspired by [claude-hud](https://github.com/jarrodwatts/claude-hud); takes a dif
95
123
 
96
124
  ## Install
97
125
 
98
- The wizard at the top is the fastest path. For the long form:
126
+ ### Option 1 Claude Code plugin (recommended)
127
+
128
+ No npm required. Works with the Claude Code plugin marketplace:
129
+
130
+ ```
131
+ /plugin marketplace add cativo23/lumira
132
+ /lumira:setup
133
+ ```
134
+
135
+ `/lumira:setup` finds the cached binary, writes `statusLine.command` to `~/.claude/settings.json`, and creates a default config. Restart Claude Code when done. To customize afterward: `/lumira:lumira`.
136
+
137
+ ### Option 2 — npm
99
138
 
100
139
  ```bash
101
140
  npx lumira install
102
141
  ```
103
142
 
104
- The installer walks you through three choices — **preset** (`full` / `balanced` / `minimal`), **theme**, and **icons** — showing a live preview at each step. Press `Esc` to abort without writing anything. In non-interactive shells (piped stdin, CI), the installer skips the wizard and writes sensible defaults (`preset: balanced`, `icons: nerd`). If Qwen Code is detected (`~/.qwen/` exists), the `/lumira` skill is installed for both CLIs.
143
+ The installer walks you through **preset** (`full` / `balanced` / `minimal`), **theme**, and **icons** — showing a live preview at each step. Press `Esc` to abort without writing anything. In non-interactive shells (piped stdin, CI), the installer skips the wizard and writes sensible defaults (`preset: balanced`, `icons: nerd`). If Qwen Code is detected (`~/.qwen/` exists), the `/lumira` skill is installed for both CLIs.
105
144
 
106
- For the fastest statusline (the command runs on **every** render), the installer offers to install lumira globally so it can invoke the compiled binary directly (`lumira`, ~60ms) instead of `npx` (which is ~10× slower). It also migrates older `npx lumira@latest` setups to the faster form automatically.
145
+ For the fastest statusline (the command runs on **every** render), the installer offers to install lumira globally so it can invoke the compiled binary directly (`lumira`, ~60ms) instead of `npx` (~10× slower). It also migrates older `npx lumira@latest` setups to the faster form automatically.
107
146
 
108
147
  Or install globally:
109
148
 
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from 'node:url';
3
- import { realpathSync } from 'node:fs';
3
+ import { realpathSync, readFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { join, dirname } from 'node:path';
6
6
  import { readStdin as defaultReadStdin, StdinParseError } from './stdin.js';
7
7
  import { parseGitStatus } from './parsers/git.js';
8
8
  import { parseTranscript } from './parsers/transcript.js';
@@ -98,9 +98,49 @@ function isDirectRun() {
98
98
  return false;
99
99
  }
100
100
  }
101
+ function getVersion() {
102
+ try {
103
+ // Assumes this file lives at dist/index.js — one level below package.json.
104
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), '../package.json');
105
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
106
+ return pkg.version ?? 'unknown';
107
+ }
108
+ catch {
109
+ return 'unknown';
110
+ }
111
+ }
112
+ function printHelp() {
113
+ const version = getVersion();
114
+ const usage = `lumira v${version}
115
+
116
+ Usage: lumira [command]
117
+
118
+ Commands:
119
+ install Interactive setup wizard
120
+ uninstall Remove statusline configuration
121
+ stats [path] Show session statistics
122
+ themes Browse and preview themes
123
+ custom Manage custom commands
124
+
125
+ Options:
126
+ --help, -h Show this help
127
+ --version, -v Print version
128
+
129
+ Run lumira with no arguments to render the statusline (requires Claude Code stdin).
130
+ `;
131
+ process.stdout.write(usage);
132
+ }
101
133
  if (isDirectRun()) {
102
134
  const cmd = process.argv[2];
103
- if (cmd === 'install') {
135
+ if (cmd === '--help' || cmd === '-h') {
136
+ printHelp();
137
+ process.exit(0);
138
+ }
139
+ else if (cmd === '--version' || cmd === '-v') {
140
+ process.stdout.write(`${getVersion()}\n`);
141
+ process.exit(0);
142
+ }
143
+ else if (cmd === 'install') {
104
144
  const configPath = join(homedir(), '.config', 'lumira', 'config.json');
105
145
  install({ configPath }).then(o => process.stdout.write(o)).catch(e => process.stderr.write(`Install error: ${e.message}\n`));
106
146
  }
@@ -147,6 +187,10 @@ if (isDirectRun()) {
147
187
  // event loop refed. Reads the spec JSON from its own stdin.
148
188
  runCustomRefreshFromStdin().then(() => process.exit(0)).catch(() => process.exit(0));
149
189
  }
190
+ else if (cmd !== undefined) {
191
+ process.stderr.write(`Unknown command: ${cmd}. Run with --help for usage.\n`);
192
+ process.exit(1);
193
+ }
150
194
  else {
151
195
  main().then(o => process.stdout.write(o)).catch(e => { if (!(e instanceof StdinParseError))
152
196
  process.stderr.write(`Statusline error: ${e.message}\n`); });
package/dist/normalize.js CHANGED
@@ -29,8 +29,12 @@ export function sanitizeTermString(s) {
29
29
  /** Allowed values for the reasoning effort level field (CC ≥ 2.1.x). */
30
30
  const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
31
31
  /**
32
- * Sum all four token categories from `context_window.current_usage` to compute
33
- * a real context usage total (input + output + cache_read + cache_creation).
32
+ * Sum input token categories from `context_window.current_usage` to compute
33
+ * a real context usage total (input + cache_read + cache_creation).
34
+ * Excludes output_tokens: they are per-turn and reset each call, which would
35
+ * cause the context bar to jitter (jump down at the start of every new turn).
36
+ * Context window fill is determined by what was READ from the context, not
37
+ * by how many tokens were output.
34
38
  * Returns undefined when `cu` is absent or not an object shape.
35
39
  */
36
40
  function getRealUsageTotal(cu) {
@@ -38,7 +42,6 @@ function getRealUsageTotal(cu) {
38
42
  return undefined;
39
43
  const obj = cu;
40
44
  const total = (obj.input_tokens ?? 0)
41
- + (obj.output_tokens ?? 0)
42
45
  + (obj.cache_read_input_tokens ?? 0)
43
46
  + (obj.cache_creation_input_tokens ?? 0);
44
47
  return total;
@@ -100,9 +103,9 @@ export function normalize(input) {
100
103
  if (claude) {
101
104
  ({ denominator: cacheTurnDenominator } = getCacheFields(claude.context_window?.current_usage));
102
105
  }
103
- // Real context usage percentage (Claude only): includes output tokens in the numerator,
104
- // unlike the hook-provided `used_percentage` which excludes them. This gives an accurate
105
- // picture of actual context consumption, especially near auto-compact thresholds.
106
+ // Real context usage percentage (Claude only): sums input + cache_read + cache_creation
107
+ // (output_tokens excluded — per-turn, resets each call, causes bar jitter). More stable
108
+ // than the hook-provided `used_percentage` near auto-compact thresholds.
106
109
  let realUsedPercentage;
107
110
  if (claude) {
108
111
  const total = getRealUsageTotal(claude.context_window?.current_usage);
@@ -113,10 +116,25 @@ export function normalize(input) {
113
116
  }
114
117
  // Auto-compact proximity warning: fires when context fill is in the
115
118
  // [threshold-gap, threshold) window. Uses realUsedPercentage when available
116
- // (more accurate; includes output+cache), falls back to usedPercentage for
119
+ // (more accurate; excludes output tokens), falls back to usedPercentage for
117
120
  // legacy payloads. Gated by platform (different thresholds Claude vs Qwen).
121
+ // For claude-code, honors CLAUDE_CODE_AUTO_COMPACT_WINDOW env var — a fill-%
122
+ // threshold (1-100) that mirrors Claude Code's own auto-compact trigger point.
123
+ // Users who changed this setting in Claude Code should set the same value here.
124
+ // Falls back to the hardcoded 80% default when absent or invalid.
118
125
  const effectivePct = realUsedPercentage ?? contextWindow.used_percentage ?? 0;
119
- const platformAutoCompactThreshold = AUTO_COMPACT_THRESHOLD[platform];
126
+ let platformAutoCompactThreshold = AUTO_COMPACT_THRESHOLD[platform];
127
+ if (platform === 'claude-code') {
128
+ const envVal = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
129
+ if (envVal !== undefined) {
130
+ // Use Number() + Number.isInteger() so floats ("75.5") and trailing-junk
131
+ // strings ("80abc") are rejected rather than silently truncated by parseInt.
132
+ const parsed = Number(envVal);
133
+ if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 100) {
134
+ platformAutoCompactThreshold = parsed;
135
+ }
136
+ }
137
+ }
120
138
  const nearAutoCompact = effectivePct >= (platformAutoCompactThreshold - AUTO_COMPACT_WARNING_GAP)
121
139
  && effectivePct < platformAutoCompactThreshold;
122
140
  // Performance (Qwen only)
@@ -83,6 +83,22 @@ export function parseStateMd(content) {
83
83
  state.phaseTotal = phaseMatch[2];
84
84
  state.phaseName = phaseMatch[3];
85
85
  }
86
+ // Plan progress within the current phase (gsd-core ≥ 1.4.x). Accepts both the
87
+ // compound "X of Y in current phase" and the bare "X of Y" forms. A "Plan: —"
88
+ // (not started) line has no digits and is correctly skipped.
89
+ const planMatch = content.match(/^Plan:\s*(\d+)\s+of\s+(\d+)/m);
90
+ if (planMatch) {
91
+ state.planNum = planMatch[1];
92
+ state.planTotal = planMatch[2];
93
+ }
94
+ // Resume point (gsd-core ≥ 1.4.x). "Resume file: None" means no active resume
95
+ // point and is treated as absent.
96
+ const resumeMatch = content.match(/^Resume file:\s*(.+)/m);
97
+ if (resumeMatch) {
98
+ const path = resumeMatch[1].trim();
99
+ if (path && path.toLowerCase() !== 'none')
100
+ state.resumeFile = path;
101
+ }
86
102
  if (!state.status) {
87
103
  // Fallback: parse body Status line when frontmatter status is missing
88
104
  const bodyStatus = content.match(/^Status:\s*(.+)/m);
@@ -145,9 +161,13 @@ function formatState(s) {
145
161
  }
146
162
  // Scene selection: activePhase → nextAction → milestone-complete → default
147
163
  const phasesStr = s.nextPhases?.length ? s.nextPhases.join('/') : null;
164
+ // Plan progress within the phase (gsd-core ≥ 1.4.x), appended to the phase
165
+ // descriptor in the active/default scenes — e.g. "auth (3/5) p2/9".
166
+ const planSuffix = s.planNum && s.planTotal ? ` p${s.planNum}/${s.planTotal}` : '';
148
167
  if (s.activePhase) {
149
168
  // Scene 1: activePhase (with optional status)
150
- parts.push(s.status ? `Phase ${s.activePhase} ${s.status}` : `Phase ${s.activePhase}`);
169
+ const phase = s.status ? `Phase ${s.activePhase} ${s.status}` : `Phase ${s.activePhase}`;
170
+ parts.push(`${phase}${planSuffix}`);
151
171
  }
152
172
  else if (s.nextAction && phasesStr) {
153
173
  // Scene 2: nextAction + phases when idle
@@ -163,7 +183,7 @@ function formatState(s) {
163
183
  parts.push(s.status);
164
184
  if (s.phaseNum && s.phaseTotal) {
165
185
  const phase = s.phaseName ? `${s.phaseName} (${s.phaseNum}/${s.phaseTotal})` : `ph ${s.phaseNum}/${s.phaseTotal}`;
166
- parts.push(phase);
186
+ parts.push(`${phase}${planSuffix}`);
167
187
  }
168
188
  }
169
189
  return parts.join(' · ');
@@ -185,14 +205,16 @@ function semverCompare(a, b) {
185
205
  return 0;
186
206
  }
187
207
  /**
188
- * Read GSD update-check cache. Checks the shared tool-agnostic cache first
189
- * (`~/.cache/gsd/`, introduced by GSD #1421), then falls back to the legacy
190
- * per-runtime location (`~/.claude/cache/`) for older GSD installs.
208
+ * Read GSD update-check cache. Checks candidates in order first match wins:
209
+ * 1. open-gsd per-package cache (`gsd-update-check-opengsd-gsd-core.json`, gsd-core 1.4.x)
210
+ * 2. Legacy shared tool-agnostic cache (`gsd-update-check.json`, get-shit-done-cc 1.x)
211
+ * 3. Legacy per-runtime location (`~/.claude/cache/`) for older installs
191
212
  * Returns update status, stale hooks flag, and dev install flag.
192
213
  */
193
- function readUpdateCache(sharedCacheFile, legacyCacheFile) {
214
+ function readUpdateCache(openGsdCacheFile, sharedCacheFile, legacyCacheFile) {
194
215
  const result = { updateAvailable: false, staleHooks: false, devInstall: false };
195
216
  const candidates = [
217
+ ['open-gsd', openGsdCacheFile],
196
218
  ['shared', sharedCacheFile],
197
219
  ['legacy', legacyCacheFile],
198
220
  ];
@@ -226,15 +248,19 @@ function readUpdateCache(sharedCacheFile, legacyCacheFile) {
226
248
  }
227
249
  export function getGsdInfo(cwd, opts = {}) {
228
250
  const claudeDir = opts.claudeDir ?? process.env['CLAUDE_CONFIG_DIR'] ?? join(homedir(), '.claude');
229
- const sharedCacheFile = opts.sharedCacheFile ?? join(homedir(), '.cache', 'gsd', 'gsd-update-check.json');
251
+ const gsdCacheDir = join(homedir(), '.cache', 'gsd');
252
+ const openGsdCacheFile = opts.openGsdCacheFile ?? join(gsdCacheDir, 'gsd-update-check-opengsd-gsd-core.json');
253
+ const sharedCacheFile = opts.sharedCacheFile ?? join(gsdCacheDir, 'gsd-update-check.json');
230
254
  const legacyCacheFile = join(claudeDir, 'cache', 'gsd-update-check.json');
231
- const cacheData = readUpdateCache(sharedCacheFile, legacyCacheFile);
255
+ const cacheData = readUpdateCache(openGsdCacheFile, sharedCacheFile, legacyCacheFile);
232
256
  let currentTask;
257
+ let hasResume = false;
233
258
  const stateFile = findStateMd(cwd || process.cwd());
234
259
  if (stateFile) {
235
260
  log('STATE.md found:', stateFile);
236
261
  try {
237
262
  const state = parseStateMd(readFileSync(stateFile, 'utf8'));
263
+ hasResume = state.resumeFile !== undefined;
238
264
  const formatted = formatState(state);
239
265
  if (formatted) {
240
266
  currentTask = sanitizeTermString(formatted);
@@ -257,6 +283,7 @@ export function getGsdInfo(cwd, opts = {}) {
257
283
  staleHooks: cacheData.staleHooks || undefined,
258
284
  devInstall: cacheData.devInstall || undefined,
259
285
  currentTask,
286
+ hasResume: hasResume || undefined,
260
287
  };
261
288
  }
262
289
  //# sourceMappingURL=gsd.js.map
@@ -53,6 +53,14 @@ export async function aggregateStats(transcriptPath) {
53
53
  throw new Error(`Transcript file not found: ${transcriptPath}`);
54
54
  }
55
55
  const stats = emptyStats();
56
+ // Dedup guard: Claude Code streams one JSONL entry per content block for
57
+ // the same logical message (thinking → text → tool_use all share one
58
+ // message.id). Each entry carries the full usage block for that turn, so
59
+ // naively accumulating every entry inflates tokens/cost by the number of
60
+ // content blocks. Track seen message IDs and skip usage+cost on repeats.
61
+ // Content extraction (tool_use, tool_result) is NOT gated — each block
62
+ // appears exactly once in its own entry and must still be counted.
63
+ const seenMessageIds = new Set();
56
64
  let fileStream = null;
57
65
  try {
58
66
  fileStream = createReadStream(resolved);
@@ -81,10 +89,20 @@ export async function aggregateStats(transcriptPath) {
81
89
  }
82
90
  }
83
91
  const message = (entry.message ?? null);
92
+ // Determine whether this is the first time we're seeing this message.id.
93
+ // Entries without a message.id (e.g. user turns, summary lines) are
94
+ // always treated as first-occurrence so they're never suppressed.
95
+ const messageId = message !== null && typeof message.id === 'string' ? message.id : null;
96
+ const isFirstOccurrence = messageId === null || !seenMessageIds.has(messageId);
97
+ if (messageId !== null)
98
+ seenMessageIds.add(messageId);
84
99
  // Usage block (assistant turns only). The mere presence of a usage
85
100
  // payload flips hasCostData=true even if all counts are zero — see
86
101
  // the "zero-cost-with-usage" test for why this matters.
87
- if (entry.type === 'assistant' && message && typeof message === 'object') {
102
+ //
103
+ // Gated on isFirstOccurrence: duplicate entries for the same message.id
104
+ // carry identical usage blocks; accumulating them inflates counts 2-3×.
105
+ if (isFirstOccurrence && entry.type === 'assistant' && message && typeof message === 'object') {
88
106
  const usage = message.usage;
89
107
  if (usage && typeof usage === 'object') {
90
108
  stats.hasCostData = true;
@@ -104,10 +122,14 @@ export async function aggregateStats(transcriptPath) {
104
122
  // undefined`), not truthiness — Anthropic emits `total_cost_usd: 0` for
105
123
  // fully-cached turns, and a `topCost || msgCost` short-circuit would
106
124
  // incorrectly fall through to the message field in that case.
107
- const topCost = safeNumber(entry.total_cost_usd);
108
- const msgCost = message ? safeNumber(message.total_cost_usd) : 0;
109
- const costContribution = entry.total_cost_usd !== undefined ? topCost : msgCost;
110
- stats.costUsd += costContribution;
125
+ //
126
+ // Also gated on isFirstOccurrence for the same dedup reason as usage above.
127
+ if (isFirstOccurrence) {
128
+ const topCost = safeNumber(entry.total_cost_usd);
129
+ const msgCost = message ? safeNumber(message.total_cost_usd) : 0;
130
+ const costContribution = entry.total_cost_usd !== undefined ? topCost : msgCost;
131
+ stats.costUsd += costContribution;
132
+ }
111
133
  // Tool / agent / error extraction from the message.content array.
112
134
  const content = message?.content;
113
135
  if (!Array.isArray(content))
@@ -13,6 +13,12 @@ export function renderLine4(ctx, c) {
13
13
  if (gsd.currentTask) {
14
14
  parts.push(c.bold(`${icons.hammer} ${truncField(gsd.currentTask, 60)}`));
15
15
  }
16
+ // Resume-point indicator (gsd-core ≥ 1.4.x). A ↩ glyph signals STATE.md has
17
+ // an active `.continue-here`/spec resume file — a cue that work can be picked
18
+ // up where it left off. Cyan: informational, distinct from update/stale warns.
19
+ if (gsd.hasResume) {
20
+ parts.push(c.cyan('↩'));
21
+ }
16
22
  if (gsd.updateAvailable) {
17
23
  parts.push(c.yellow('⬆ /gsd:update'));
18
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumira",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Real-time statusline HUD for Claude Code and Qwen Code. Includes session analytics CLI, API latency overhead widget, 7d quota projection, auto-compact proximity warnings, themes, and powerline. Zero deps.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,7 +16,17 @@ You activate the **lumira** statusline for Claude Code after plugin installation
16
16
  3. **Read settings** — Read `~/.claude/settings.json`. If the file is **missing**, start from `{}`. If it is **present but invalid JSON**, stop and report the parse error — do not overwrite.
17
17
  4. **Check existing** — If `statusLine.command` is already set to any non-empty value, tell the user what's currently set and ask if they want to replace it. Do not proceed until they confirm. If it is empty or absent, skip this step.
18
18
  5. **Write command** — Set `statusLine.command` to `node "<installPath>/dist/index.js"`.
19
- 6. **Init config** — If `~/.config/lumira/config.json` does not exist, create it with `{"preset": "balanced"}`. Never overwrite an existing config.
19
+ 6. **Init config** — Only create `~/.config/lumira/config.json` if it does not already exist. Run the following Bash — the `test -f` guard is mandatory, do not skip it:
20
+ ```bash
21
+ if [ ! -f ~/.config/lumira/config.json ]; then
22
+ mkdir -p ~/.config/lumira
23
+ printf '{"preset": "balanced"}\n' > ~/.config/lumira/config.json
24
+ echo "Created default config at ~/.config/lumira/config.json"
25
+ else
26
+ echo "Config already exists at ~/.config/lumira/config.json — not modified"
27
+ fi
28
+ ```
29
+ Never overwrite an existing config under any circumstances.
20
30
  7. **Confirm and instruct** — Tell the user what was written and that they must restart Claude Code.
21
31
 
22
32
  ## Finding the install path