Skip to content

Releases: HackPoint/lumen

Lumen v1.5.1

Choose a tag to compare

@github-actions github-actions released this 31 Jul 12:57

A patch release, and the reason to install it is that 1.5.0's Linux CLI does not run.

The Linux CLI required a glibc most distributions do not have

lumen-v1.5.0-x86_64-unknown-linux-gnu.tar.gz links against GLIBC_2.39 and exits
immediately on anything older:

distribution glibc 1.5.0
Ubuntu 24.04 2.39 runs
Debian 12 2.36 version 'GLIBC_2.39' not found
Ubuntu 22.04 LTS 2.35 version 'GLIBC_2.39' not found

The release workflow built it on ubuntu-latest, which GitHub moved to 24.04, so the
binary took that runner's glibc as its floor. The GUI job had been pinned to ubuntu-22.04
for a related reason; the CLI job never was. Anyone installing the CLI on a stable
distribution — including through the Homebrew formula on Linux — received a binary that
could not start. Every test passed the whole time: nothing running in-process can see a
dynamic-link failure.

Pinned to ubuntu-22.04, which lowers the floor to 2.35.

Install verification, per platform, before release rather than after

scripts/verify-install.sh and verify-install.ps1 check that an install works: the MCP
server is driven over stdio with a real JSON-RPC handshake and asked for its tool list, the
tokenizer is asked to count, the CLI is asked to refuse filing without --yes, and the
intercept is checked for both fail-open guards. Every install defect found so far was
invisible to a file-existence check — a bundled MCP server two days older than its UI, a
June hook script with no guards, and now a binary that cannot link.

A CI workflow runs them on macOS, Linux and Windows, plus a job that runs the freshly built
Linux CLI inside debian:12 and ubuntu:22.04 containers. That job is the one that would
have caught the break above: it deliberately runs the binary on distributions older than
the one that built it. All ten checks are now required before a merge.

The fault button was rendered off the bottom of the tray popover

The popover is a fixed 320x400 window whose card was stretched to it, with overflow: hidden turning overflow into silent clipping. The rows below the gauge are all
conditional, and each one pushed the next further down: measured at 308px with none of
them and 396px with a recorded fault and an update notice both showing, in a window with
392px of usable height. The button was present, styled and impossible to click.

The card sizes to its content now and the window follows it.

The Windows browser filing route could never succeed

cmd /C start "" <url> passed the URL as an ordinary argument, and cmd.exe treats &
as a command separator. Every prefilled issue URL contains &body=, so cmd tried to run
the second half as a command and exited 1. The browser route exists so that someone
without the GitHub CLI can still report a fault; on Windows it could not.

A failed tray left no way into the app

