lumira 1.13.0 → 1.14.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,14 +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.13.0"
8
+ "version": "1.14.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "lumira",
13
13
  "source": "./",
14
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.",
15
- "version": "1.13.0",
15
+ "version": "1.14.0",
16
16
  "author": {
17
17
  "name": "Carlos Cativo"
18
18
  },
@@ -32,5 +32,5 @@
32
32
  ]
33
33
  }
34
34
  ],
35
- "version": "1.13.0"
35
+ "version": "1.14.0"
36
36
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumira",
3
- "version": "1.13.0",
3
+ "version": "1.14.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
@@ -111,6 +111,7 @@ See [`docs/competitive-comparison.md`](docs/competitive-comparison.md) for the f
111
111
  - **Pace delta** — `usedPct − elapsedPct` of the 5h window. Turtle when behind pace (healthy), car with time-to-exhaustion when ahead. Color escalates green → yellow → orange → blinkRed. Toggle independently via `display.paceDelta`.
112
112
  - **7d quota projection** — when the current burn rate would exhaust the 7d quota before the window resets, the 7d segment grows a warning: `⚠ ~24h`, `⚠ ~2d`, `⚠ Tue`, or `🔥 ~8h` (critical icon under 12h). Default on. Toggle via `display.quotaProjection`; off in the `minimal` preset. Different from pace delta — pace looks backwards at the 5h window's actual vs proportional burn, projection looks forwards at the 7d window's exhaustion ETA.
113
113
  - **Active agents** — live count of running subagents (`⚡N agents`) plus types parsed from the transcript. Toggle via `display.agents`.