Reported from the field on macOS 26 (#5): Lumen running with two processes, no menu-bar
icon, and — because both windows start hidden — no popover, no main window, and no way to
reach the fault reporter to report it. The tray build result was logged and otherwise
ignored.

The main window is now shown when the tray cannot be built. That is not a fix for whatever
makes the tray fail, which remains undiagnosed and does not reproduce here on the same OS
version; it is the difference between a degraded app and an unusable one. The identifying
line is TRAY: build failed: in ~/Library/Logs/io.speedata.lumen/Lumen.log.

The same report caught the cask uninstalling a LaunchAgent that does not exist: it
unloaded io.speedata.lumen, but launchctl matches on the agent's Label and the plugin
writes Lumen.plist with Label Lumen. Every uninstall left a login item behind, still
trying to launch a deleted app at each boot.

The developer meter hook recorded nothing

.claude/hooks/lumen_meter.sh resolved its database as <workspace>/lumen.db — a file
with no schema — so every insert failed with "no such table" and || true discarded the
error. It had also drifted to nine columns where the installed copy writes fifteen, losing
token_source (making a bytes/4 estimate indistinguishable from a real count), req_key,
is_subagent, session_id and file_mtime. Same shape as the intercept bug two releases
ago: two copies of one script with nothing asserting they agree. There is a test now.

Notes

  • A notifier for new releases, for minor and major versions only. Patch releases are
    silent: a notice that fires for every x.y.Z gets dismissed reflexively, and then the
    one that mattered gets dismissed too. This adds the only unprompted network request
    Lumen makes
    — an unauthenticated read of a public endpoint, no credential and no
    identifier — documented under Security & privacy and disabled by LUMEN_UPDATE_CHECK=0.
  • Efficiency measurement on a real corpus: cargo test --release -p lumen-mcp --test efficiency -- --nocapture. Measured on this repository, an outline costs 6.4% of a
    full read. The same harness reports where interception does not pay, and asserts it
    rather than omitting it.
  • Print macros in the daemon could kill it. Its rule is "never eprintln! in this process"
    because the GUI owns that pipe, but the rule named only stderr and four println! calls
    survived it — one on the main thread during startup, where a panic ends the process.
  • Four classes were styled nowhere at all, and .mcp.json declared no servers, so a fresh
    clone got none of the tools the Read intercept routes to.

Lumen v1.5.0

Choose a tag to compare

@github-actions github-actions released this 30 Jul 18:41

[1.5.0] — 2026-07-30

The Read intercept could deadlock a session

The intercept blocked every Read of a ≥300-line source or log file and redirected the
model to the Lumen MCP tools — without ever checking that those tools could run. When the
MCP server was absent or its tools were denied, the model had no way to read the file at
all: the built-in Read was blocked and the replacement was unreachable. The documented
escape, LUMEN_HOOK_ENABLED=0, has to be set before Claude Code launches, so nothing
inside a running session could recover.

Observed in the field as an agent that retried the same routing about fifteen times and
then abandoned the task, reporting that it could not read the files it needed.

Two fail-open guards now make blocking conditional on the redirect being viable. If
lumen-mcp is not on the machine, the hook does not block. And a file is redirected at
most once per session — a model coming back to the built-in Read has already been told to
use Lumen, so if it is asking again the Lumen route failed for it, and the Read is
released. The block message now says so explicitly, which is what breaks the retry loop:
"If the lumen tools are unavailable to you, retry this exact Read — it will be allowed
through."

The first version of this fix shipped to nobody. It landed in the developer copy of
the hook, .claude/hooks/lumen_read_intercept.sh. Setup installs a different script,
generated from a string constant in setup.rs, and that one was untouched — so the
repository looked fixed while every real install kept the deadlock, and the staleness
check correctly reported no drift because the constant it compares against had not
changed. Found by installing the build rather than reading it. Six tests now cover the
shipped script, five of them by running it, and one compares guard markers between the
two copies.

Also fixed: smart_read's tool description advertised "files ≥100 lines" while the hook
and CLAUDE.md both said 300. The model was reading one threshold and meeting another.

lumen report — faults become a filed issue

A fired fail-open guard is the highest-signal fault Lumen has: routing degraded on a real
machine and nothing recorded it. lumen report now renders that and five other fault
kinds as a GitHub issue, and files it.

Writers append to a JSONL spool; nobody writes to SQLite. The intercept sits on the path
that decides whether the model may read a file, so a lock acquisition there is not
acceptable — lumen report does the database work later, draining the spool into a new
faults table. A drain renames before reading, so a hook writing mid-drain lands in the
fresh spool rather than being lost, and an interrupted drain is recovered next run.
Retry-loop callers are throttled: a permanently failed WS bind retries every two seconds
and would otherwise write about 43,000 lines a day.

Captured: both intercept guards, the daemon's three ingest-error paths (until now logged
and dropped — the silent loss that froze the gauge in 1.1.0), and its WS restart loop.
Ranked declines needed no writer; they were already in read_events.routed_via. Schema
drift is checked live. The daemon's supervisor-exit path is deliberately not captured:
it is documented as exit-first because logging before exit is what left an orphan daemon
holding the port in 1.2.2.

Filing tries three routes and takes the first that works — the GitHub CLI, then the REST
API with a token, then a prefilled browser form. The browser route is a handoff, not a
filing
: nothing exists on the tracker until you press Submit, and both the CLI and the UI
say so rather than claiming an issue was created. Every skipped route is reported, because
a chain that silently degrades hides that the preferred one is broken. Reports are
deduplicated on a fingerprint of (kind, variant, version) carried in the body, so
re-running comments on the existing issue instead of opening a duplicate; the lookup works
without credentials on a public repository, since a token is only needed to write.

The tracker is public and Lumen reads your source files, so redaction is the feature.
Bodies are metadata-only: extension, line count, and a content hash that identifies a file
without shipping it. Paths outside the workspace lose even their basename — acme_client_billing.ts
identifies a client on its own. Path-valued environment overrides render as <path>,
because collapsing $HOME still leaves clients/acme visible.

That property did not hold on Windows. Path::is_relative answers for the host, and
/Users/me/clients/acme carries no drive letter, so a report rendered on Windows
classified every Unix-style absolute path as workspace-local and published it verbatim —
while the $HOME scrub made the output look sanitised. The same call appeared in three
places, so --include-source would also have embedded a file from outside the workspace.
Absoluteness is now decided from the string rather than the host. Caught by CI on
windows-latest, on a push made before tagging; a release cut an hour earlier would have
shipped a public-tracker leak to every Windows user.

Reporting faults from the app

The report lives under Report a fault on the Hotspots screen, with a count badge on
the nav and a row in the tray popover — which hands off to the main window rather than
filing, because a 320×400 popover cannot show you the body first. Two clicks throughout:
one renders locally, the other publishes, and the text shown is the text sent.

Its first version was reachable only through a nav the tray popover does not have, and
below a ten-row list at that. Fixing the placement exposed three older layout bugs on that
screen: .home, the tab nav and the centred column were all styled only in the Home
component, so Hotspots painted no background at all — near-white text on a white window —
had no navigation styling, and sat flush against the window edge. The app shell is now
defined once, globally.

release.sh has been failing halfway through since July

The script bumps Casks/lumen.rb. That file was renamed to Casks/lumen-app.rb on
28 July — to stop an unqualified brew install --cask lumen fetching an unrelated
brightness tool — and the script was not updated. The sed then failed under set -e
and the script aborted partway: after the Cargo manifests and the formula, before the
changelog, the commit or the tag. v1.3.0, v1.3.1 and v1.4.0 were cut some other way.

So the in-repo cask template drifted to 1.2.0, and crates/lumen-stats — absent from
the script's list of crates entirely — was never bumped by any release and only stayed in
step when someone noticed by hand. Both are fixed, and update_ruby_version now refuses
a missing file or one with no version line instead of failing mid-bump. A run that does
most of its job and stops is how this went unnoticed.

Correction. An earlier draft of this entry, and the commit message of 30be13c,
claimed the stale template meant brew install --cask lumen-app had been fetching
Lumen_1.2.0_aarch64.dmg for three releases. That is wrong, and no user was affected.
The tap is generated, not authored: CI copies these files into HackPoint/homebrew-tap
and then stamps the version from the resolved tag and the sha256 from the published
assets. The tap's own history shows a correct bump for every release — v1.2.4, v1.3.0,
v1.3.1, v1.4.0 — so the in-repo drift never reached anyone. The bug was real; the
consequence asserted was not.

Notes

  • Every fault kind is checked against the renderer by a test. The first report ever filed
    from the app read "Impact: Unclassified fault" because the daemon's kinds were added
    without teaching the renderer about them, while a kind that is never emitted had prose
    ready and waiting.
  • docs/filing-a-fault-report.md walks through filing with screenshots of each state.
  • tauri build does not rebuild sidecars. build-sidecar.sh must run first or the bundle
    ships whatever binaries were last staged — during this work it shipped a two-day-old
    MCP server alongside a current UI.

Lumen v1.4.0

Choose a tag to compare

@github-actions github-actions released this 29 Jul 14:58

The headline is dollars now, and the token ratio is demoted to an input

87% fewer tokens per intercepted read was the hero metric, and it flattered the product.
A ratio cannot be wrong in the direction that matters: an intercepted read is a blocked
read, so the model spends an extra round calling a Lumen tool instead, and a smaller reply
that forces another round is a loss however good the ratio looks.

The Optimizer screen now leads with the net dollar value — the tokens avoided, priced,
less the rounds they cost — and keeps the token ratio underneath it. The README's 87%
figure is replaced the same way.

The regression test for this is a call that saves 300 of 400 tokens: a 75% ratio, and a
dollar loss. It would have rendered as a success under the old headline.

Published under a pre-committed rule: whatever the number is, it renders. A negative
result shows as negative with no softening — asserted by a test that fails if the words
"but", "still", "however", "nonetheless" or "despite" appear near it. A result within a
dollar of zero says roughly break-even rather than rounding into a win. And when there
are too few recorded turns to price a round, no figure is claimed at all, because a gross
figure with no cost beside it is the exact overstatement being removed.

Measured on the author's machine: +$276 over 291 attributable calls, about +$0.95 each.
The sign is robust across every plausible value of R; the magnitude is not, spanning
+$31 to +$368 on that one input, so R is shown in the UI rather than hidden. smart_read
taken alone is roughly break-even — the surplus comes from recall_file.

Hotspots: where your context actually goes

A new screen answering a question Lumen could always have answered and never did. Top
files by cumulative tokens read, with read counts, share of all context, and how much of
each file's reading found it unchanged since the previous read — context re-acquired
rather than retained.

On this repository: Run.tsx, 139 reads, 3.83M tokens, 20.8% of everything read, and
the top ten files account for 40.1% of 18.4M tokens across 1,189 files. Where the numbers
warrant it the screen says what to do — a 3,833-line file read 139 times is a refactor
candidate, and no read optimisation beats splitting it.

It is framed as diagnosis, not savings, and the framing is load-bearing. It costs zero
tokens, intercepts nothing and forces no rounds, so it is the only figure in the product
that cannot come out negative. A test enforces the copy: the screen may not claim to have
saved anything.

The unchanged-read signal is a proxy, and labelled as one. The direct measure would be
"re-read after a compaction", but compaction is recorded in the transcript rather than the
database; file_mtime equality answers the same question from data that is present. Rows
predating file_mtime are excluded rather than assumed unchanged.