114
+ - **Subagent panel rows** — custom rendering for each row in Claude Code's agent panel via [`lumira subagent`](#subagent-rows): a state glyph (running / done / error), what each agent is doing, and its token count, in your configured theme and icon set. Opt-in at install.
114
115
  - **Cache hit rate** — prompt cache efficiency for the current turn (`87%⚡`). Alarm-mode: hidden while ≥90% (Anthropic's prompt cache pins this near 99% in healthy steady state, so an always-on number is wallpaper, not signal). Surfaces as yellow/orange/blinkRed only when the cache is actually degrading. Same hide-when-healthy pattern as rate-limits and agent count.
115
116
  - **GSD integration** — current task and update notifications (opt-in).
116
117
  - **Config health widget** — surfaces silent fallbacks (theme/powerline degrading in named-ANSI, missing GSD STATE.md). Opt-in.
@@ -259,6 +260,29 @@ lumira stats
259
260
 
260
261
  **Qwen Code sessions** are parsed the same way, but cost and burn-rate lines are suppressed when the transcript lacks usage blocks (`hasCostData: false` in the JSON output) — no misleading `$0.00`.
261
262
 
263
+ ## Subagent rows
264
+
265
+ Claude Code (≥ 2.1.x) exposes a [`subagentStatusLine`](https://code.claude.com/docs/en/statusline#subagent-status-lines) hook that lets a command customize how each subagent renders in the agent panel — replacing the default `name · description · token count` row. `lumira subagent` reads the visible tasks from stdin and renders each row in your configured theme and icon set:
266
+
267
+ ```
268
+ 󱎫 reviewing auth module · 15k tok · running
269
+ ✓ exploring the codebase · 3k tok · done
270
+ ```
271
+
272
+ The **glyph and colour are driven by state** — running (cyan clock), done (green check), error (red warning) — so a glance tells you what's live, what finished, what failed.
273
+
274
+ The **row label** identifies each agent. In practice Claude Code reports every Task subagent with the generic `type: "local_agent"` and no `name`, so the per-agent `description` is the only field that distinguishes one row from another — lumira uses it. The full fallback is `name → a meaningful type → description → id`: an explicit `name` or a real agent type wins when Claude Code provides one, otherwise the `description` carries the row. Rows respect your `icons` (`nerd`/`emoji`/`none`) and `colors.mode` config, and are truncated to the width Claude Code budgets.
275
+
276
+ **Enable it:** the installer offers to register `subagentStatusLine` alongside the main statusline (opt-in — it never writes the second key without asking). To wire it manually, add to `~/.claude/settings.json`:
277
+
278
+ ```json
279
+ {
280
+ "subagentStatusLine": { "type": "command", "command": "lumira subagent", "padding": 0 }
281
+ }
282
+ ```
283
+
284
+ It's robust by design: on any unreadable or malformed payload it emits nothing and exits 0, so Claude Code falls back to its default rows — a bad render never breaks the panel. Set `LUMIRA_DEBUG=1` to log the raw payload Claude Code sends to stderr.
285
+
262
286
  ## Powerline
263
287
 
264
288
  `style: "powerline"` (or `--powerline`) renders the statusline with colored segment backgrounds and glyph separators inspired by powerline-go / oh-my-posh. Available separator presets via `powerline.style` (or `--powerline-style=<name>`):
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `lumira subagent` subcommand (issue #176 — `subagentStatusLine` renderer).
3
+ *
4
+ * Claude Code (≥ 2.1.x) lets a command customize how each subagent row renders
5
+ * in the agent panel. CC pipes a JSON object on stdin describing every visible
6
+ * subagent and expects ONE JSON line back per row (`{ id, content }`). Omitting
7
+ * an id keeps CC's default rendering for that row; empty content hides it.
8
+ *
9
+ * Input schema (distinct from the main statusline's `RawInput`):
10
+ * { columns: number, tasks: [ { id, name, type, status, description,
11
+ * label, startTime, tokenCount, ... } ] }
12
+ *
13
+ * Icon choice is by STATE, not agent type. CC's `type` field is open-ended and
14
+ * user-defined (dozens of agent types in a real setup), so there's no stable
15
+ * type→glyph map to maintain — but `running/completed/error` map cleanly onto
16
+ * glyphs lumira already ships across all three icon sets. The state also drives
17
+ * the colour, so a glance tells you what's live vs done vs failed.
18
+ *
19
+ * Robustness contract: this runs inside CC's render loop. On ANY bad input
20
+ * (unreadable stdin, malformed JSON, missing fields) it emits nothing and exits
21
+ * 0 — CC then falls back to its default rows. It must never crash the panel.
22
+ */
23
+ import { formatTokens } from '../utils/format.js';
24
+ import { displayWidth, truncField } from '../render/text.js';
25
+ import { resolveIcons } from '../render/icons.js';
26
+ import { createColors, detectColorMode } from '../render/colors.js';
27
+ import { loadConfig } from '../config.js';
28
+ import { readStdin } from '../stdin.js';
29
+ import { debug } from '../utils/debug.js';
30
+ /** Per-state presentation: glyph + colour + human label. */
31
+ function styleFor(status, icons, colors) {
32
+ switch (status) {
33
+ case 'running':
34
+ return { icon: icons.clock, color: colors.cyan, label: 'running' };
35
+ case 'completed':
36
+ return { icon: icons.checkmark, color: colors.green, label: 'done' };
37
+ case 'error':
38
+ case 'failed':
39
+ return { icon: icons.warning, color: colors.red, label: 'error' };
40
+ default:
41
+ // Unknown/absent state: no glyph, dim, pass the raw status through as the
42
+ // label so a future CC state is still legible instead of being dropped.
43
+ return { icon: '', color: colors.dim, label: status ?? '' };
44
+ }
45
+ }
46
+ /**
47
+ * Render the `content` string for a single subagent row:
48
+ * `<glyph> <name> · <tokens> · <state-label>`
49
+ *
50
+ * The glyph and label are state-coloured; tokens are dim. When `columns` is
51
+ * given, the name is truncated (with an ellipsis) so the visible row fits,
52
+ * reserving width for the fixed glyph/meta first.
53
+ */
54
+ export function renderSubagentContent(task, icons, colors, columns) {
55
+ const { icon, color, label } = styleFor(task.status, icons, colors);
56
+ // Identity fallback. CC reports every Task subagent as the generic
57
+ // `type: "local_agent"` with no `name`, so `description` (the dispatch arg) is
58
+ // the only field that distinguishes rows — it must win over a generic type.
59
+ // A *meaningful* type (a real agent_type, if CC ever exposes one) still wins
60
+ // over description, and an explicit `name` wins over everything.
61
+ const meaningfulType = task.type && task.type !== 'local_agent' ? task.type : '';
62
+ const name = (task.name || meaningfulType || task.description || task.id || 'agent').trim();
63
+ const tokens = Number.isFinite(task.tokenCount) ? `${formatTokens(task.tokenCount)} tok` : '';
64
+ const prefix = icon ? `${color(icon)} ` : '';
65
+ const prefixW = icon ? displayWidth(icon) + 1 : 0;
66
+ // Plain (uncoloured) meta used both for measuring and for assembling the
67
+ // coloured version, so width math and output never drift apart.
68
+ const metaPlain = [tokens, label].filter(Boolean);
69
+ const metaW = metaPlain.length ? displayWidth(` · ${metaPlain.join(' · ')}`) : 0;
70
+ // Fit the name to whatever width is left after the fixed glyph + meta.
71
+ let shownName = name;
72
+ if (columns !== undefined && columns > 0) {
73
+ const budget = columns - prefixW - metaW;
74
+ if (budget < displayWidth(name))
75
+ shownName = budget > 1 ? truncField(name, budget) : '';
76
+ }
77
+ const metaColored = [];
78
+ if (tokens)
79
+ metaColored.push(colors.dim(tokens));
80
+ if (label)
81
+ metaColored.push(color(label));
82
+ const meta = metaColored.join(' · ');
83
+ // Avoid a dangling " · " when the name was truncated away entirely.
84
+ if (!shownName)
85
+ return `${prefix}${meta}`.trimEnd();
86
+ return meta ? `${prefix}${shownName} · ${meta}` : `${prefix}${shownName}`;
87
+ }
88
+ /**
89
+ * Render the full CC response: one `{ id, content }` JSON line per addressable
90
+ * task. Tasks without an id are skipped (CC keys rows by id, so a row we can't
91
+ * address can't be overridden). Empty/missing task list → empty string.
92
+ */
93
+ export function renderSubagentTasks(input, icons, colors) {
94
+ const tasks = Array.isArray(input?.tasks) ? input.tasks : [];
95
+ const columns = Number.isFinite(input?.columns) ? input.columns : undefined;
96
+ const lines = [];
97
+ for (const task of tasks) {
98
+ if (!task || typeof task.id !== 'string' || task.id === '')
99
+ continue;
100
+ lines.push(JSON.stringify({ id: task.id, content: renderSubagentContent(task, icons, colors, columns) }));
101
+ }
102
+ return lines.join('\n');
103
+ }
104
+ /**
105
+ * Wire stdin → config → render. Reuses the main `readStdin` (its only schema
106
+ * assertion is "is a plain object", which our payload satisfies). Any failure
107
+ * degrades to empty stdout + exit 0 so CC's panel is never broken.
108
+ */
109
+ export async function runSubagentCommand(opts = {}) {
110
+ const log = debug('subagent');
111
+ let raw;
112
+ try {
113
+ raw = await readStdin(opts.stream ?? process.stdin);
114
+ }
115
+ catch (e) {
116
+ log('stdin read failed:', e.message);
117
+ return { stdout: '', stderr: '', exitCode: 0 };
118
+ }
119
+ if (log.enabled)
120
+ log('payload:', raw);
121
+ const config = opts.config ?? loadConfig();
122
+ const icons = resolveIcons(config.icons);
123
+ const mode = opts.colorMode
124
+ ?? (config.colors?.mode && config.colors.mode !== 'auto' ? config.colors.mode : detectColorMode());
125
+ const colors = createColors(mode);
126
+ const out = renderSubagentTasks(raw, icons, colors);
127
+ return { stdout: out ? `${out}\n` : '', stderr: '', exitCode: 0 };
128
+ }
129
+ //# sourceMappingURL=subagent.js.map
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { runThemesCommand } from './commands/themes.js';
20
20
  import { runStatsCommand } from './commands/stats.js';
21
21
  import { runCustomRefreshFromStdin } from './commands/custom-refresh.js';
22
22
  import { runCustomCommand } from './commands/custom.js';
23
+ import { runSubagentCommand } from './commands/subagent.js';
23
24
  import { EMPTY_TRANSCRIPT } from './types.js';
24
25
  import { normalize } from './normalize.js';
25
26
  const defaultDeps = {
@@ -121,6 +122,7 @@ Commands:
121
122
  stats [path] Show session statistics
122
123
  themes Browse and preview themes
123
124
  custom Manage custom commands
125
+ subagent Render Claude Code agent-panel rows (reads tasks JSON from stdin)
124
126
 
125
127
  Options:
126
128
  --help, -h Show this help
@@ -181,6 +183,19 @@ if (isDirectRun()) {
181
183
  process.exit(1);
182
184
  });
183
185
  }
186
+ else if (cmd === 'subagent') {
187
+ // CC's subagentStatusLine hook: reads the tasks JSON from its own stdin and
188
+ // emits one JSON line per row. Degrades to empty output on any error so a
189
+ // bad payload never breaks CC's agent panel.
190
+ runSubagentCommand().then(r => {
191
+ if (r.stdout)
192
+ process.stdout.write(r.stdout);
193
+ if (r.stderr)
194
+ process.stderr.write(r.stderr);
195
+ if (r.exitCode !== 0)
196
+ process.exit(r.exitCode);
197
+ }).catch(() => process.exit(0));
198
+ }
184
199
  else if (cmd === '__custom-refresh') {
185
200
  // Internal: invoked by the renderer as a detached child to refresh a
186
201
  // single custom command's cache entry without keeping the renderer's
package/dist/installer.js CHANGED
@@ -25,6 +25,25 @@ const header = () => `\n${CYAN} lumira installer${RST}\n`;
25
25
  function makeStatusLine(command) {
26
26
  return { type: 'command', command, padding: 0 };
27
27
  }
28
+ /** Atomically write settings.json: temp file + fsync + rename, mode 0600. */
29
+ function writeSettingsAtomic(settings, settingsPath) {
30
+ mkdirSync(dirname(settingsPath), { recursive: true });
31
+ const tmp = `${settingsPath}.${process.pid}.${Date.now()}.lumira.tmp`;
32
+ try {
33
+ const fd = openSync(tmp, 'wx', 0o600);
34
+ writeSync(fd, JSON.stringify(settings, null, 2) + '\n');
35
+ fsyncSync(fd);
36
+ closeSync(fd);
37
+ renameSync(tmp, settingsPath);
38
+ }
39
+ catch (e) {
40
+ try {
41
+ unlinkSync(tmp);
42
+ }
43
+ catch { }
44
+ throw e;
45
+ }
46
+ }
28
47
  // Rank a statusLine command by per-render speed (higher = faster).
29
48
  // 3 = bare `lumira` binary — always resolves to the current installed version
30
49
  // 2 = node /path/dist/index.js or plugin-cache path — fast but version-pinned
@@ -153,6 +172,31 @@ function emitFooter(lines, homeOverride) {
153
172
  lines.push(`\n Restart Claude Code to see your statusline.\n`);
154
173
  }
155
174
  // ── Install ─────────────────────────────────────────────────────────
175
+ /**
176
+ * Optionally register Claude Code's `subagentStatusLine` hook (CC ≥ 2.1.x)
177
+ * alongside the main statusLine, pointing it at `<cmd> subagent`.
178
+ *
179
+ * Opt-in and interactive-only: we never add a second settings key without
180
+ * explicit consent, and skip silently in non-TTY runs (so CI/scripted installs
181
+ * stay predictable). No-op when it already points at lumira. Returns true when
182
+ * the key was added, so the caller knows it must flush settings to disk.
183
+ */
184
+ async function maybeRegisterSubagent(args) {
185
+ const { settings, baseCmd, confirm, isTTY, lines } = args;
186
+ // Only register when the key is absent. If it's already lumira there's nothing
187
+ // to do; if it's a *foreign* command we leave it untouched rather than clobber
188
+ // a user's own subagent renderer without an explicit backup/replace flow.
189
+ if (settings.subagentStatusLine != null)
190
+ return false;
191
+ if (!isTTY)
192
+ return false;
193
+ const accepted = await confirm('Customize subagent panel rows too? (subagentStatusLine)');
194
+ if (!accepted)
195
+ return false;
196
+ settings.subagentStatusLine = makeStatusLine(`${baseCmd} subagent`);
197
+ lines.push(ok(`Configured subagentStatusLine → ${DIM}${baseCmd} subagent${RST}`));
198
+ return true;
199
+ }
156
200
  export async function install(opts = {}) {
157
201
  const settingsPath = opts.settingsPath ?? defaultSettingsPath();
158
202
  const configPath = opts.configPath ?? defaultConfigPath();
@@ -243,6 +287,11 @@ export async function install(opts = {}) {
243
287
  // here — it may point to a stale version and should be migrated to `lumira`.
244
288
  if (existingIsLumira && commandSpeed(existingCmd) >= 3) {
245
289
  lines.push(ok('lumira is already configured (optimal command)'));
290
+ // statusLine needs no rewrite, but a returning user may still want to opt
291
+ // into the subagent hook — offer it and flush only if they accept.
292
+ const added = await maybeRegisterSubagent({ settings, baseCmd: existingCmd, confirm, isTTY: !!stdin?.isTTY, lines });
293
+ if (added)
294
+ writeSettingsAtomic(settings, settingsPath);
246
295
  return finalize();
247
296
  }
248
297
  // Resolve the fastest per-render command this environment can offer.
@@ -254,6 +303,9 @@ export async function install(opts = {}) {
254
303
  // so we never downgrade a user's direct binary to npx.
255
304
  if (commandSpeed(resolvedCmd) <= commandSpeed(existingCmd)) {
256
305
  lines.push(ok('lumira is already configured'));
306
+ const added = await maybeRegisterSubagent({ settings, baseCmd: existingCmd, confirm, isTTY: !!stdin?.isTTY, lines });
307
+ if (added)
308
+ writeSettingsAtomic(settings, settingsPath);
257
309
  return finalize();
258
310
  }
259
311
  }
@@ -263,22 +315,8 @@ export async function install(opts = {}) {
263
315
  lines.push(ok(`Backed up existing settings → ${DIM}settings.json.lumira.bak${RST}`));
264
316
  }
265
317
  settings.statusLine = makeStatusLine(resolvedCmd);
266
- mkdirSync(dirname(settingsPath), { recursive: true });
267
- const tmp = `${settingsPath}.${process.pid}.${Date.now()}.lumira.tmp`;
268
- try {
269
- const fd = openSync(tmp, 'wx', 0o600);
270
- writeSync(fd, JSON.stringify(settings, null, 2) + '\n');
271
- fsyncSync(fd);
272
- closeSync(fd);
273
- renameSync(tmp, settingsPath);
274
- }
275
- catch (e) {
276
- try {
277
- unlinkSync(tmp);
278
- }
279
- catch { }
280
- throw e;
281
- }
318
+ await maybeRegisterSubagent({ settings, baseCmd: resolvedCmd, confirm, isTTY: !!stdin?.isTTY, lines });
319
+ writeSettingsAtomic(settings, settingsPath);
282
320
  lines.push(ok(existingIsLumira
283
321
  ? `Upgraded statusline command → ${DIM}${resolvedCmd}${RST} (faster)`
284
322
  : 'Configured lumira as statusline'));
@@ -339,21 +377,10 @@ export function uninstall(opts = {}) {
339
377
  return lines.join('\n') + '\n';
340
378
  }
341
379
  delete uninstSettings.statusLine;
342
- const uninstTmp = `${settingsPath}.${process.pid}.${Date.now()}.lumira.tmp`;
343
- try {
344
- const fd = openSync(uninstTmp, 'wx', 0o600);
345
- writeSync(fd, JSON.stringify(uninstSettings, null, 2) + '\n');
346
- fsyncSync(fd);
347
- closeSync(fd);
348
- renameSync(uninstTmp, settingsPath);
349
- }
350
- catch (e) {
351
- try {
352
- unlinkSync(uninstTmp);
353
- }
354
- catch { }
355
- throw e;
356
- }
380
+ // Only remove the subagent hook if it's ours — never wipe a foreign renderer.
381
+ if (isLumira(uninstSettings.subagentStatusLine))
382
+ delete uninstSettings.subagentStatusLine;
383
+ writeSettingsAtomic(uninstSettings, settingsPath);
357
384
  lines.push(ok('Removed lumira statusline from settings'));
358
385
  // Remove skill from both destinations (best effort)
359
386
  for (const root of [join(home, '.claude'), join(home, '.qwen')]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumira",
3
- "version": "1.13.0",
3
+ "version": "1.14.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",