Notes

  • 537 Rust tests, 268 frontend.
  • R is a measured constant (194) in the UI, not per-call. Per-call R needs the
    transcript replay in scripts/lumen_percall.py, which cannot run inside a tool call.
  • The published figure covers the 291 calls that could be attributed, and is not scaled
    up
    to the 1,470 in the ledger.
  • The measurement window has not started: it needs the Bash matcher registered (a Setup
    press) and MCP-side req_key flowing (a Claude Code restart). Both were still absent at
    release, so no A/B data exists yet and none is claimed.

Lumen v1.3.1

Choose a tag to compare

@github-actions github-actions released this 29 Jul 14:38

The budget was a floor mistaken for a target, which disabled the ranking

budget = full_tokens − S_min was wrong. S_min is a floor on the saving — what the
outline must not exceed if the call is to pay for itself. It says nothing about what the
outline should aim for, because net value rises monotonically as the outline shrinks: a
cost formula cannot bound an outline from below.

The consequence was not subtle. On a 39,281-token file the budget came out at 33,897, so
all 222 definitions fitted and k = n — the ranking selected nothing, and the A/B would
have compared two unranked outlines. The same file now produces 785 tokens at k=46/222.
Across the repository the budget binds in 9 of 12 qualifying files; the three where it
does not are files whose entire outline already fits, which is correct.

budget = min(full_tokens − S_min, target_outline)     target_outline = 800

The real lower bound is sufficiency, which is empirical, not derivable — hence a tuning
parameter, lowered while follow-up rate stays flat and reverted on the first rise. Set it
with LUMEN_TARGET_OUTLINE; it is recorded on every row, so rows produced under different
sweep values are never pooled.

One correction to the target's justification. 1,439 tokens is the largest legacy
outline (setup.rs), not a typical one. The legacy median across qualifying files is ~401
tokens and the mean ~550, so 800 starts the sweep above the status quo, not below it. That
is still the safe direction — begin generous and come down — but at 800 the ranked arm
costs about 13% more than legacy (7,450 vs 6,595 tokens across 12 files), so the first
sweep step should be expected to close a gap rather than open a lead.

The refusal arm is untouched: 3,216 → 294, refused is the part that pays.

Per-call economics, measured jointly — and the follow-up rate

scripts/lumen_percall.py computes (R, round cost) as a joint pair per call, both
from the same position in the same session, which retires the mean-versus-median question:
no average has to be chosen when every call carries its own.

Two findings changed the picture.

R is bounded by compaction, and that is worth a factor of three. A saved token stops
paying the moment the context is rebuilt. Counting to the end of the session gave a
call-weighted median R of 658; bounding it at the next isCompactSummary gives
194–249. Both are far above the 65 previously assumed, which was a session-length
median rather than a call-weighted one — calls concentrate in long sessions.

60.4% of smart_read calls are followed by a recall_file on the same file, at a
median gap of 3 rounds. The pair multiplier is therefore 1.604 rounds per intercept, and
the cost side now carries it.

That 60.4% needs reading carefully: outline-then-fetch is Lumen's documented workflow —
smart_read's own tool description says to follow it with recall_file. So a follow-up is
not by itself a failure, and the A/B question is not "does trimming cause follow-ups" but
"does trimming push the rate above the baseline the two-step design already implies."

The dollar figure

Net over the 291 attributable calls, with each call's own R and its own round cost, times
the measured pair multiplier:

route calls gross round cost net paid for its own round
smart_read 53 $39.83 $14.93 +$24.90 62.3%
recall_file 238 $346.72 $95.69 +$251.04 51.7%
+$275.93

About +$0.95 per call. The sign is robust: positive at every plausible R, from +$31 at
R=65 to +$368 at R=249. The magnitude is not — it spans an order of magnitude on that one
input, and smart_read taken alone is break-even to slightly negative at the low end.

Notes

  • Only 291 of 1,470 ledger calls are attributable. The transcripts on disk are a subset,
    so this is a per-call figure over what can be attributed, not a total. It is not scaled up.
  • One approximation, stated rather than buried. Ledger savings and transcript economics
    are the same calls seen from two sides but cannot be joined without req_key, so each
    call's saving is matched to a call's economics at the same quantile.
  • The compaction marker is a user record carrying isCompactSummary with no assistant
    message id, so an id lookup finds zero of them. It is located by file order instead — the
    first attempt silently measured no compactions at all.
  • 522 Rust tests, 251 frontend.

Lumen v1.3.0

Choose a tag to compare

@github-actions github-actions released this 29 Jul 13:20

Ranked, budget-aware outline — implemented, shipped off, and measured

smart_read's outline can now be sized by the economics of the call instead of by a fixed
format. An intercepted read costs exactly one extra round, so an outline is worth returning
only when it saves more than that round costs:

S_min = (C × 0.5 + O × 25) / (6.25 + 0.5·R)      budget = full_tokens − S_min

C and O come from your own turns history when there is enough of it, and the row
records which. Files that cannot clear the bar are refused, with the refusal and the
numbers behind it written to the ledger.

Enable with LUMEN_RANKED_OUTLINE=on, or =ab to run both arms split by a stable hash of
the path. Unset — the default — changes nothing. Anything unrecognised is also off: a
typo must not enable an experiment.

Read this before enabling it: the ranking is not the win

Measured across this repository, the ranked outline returns 73% more tokens than the
outline it replaces — 10,759 against 6,205 over the files that qualify. It captures nested
definitions the old outline never did, and the budget is usually generous enough that
nothing is trimmed at all: 52 of 56 definitions included at the measured context, 37
of 37
at 100k. The ranking machinery is close to inert at current economics.

What pays is the refusal. Replaying the ledger, declining calls that cannot clear
S_min would have moved smart_read from −$24.41 to +$15.09 and recall_file from
+$90.29 to +$159.24 — about +$108 by not making 831 of 1,470 calls. That is a gate,
not a trimmer, and its proper home is the PreToolUse threshold rather than smart_read:
by the time smart_read runs, the extra round is already spent. That threshold is
deliberately untouched here.

Also worth correcting: the premise that outlines cost ~1,300–1,600 tokens is recall_file's
figure. smart_read's median return is 418 tokens, and it has zero net-negative
rows in 421 calls. The losses were dollar-negative — a saving worth less than the round it
forced — not token-negative.

Upstream tag queries turned out to be the wrong tool

They index symbols for jump-to-definition, not scope for outlining.

  • TypeScript: upstream captures only declaration forms, so ordinary class,
    function, method_definition and every call yield nothing — measured at 0
    definitions in a 652-line Angular service
    and 0 in a 57-line component. A ranked
    outline of the frontend would have been empty.
  • Rust: impl blocks are @reference.implementation, leaving every method with no
    container — fn new with nothing saying what it constructs.

Both are supplemented by queries authored in-repo rather than copied, which satisfies the
reason upstream was preferred (staying MIT-clean; copying from an Apache-2.0 source is what
that ruled out). That service went from 0 to 56 definitions.

Fixes and deviations found while building it

  • Query::new compiles the pattern set and cost 9–16 ms of a 10 ms budget, so the
    feature timed out on any file worth outlining. Compiled once per process now: the whole
    pipeline is 2 ms at 421 lines, 7 ms at 1,433, 17 ms at 4,198 — parse-dominated.
  • The wall-clock ceiling is 50 ms, not 10. What it guards against is returning the
    whole file, which costs the model far more than 50 ms in latency and tokens; 10 ms
    rejected exactly the large files an outline helps most. Ten times that in debug builds,
    whose constant factor is 3–5× and is not what the ceiling is calibrated against.
    Overridable with LUMEN_RANKED_TIME_BUDGET_MS: a deadline is a property of the machine,
    not of the code, and without the override the tests declined as TooSlow on CI runners
    while passing locally — a test measuring the runner rather than the feature.
  • The A/B split was 100/0. FNV-1a's lowest bit is close to the XOR of its input bytes,
    so on structured paths hash % 2 put every one of 4,000 generated paths in the same
    arm
    while reporting a 50/50 design. A splitmix64 finalizer brings it to 0.509, and the
    assignment of four real paths is pinned so a future hash change fails a test instead of
    silently re-randomising an experiment in flight.
  • The inflation guard is unreachable. budget = full − S_min with S_min > 0 and the
    fit never exceeding budget means returned < full by construction. It is kept as a
    backstop against a future change to the budget rule, and the test asserts that invariant
    rather than faking a trigger.
  • The cache key omits the budget, departing from the specification. Bucketing the
    budget was meant to stop context fluctuations thrashing the cache — but what is cached is
    the tag extraction, which does not depend on the budget at all, so including it would
    cause the very thrashing it was meant to prevent. mtime and size are both in the
    key: this runs inside a tool call where a file can be written and re-read within one
    second, which second-resolution mtime cannot see.

Notes

  • 519 Rust tests (+18 in this release), 251 frontend. Red-then-green on the export prior;
    the budget-guard test initially passed with the guard deleted — fit_budget returns k=0
    for a non-positive budget and reports the same verdict — and now asserts the ordering
    property only the guard provides.
  • Nine columns added to read_events: budget, s_min, econ_context, econ_rounds,
    econ_output, econ_source, k_selected, n_total, coeff_version. NULL on every row
    not produced by this path, including all hook-written rows — a zero would claim a
    decision was made.
  • k_selected = n_total on a row is the signal that the budget did not bind and the
    ranking had no effect. That is the number to watch if you run the experiment.
  • R is still the measured default of 65, not derived per call. Doing that needs the
    (R, round cost) pairing req_key was added to make possible.
  • MIN_USEFUL_OUTLINE = 120 tokens is an unmeasured constant introduced here, and it
    decides the positive-but-too-small band.

Lumen v1.2.4

Choose a tag to compare

@github-actions github-actions released this 29 Jul 10:56

Setup reported healthy hooks while the Bash meter was not installed

Verifying 1.2.3 on a real install turned up the gap. ~/.claude/settings.json still
carried the pre-1.2.1 matcher set — no Bash, and all three retired mcp__lumen__*
entries — and the Setup screen called it healthy: "5 hook commands, all present".

Two things combined. Hooks are validate-and-report rather than auto-repaired, which is
deliberate: rewriting a user's Claude Code settings without being asked is worse than
telling them to press a button. But that makes the report the entire mechanism, and the
validator only checked that each lumen hook command pointed at a file that exists. It
never compared which matchers were registered, so it could not see a stale desired
state — the one thing it needed to see for the button to ever be pressed.

The matcher set now lives in one place that the installer and the validator both read
(METER_MATCHERS, INTERCEPT_MATCHER, RETIRED_MATCHERS). Previously the installer
held the list privately, so changing it in 1.2.1 left the validator measuring the old
contract. The report now names what is missing and what is stale, so the repair is
actionable rather than a bare "unhealthy".

If you upgraded from 1.2.0 or earlier, open Setup and run it once to pick up the
Bash matcher and drop the retired ones. Everything else self-repairs.

Notes

  • Paired tests keep the two sides from drifting again: one asserts a stale matcher set
    is reported, the other asserts what step_install_hooks_in writes validates as
    current. Changing either side alone fails one of them.
  • The dangling-path check is unchanged and still covered.
  • 465 Rust tests, 251 frontend.

Lumen v1.2.3

Choose a tag to compare

@github-actions github-actions released this 29 Jul 10:37

The orphan-daemon fix in 1.2.1 did not work, and its test could not tell

1.2.1 added a watchdog so the daemon exits when the GUI dies, freeing
127.0.0.1:9999 instead of squatting it across an upgrade. Verified on a real install,
it did not work: after force-killing the app, the daemon was still holding the port
twenty-four seconds later. lsof showed its stdin had no peer — the pipe was at
EOF and the watchdog's read had already returned.

The watchdog logged before exiting:

eprintln!("lumen-daemon: supervisor exited, ...");
std::process::exit(0);

The daemon's stderr is a pipe whose read end also belongs to the GUI, so it breaks at
the same instant as stdin. eprintln! panics when the write fails, and a panic on a
spawned thread unwinds only that thread — so exit(0) was never reached. The orphan
survived for exactly the reason the log line existed: to announce itself.

The test could not catch it, because it wired the daemon's stderr to Stdio::null(),
where every write succeeds. It reproduced the EOF but not the broken pipe, which is a
strictly easier situation than production. It now closes stdout and stderr alongside
stdin, and with the old code restored it fails — 2 of 3, with the negative control
still passing.

Every eprintln! in the daemon is now a non-panicking logline!. This was a class, not
an instance: fourteen other call sites had the same hazard, and the WebSocket restart
loop reaches one of them every two seconds. A daemon must not die because it could not
describe itself — nor be kept alive by the attempt.

Notes

  • On the install where this was found, the orphan was still present after upgrading
    1.2.0 → 1.2.2. That is expected and not a further bug: the process that must kill the
    daemon is the one being replaced, so an upgrade away from a version without the fix
    cannot benefit from it. Upgrades from 1.2.3 onward are covered. To clear a stale one
    now: quit Lumen, pkill -f 'MacOS/lumen-daemon', then reopen it.
  • Verified on the real install after this fix, not only in tests: an ordinary quit and a
    kill -9 of the app both leave zero daemons and free the port.
  • 462 Rust tests, 251 frontend.

Lumen v1.2.2

Choose a tag to compare

@github-actions github-actions released this 29 Jul 10:14

Linux: the metering hook wrote nothing, in two independent ways

Both bugs arrived with 1.1.5 and neither was visible, because a metering hook must exit
0 so it can never fail the tool call it observes. On Linux that meant no
builtin_read and no bash_output rows at all. MCP-written rows (smart_read,
recall_file) were unaffected — those come from the Rust binary, not the shell. The
exposure is 1.1.5 through 1.2.1, all released on the same day.

  • mktemp -t lumen_bash_out — BSD accepts a bare prefix and appends its own
    suffix; GNU requires at least three X's and refuses. Verified against coreutils
    9.1: exit=1, too few X's in template. The temp path came back empty, python3
    raised FileNotFoundError opening "", and a || exit 0 swallowed it. Now an
    explicit $TMPDIR/lumen_bash_out.XXXXXX, and if it still fails the hook says so on
    stderr instead of vanishing.
  • stat -f %m — on BSD -f is "format"; on GNU -f is "display filesystem
    status".
    It does not fail, so the || stat -c %Y fallback never ran. It printed six
    lines of filesystem information into a newline-delimited field list, shifting every
    field after it and making the insert throw.

The second is the instructive one. The fallback was ordered on the assumption that the
wrong dialect would fail; instead it succeeded with the wrong answer. Both are now
settled by validating the output — the mtime helper requires a pure integer and tries
the other dialect otherwise — rather than by trusting an exit code.

Neither was reachable by the tests that existed before 1.2.1, which only pattern-matched
the script's text. 1.2.1 added tests that execute it, its first CI run went red on Linux
with exactly these, and both bugs were then reproduced and their fixes confirmed against
real GNU coreutils in a container before this release was cut.

Notes

  • The daemon's no-override path resolution is tested on Unix only. Reaching that branch
    means letting the daemon resolve a home directory, and the only way to redirect that
    is HOME, which dirs::home_dir() honours on Unix but ignores on Windows — there it
    asks the shell for the profile. Run unguarded it would have opened the real ledger, so
    it is gated rather than quietly pointed at production. A companion test covers
    LUMEN_DB precedence on every platform.
  • 462 Rust tests, 251 frontend.

Lumen v1.2.1

Choose a tag to compare

@github-actions github-actions released this 29 Jul 09:57

Read this first: your "missed optimization" number will drop, and the old one was wrong

Half of that figure was never real. Of 6,862,596 tokens attributed to reads that
bypassed Lumen, 3,431,295 came from 117 binary files out of 2,749 rows — and the
six largest entries in the entire table were screenshots. lumen-tok cannot tokenize
a PNG; it crashed, the meter read the crash as a broken tokenizer, and substituted
bytes ÷ 4. For a screenshot that overstates the cost by roughly 44x. Three PNGs read
during testing were recorded as 119,921 tokens against approximately 2,750 actual.

That number is now computed without them, for history as well as for new rows, so
expect it to fall by about half. The lower figure is the honest one. Nothing was
deleted to achieve it — the rows are still there, still queryable, and the report
script prints exactly what was excluded.

This also retires the "opportunity" that a non-text interception feature was scoped
against. It did not exist.

Fixes

  • fix(daemon): an orphaned daemon squatted the WebSocket port across every upgrade,
    so the GUI silently kept reading from the version you replaced.
    The app bound the
    spawned daemon to _child and dropped it; dropping a CommandChild does not
    terminate the process. Every quit therefore left a daemon reparented to launchd and
    still holding 127.0.0.1:9999. After an upgrade the new app's daemon lost the bind
    and spun in the restart loop that exists to absorb transient collisions — it
    cannot tell one from a permanent squatter, so nothing was reported.

    Caught by inode on a real 1.1.4 → 1.2.0 upgrade: the process holding the port was
    inode 89539790 (the old binary, 8,835,200 bytes) while the newly spawned daemon was
    inode 90122126 with zero TCP descriptors.

    Two independent guards, because one is not enough. The app now kills the daemon on
    exit, which covers an ordinary quit. The daemon also exits when its stdin reaches
    EOF, which is what a dying parent looks like from the child's side — that covers a
    crash, a force quit, or an installer killing the app outright, where no exit handler
    runs at all. The watchdog is gated on LUMEN_SUPERVISED=1, set only by the app, so
    running lumen-daemon by hand still behaves.

  • fix(daemon): the daemon resolved an unset LUMEN_DB to the relative path
    lumen.db.
    This is the same fallback class that previously split the ledger into
    two files accumulating real events in parallel. It now shares
    meter::resolve_db_path with every other writer, or refuses to start.

  • fix(hooks): Bash output was never actually measured. The meter script has had a
    Bash) branch since the instrumentation phase, but Bash was never registered
    under PostToolUse, so the branch was unreachable and bash_output had zero rows
    in 51 days
    . The test that covered this asserted the exact four-matcher list, so it
    locked the gap in place rather than catching it.

    Bash is now registered. Command output is tokenized; of the command line itself
    only the program and subcommand are stored (cargo test, git status), with any
    leading VAR=value dropped first, so TOKEN=secret curl … records curl. This is
    observation only: no PreToolUse hook on Bash, nothing intercepted or wrapped, and
    Lumen never executes anything from a payload. To opt out, delete the Bash entry
    under PostToolUse in ~/.claude/settings.json.

  • fix(hooks): the three mcp__lumen__* PostToolUse matchers are removed. Those
    tools meter themselves in-process, so the hook script fell straight through to
    exit 0 — forking a bash and a python3 on every smart_read, recall_file and
    compress_logs call to do nothing. Waste inside a tool whose purpose is removing
    waste. Removal is surgical: a matcher shared with a hook you added keeps your hook.

  • fix(tok): lumen-tok panicked on any input that was not valid UTF-8. It now
    exits 3, meaning "this is not text and has no token count", which the meter records
    as token_source = 'unsupported' with no number. That is deliberately distinct from
    a genuinely broken tokenizer, which still yields a row labelled estimated — a real
    file whose count we could not take is not the same as a file that has no count.

  • fix(setup): LUMEN_DB and LUMEN_TOK are overridable in the generated meter.
    They were fixed strings, which made the installed hook the one component in the
    pipeline that could not be exercised without writing to your real ledger. It is now
    covered by tests that run the actual generated script.

Notes

  • 24 tests added (460 Rust, 251 frontend). Every fix was falsified before being
    accepted: disabling the watchdog fails two of three supervisor tests while the
    negative control still passes; restoring the old read_to_string().expect() fails
    two of six tokenizer tests; removing the metric filter fails the exclusion test but
    not its control; unregistering Bash and re-hardcoding LUMEN_DB fails six setup
    tests.
  • Historical rows cannot be separated by provenance: before this release a failed
    tokenizer produced a bytes ÷ 4 value labelled estimated, indistinguishable from a
    broken tokenizer on real source. The correction therefore also matches known
    unmeasurable extensions. That is a query-level filter, not a rewrite — the ledger
    stays append-only.
  • The extension list is finite and will miss a binary file with an unusual suffix.
    Rows written from this release forward are labelled at write time and do not depend
    on it.

Lumen v1.2.0

Choose a tag to compare

@github-actions github-actions released this 29 Jul 08:50

Fixes

  • fix(panel): the popover clipped its own cost figures. Measured in a browser at the
    popover's real 320x400: with a seven-digit context, a project label and a
    compaction badge all present, the content needed 413px inside 382px, so the second
    tile ran 7.9px past the bottom edge and $1134.87 was cut in half.

    Three separate causes, each fixed structurally rather than by nudging a constant:

    • "SAVED BY CACHING ⓘ" wrapped to two lines. It needed 107px inside a 106.6px
      column, so the icon fell to its own row and stole 14px from the tiles below. The
      label and its icon are now one nowrap flex row, and the panel-scoped type is
      tightened until it fits with slack — so it cannot wrap regardless of translation
      or font fallback.
    • Nothing could yield when the optional rows appeared. The project label and
      the badge add 48px between them and are conditional, so the layout was only ever
      correct without them. The firefly is now the designated shrink target
      (flex: 1 1 auto; min-height: 0, size tied to viewport height), so growth
      squeezes the illustration instead of clipping the numbers.
    • Long figures had no way to shrink. CSS cannot size text by its own character
      count, so the component now picks a size class by length: eight characters is
      where the default stops fitting, ten where the reduced size does.

    Also: the token row and badge no longer wrap, grid items get min-width: 0 so a
    long figure cannot widen its column, and the panel clips as a last resort.

    Verified at an extreme well past the report — 1,000,000 / 1,000,000,
    $123456.78, a 28-character project name and a dated model id — with zero
    clipping and no horizontal overflow on any element.

Notes

  • Non-text file interception was investigated and rejected. The plan was to block
    reads of images above a size threshold, on the evidence that 87 PNGs accounted for
    4.3M "missed optimization" tokens. Both premises turned out to be wrong.

    Attribution through the transcript chain showed 39 of 45 traceable image reads
    (87%) were the agent reading back a screenshot it had just taken itself
    — the
    visual verification loop behind prompts like "run it on local let's see how it
    looks". Blocking those does not remove waste, it blinds the agent on work the user
    asked for. Only 6 reads touched a pre-existing file.

    The token figure was also an artefact of the metering bug 1.1.5 fixed: image costs
    were recorded as bytes / 4 of binary data by a hook whose tokenizer path was
    dead, or as 0 where the tokenizer panicked on non-UTF-8. Real cost scales with
    pixels, and for these screenshots is roughly 150k tokens — about 22x smaller than
    recorded, on the order of cents across 51 days.

    Not shipped, and not shipped disabled-by-default either: a feature that would break
    a working capability to save a rounding error should not exist. What remains worth
    doing is making lumen-tok report honestly on binary input instead of panicking,
    and excluding unparseable file types from the missed-optimization metric — that
    metric is what manufactured this opportunity.