Commit graph

538 commits

Author SHA1 Message Date
iamtoruk
bb5b71dff1 feat(models): name the MiMo v2 Flash row
It was the only MiMo row still rendering as its raw slug next to
"MiMo v2.5" and "MiMo v2.5 Pro". SORTED_SHORT_NAMES is longest-first, so
the two v2.5 entries keep their own labels.
2026-08-19 11:33:13 -07:00
iamtoruk
ef040a4c4d test(models): pin the shipped MiMo v2 Flash crash; name the base 2.5 row
The `mimo-v2-flash -> xiaomi/mimo-v2-flash` alias shipped before this
branch and already cycled through display-name resolution, so
getShortModelName threw RangeError on every real MiMo v2 Flash session.
The new cycle-safe resolver fixes it, but nothing pinned the ids that
actually crashed in production: cover the four spellings found in a real
session cache, including the unnamespaced `mimo/mimo-v2-flash`.

Add the base `mimo-v2.5` display name so the row reads next to
"MiMo v2.5 Pro" instead of showing a raw slug; SORTED_SHORT_NAMES is
longest-first, so the Pro tier still wins its own entry.
2026-08-19 11:26:22 -07:00
Aditya Vikram Singh
827a41241c fix(models): keep getShortModelName unary for Array.map
CI typecheck failed: sessions-report maps getShortModelName, and
the Extra High cycle Set was a second parameter. Array.map fed
the index as `seen`.

Cycle tracking stays on an internal helper. Display and alias
behavior unchanged. No second Extra High.
2026-08-19 21:27:13 +05:30
Aditya Vikram Singh
d5ced78595 fix(models): cycle-safe short names; keep user-alias display
Extra High MERGE AFTER FIX on d3f86f5. mimo-v2.5 aliased to
xiaomi/mimo-v2.5 then last-segment recursed forever. Looking up
SHORT_NAMES before resolveAlias also froze user remaps of known
ids (gpt-4o still displayed as GPT-4o).

Follow user aliases first. Break strip→alias→leaf cycles.
Do not invent a Kimi rate. Do not paper over this with a
mimo-v2.5 SHORT_NAMES row.
2026-08-19 21:10:33 +05:30
Aditya Vikram Singh
d3f86f5d16 fix(models): alias bare MiMo 2.5 ids to the LiteLLM Xiaomi rows
Hermes and token-plan sessions store mimo-v2.5-pro. The snapshot
row is xiaomi/mimo-v2.5-pro. Same class as the existing
mimo-v2-flash alias. No invented rate.

Looking up the display name on the stripped leaf before following a
pricing alias, so cline-pass/mimo-v2.5-pro cannot recurse
strip → alias → last-segment forever.
2026-08-19 20:44:18 +05:30
iamtoruk
3093887a14 Merge remote-tracking branch 'origin/main' into fix/scoped-save-republish
# Conflicts:
#	CHANGELOG.md
2026-08-18 12:48:02 -07:00
iamtoruk
ed9057f230 Merge remote-tracking branch 'origin/main' into fix/scoped-save-republish
# Conflicts:
#	CHANGELOG.md
2026-08-18 12:43:52 -07:00
iamtoruk
10ceb467a8 Merge remote-tracking branch 'origin/main' into fix/pre-release-hardening
# Conflicts:
#	CHANGELOG.md
2026-08-18 12:43:39 -07:00
iamtoruk
ab98a04c51 fix(cache): stop republishing month shards a scoped run never read
A date-ranged query loads only the months its range can report on, so a
file in an out-of-range month has no visible cache entry: the reconcile
re-parses it and writes the identical entry back, which marks a bucket
this run never loaded dirty. The save then merged and republished that
month under a fresh nonce name on every run, with byte-identical
content.

A merge into an unloaded month that neither adds, changes nor removes an
entry now keeps the published shard.

Fixes #1032
2026-08-18 12:29:07 -07:00
iamtoruk
e9d922ca2d fix(daily-cache): keep history for days whose transcripts partly aged out
The never-lose contract carried a (day, provider) slice forward only when
the re-derivation found nothing for it. Transcripts expire per file, so a
mostly-aged-out day still gets a few turns from surviving later files: the
fresh slice came back non-empty but truncated and replaced the full cached
one (a real cache lost $2,765.75 / 19,209 calls / 520 sessions over 13 days
on the 17 -> 19 upgrade).

A fresh slice now replaces a settled baseline slice only when it carries at
least as many calls. Comparison is on calls alone - cost and tokens are
re-priced accounting on the same evidence, and session counts drift down on
healthy days. Days inside a 7-day settle window stay authoritative. The
tz-change re-derive gets the exact form of the rule: the subtraction residual
is added on top of a data-carrying fresh slice instead of being dropped. The
cross-file adoption union is unchanged.
2026-08-18 12:23:29 -07:00
iamtoruk
37796a568e models: distinguish grok-4.5-build from grok-4.5 in reports
Reports bucket rows by model id and label them afterwards, so the two ids
collapsing onto one display name printed what looked like the same row twice
with different numbers. Give the variant its own SHORT_NAMES entry, which the
longest-first match picks over the grok-4.5 prefix. Display only: ids are
untouched, so nothing re-parses and no cost moves.

Fixed in the shared table rather than the grok provider so the menubar and
model-breakdown, which call getShortModelName directly, get it too.

Fixes #1029.
2026-08-18 12:01:12 -07:00
iamtoruk
39075edd50 parser: keep promptSource on lines over 32 KB
parseLargeJsonl dropped promptSource for exactly the lines SDK-generated
prompts live on, so the recurring-context detector regex-scanned the ends of
the raw line for it. Add the field to LARGE_ROOT_FIELDS (tiny scalar,
add-only, isSidechain already there) and delete the workaround: it read only
2 KB from each end, so a flag further in was missed.

No cache change: optimize scans the raw JSONL each run, so promptSource never
has to persist on CachedFile.

Fixes #1030. With #994 this closes #1023.
2026-08-18 12:00:29 -07:00
iamtoruk
087656bacc cache: CODEBURN_CACHE_SCOPE=all forces a full shard read
The month-scoped load a ranged query takes is a behaviour change on a warm
cache with no way back except deleting it. Drop the scope in loadCache, the
one place every caller (including the resident serve) routes through, so a
suspect scoped read can be compared against a full one in place.

Read policy only: deliberately not in PROVIDER_ENV_VARS, so setting or
unsetting it invalidates nothing.
2026-08-18 11:59:21 -07:00
Resham Joshi
564c618829
Merge pull request #1014 from ozymandiashh/fix/997-session-legend-titles
fix(dash): lead the session legend with the session title
2026-08-18 11:49:20 -07:00
iamtoruk
ca3b692acb Merge remote-tracking branch 'origin/main' into pr1014-rebase
# Conflicts:
#	CHANGELOG.md
2026-08-18 11:42:05 -07:00
iamtoruk
cfc0abb50f Merge remote-tracking branch 'origin/main' into pr1017-rebase
# Conflicts:
#	CHANGELOG.md
2026-08-18 11:41:52 -07:00
iamtoruk
60feaa8651 fix(sqlite): only reach for an immutable URI where node:sqlite honours one
node:sqlite enables SQLITE_OPEN_URI from Node 22.15 on. Below that -- 22.13 is
the package floor -- a `file:...` location is a literal filename, so the
immutable open failed as CANTOPEN and the copy quietly stood in for it. That
was the right outcome by accident; the test asserted the newer behaviour and
failed on the floor.

The support question is now asked once per process, with an in-memory URI that
touches no filesystem whichever answer comes back, and the immutable open is
attempted only when the answer is yes. The test asks the same question rather
than skipping, so both CI lines assert something: rows are correct either way,
in place where URI filenames work and from a copy where they do not.
2026-08-18 10:38:01 -07:00
iamtoruk
9bfe9cc492 fix(sqlite): read a read-only parent in place when there is no WAL to lose
Four things the copy fallback got wrong.

A database whose -wal is absent or empty has no un-checkpointed frames, so
there is nothing to go stale and nothing worth copying: immutable=1 opens the
source in place and SQLite skips the -shm it cannot create. The copy is now
taken only when a non-empty -wal exists, which is the case where dropping it
would lose rows.

A copy is published under a name carrying its fingerprint, so refreshing one
never has to unlink a file another process may still hold open, which Windows
does not allow. The -wal is published before the database so a reader can
never see the database without the sidecar holding its newest rows, and losing
a publish race to an identical copy is not an error. That removes the metadata
sidecar: the name is the fingerprint.

Superseded copies are evicted rather than overwritten -- the one in use plus at
most one predecessor, and anything untouched for a day, which is also what a
source path that no longer exists looks like. Reuse touches the copy, so its
mtime is last use.

A cache directory that cannot be written no longer fails the same way the bug
did. It emits the once-per-database notice naming the database and the reason
before the database is skipped, instead of going quiet.
2026-08-18 10:28:31 -07:00
iamtoruk
ada9382833 test(grok): pin the unpriced-model branch instead of inheriting it from the snapshot
Both multi-model tests assert that chooseAuthoritativeModel skips a
modelUsage id it cannot price and falls back to a priced one. They got that
"cannot price" from the bundled LiteLLM snapshot happening not to carry
grok-4.6-build, so `npm run build` - which re-fetches the snapshot - flipped
both assertions the moment an xai/grok-4.6 entry appeared upstream and the
prefix match started pricing the id.

Stub getModelCosts for that one id instead. The reporter's real ids stay in
the fixtures, so the tests still document the #998 case, and calculateCost is
left alone: module-internal calls are not intercepted, so cost assertions
keep pricing off the real tables.

Verified by re-running both files against a refreshed snapshot that does
carry xai/grok-4.6; 26/26 pass where they previously failed.
2026-08-18 10:26:06 -07:00
iamtoruk
7bb4e7f8e1 docs(grok): state the mixed-session drop and the global daily re-derivation plainly
The daily-cache re-derivation test seeded v18, a version that only ever
existed as an unreleased draft of this change. Seed the shipped v17 so the
test models the 17 -> 19 upgrade path users actually hit, and rename it: the
bump re-derives every day for every provider, not just Grok, because the
daily cache has no per-provider invalidation. The Grok day stays as the
fixture since Grok is what the bump exists to correct.

The changelog entry now says outright that Grok totals change materially on
upgrade (150K -> 96.3M cache-read tokens on a 568-session corpus), that a
turn without a turn_completed record inside an otherwise-covered session is
dropped rather than estimated, and that the one-time daily re-derivation
reads the warm session cache and keeps the superseded file. The
context-bloat denominator fix moves to Fixed and names the providers it
corrects.

docs/providers/grok.md gets the same undercount warning in the token model
and a matching entry under Quirks.
2026-08-18 10:22:36 -07:00
iamtoruk
267749b112 fix(sqlite): fall back for a read-only parent that reports SQLITE_CANTOPEN
A read-only parent with a -wal but no -shm fails as SQLITE_CANTOPEN (14),
not SQLITE_READONLY (8), so the fallback never ran and the un-checkpointed
rows in the -wal stayed invisible. openReadonlyCache already re-throws the
original error when the database itself is missing, which is the other
CANTOPEN, so widening the trigger keeps that case distinguishable.

Also stops copying the source -shm: SQLite rebuilds the wal-index from the
-wal in the writable cache directory, so the copy is dead weight.
2026-08-18 10:18:03 -07:00
iamtoruk
05a2afb064 Merge remote-tracking branch 'origin/main' into pr1015-rebase
# Conflicts:
#	src/main.ts
#	src/optimize.ts
#	src/session-cache.ts
#	tests/models-report.test.ts
2026-08-18 10:09:05 -07:00
iamtoruk
4dc53149bd Merge remote-tracking branch 'origin/main' into pr1014-rebase
# Conflicts:
#	CHANGELOG.md
2026-08-18 10:08:28 -07:00
iamtoruk
cc6e048479 Merge remote-tracking branch 'origin/main' into pr1017-rebase 2026-08-18 10:07:22 -07:00
iamtoruk
6b427d72ce fix(models): honest unpriced guidance, shared ID sanitizing, readable narrow warning
The unpriced-models pointer told every user to run `codeburn model-alias`. A
subscription or flat-rate model is correctly $0, and mapping it onto another
model's per-token rate invents spend that was never billed (#968), so the hint
now states the condition instead of the instruction.

`sanitizeModelForDisplay` guarded only the `--unpriced` path, leaving every
other command and format rendering provider-supplied IDs unfiltered. It moves
to the `formatModel` closures in models-report and audit-report, the two sites
every renderer routes through, covering the raw-ID fallback as well. The
`--unpriced` override stays: it bypasses that path deliberately, because
`model-alias` keys on the raw ID, not the friendly name. README says so.

Below 45 columns of panel the dashboard warning dropped its marker and count
and printed a bare command, so a narrow terminal gave no signal that anything
was wrong. `! N: codeburn models --unpriced` is 31 characters and fits.
2026-08-18 09:58:25 -07:00
iamtoruk
81eb7310a3 merge origin/main into pr996
Resolve src/main.ts to main's #1016 rank-map --unpriced/--top implementation and
drop this branch's inline comparator and topN hunk entirely.
2026-08-18 09:57:06 -07:00
Resham Joshi
8ef3468dca
Merge pull request #1016 from ozymandiashh/fix/969-unpriced-top-and-docs
fix(models): let --unpriced survive --top, and document the flag
2026-08-18 09:53:17 -07:00
iamtoruk
d8213e5827 Merge remote-tracking branch 'origin/main' into pr1016
# Conflicts:
#	CHANGELOG.md
2026-08-18 09:49:08 -07:00
iamtoruk
7fa7b36d00 merge origin/main into pr994 2026-08-18 09:41:26 -07:00
iamtoruk
29b531fced optimize: keep sidechain tool calls in the junk-read and read:edit signals
Only duplicate-reads has a structural reason to skip them: a subagent
starts on a fresh context, so re-reading what its parent read is a
necessary read, not a repeat. Reading node_modules or editing without
reading is the same waste whoever does it, and the CLAUDE.md rule both
findings suggest binds subagents too - filtering them there discarded
most of the evidence on a subagent-heavy corpus.
2026-08-18 09:38:11 -07:00
iamtoruk
2d35c8fa24 test(parser): cover durable retention through a month-scoped refresh 2026-08-18 08:52:43 -07:00
iamtoruk
2d4c00a9a8 merge origin/main into pr996
Keep both: #985's --unpriced filter (in main) and #996's top/ordering/sanitize/dashboard changes.
2026-08-18 08:50:38 -07:00
iamtoruk
eb17a03610 merge origin/main into pr994 2026-08-18 08:50:09 -07:00
iamtoruk
b2d0f29b6c Merge origin/main into pr992 2026-08-18 08:49:13 -07:00
iamtoruk
d5d42e4d26 fix(optimize): disambiguate connector namespaces against local MCP config
The prefix alone could not tell a claude.ai connector from a local MCP
server that happens to be named claude_ai_*, so the latter lost its
removal command and its apply plan. A namespace is now a connector only
when no readable local config claims that exact name; localMcpServerNames
supplies the set from the same files the remove plan edits (settings.json,
.mcp.json, and ~/.claude.json top-level plus per-project mcpServers).

A prefixed name that local config does own stays local: normal removal
command, apply plan, class fix, full savings attribution. Because the
transcript still cannot rule out a same-name connector, the finding adds a
manual note about it instead of asserting the server is one. Config that
cannot be read contributes no names, which leaves every prefixed namespace
on the conservative connector path.
2026-08-18 08:39:53 -07:00
iamtoruk
cddf5d5a53 test(optimize): pin per-session MCP schema charging without connectors
The rewritten cost pass charges each session only for the schemas that
session loaded, which changes local-only estimates too (on a real corpus
the mcp-low-coverage estimate roughly halves). Pin it so the change is
deliberate rather than a side effect of the connector split.
2026-08-18 08:27:22 -07:00
iamtoruk
117aa833cc fix(optimize): scope the apply-able subtotal to the local MCP subset
A mixed local + claude.ai connector finding is class `fix`, but `--apply`
only mutates the local servers. classTotals now credits the `fix` group
with `applyTokensSaved` when present, so the "Fix now (apply-able)"
subtotal, the "apply-able: ~$X" headline and `summary.byClass.fix` (CLI,
TUI and desktop all read these) describe what apply can actually recover.
The finding keeps the whole opportunity in its own `tokensSaved`.

Also fixes the desktop connector fixture, which predated the class/basis
fields, and adds class-level coverage: connector-only findings resolve to
`nudge` (no apply payload), a local server named like a connector stays
manual-only, and local-only findings keep their full subtotal.
2026-08-18 08:22:33 -07:00
iamtoruk
c5df60e69a merge: main into connector guidance branch
Kept both sides' act-report tests: the PR's partial-action baseline
capture block and main's applied-fix verdict block.
2026-08-18 08:17:25 -07:00
iamtoruk
527e58078d menubar: install and launch the Windows tray app from codeburn menubar
Shares the mac release resolution behind a per-platform spec (tag prefix, asset name, error text), so the Windows path reuses the pinned-version URL, the release-API fallback scan, the retrying download and the sha256 verify unchanged. Windows then runs msiexec out of %SystemRoot%\System32 with /i /passive /norestart, treats 3010 and 1602 as non-failures, and launches the exe named by the product's Uninstall registry key.
2026-08-18 06:55:09 -07:00
iamtoruk
b249ba1ffe Merge remote-tracking branch 'origin/main' into feat/optimize-recurring-context
# Conflicts:
#	CHANGELOG.md
2026-08-18 03:31:16 -07:00
iamtoruk
e3a55cdf20 optimize: count only human pastes as recurring context
An SDK session's opening prompt and a subagent's task prompt are written by
a program: they repeat by design and have no home in CLAUDE.md. Both are
flagged on the entry, but a user entry over the parser's large-line
threshold comes back without its root flags - routine for generated prompts,
which are exactly the long ones - so the markers are read off the ends of
the raw line, where the fields sit either side of the oversized message.
2026-08-18 03:27:10 -07:00
iamtoruk
52eb9fdb8d optimize: re-measure applied fixes on every run, with --auto-revert
Every still-applied journal entry now comes back with a verdict on the
next optimize run: worked (>=70% of its window-scaled estimate
realized), partial, no-effect (printed with its undo command), or
measuring while it is younger than the 3-day window. The verdicts come
off the rows act report already computes, so there is one
reconciliation, not two; the AppliedFix type and its formatter live in
act/types.ts so the optimize renderer can use them without importing
report.ts back into optimize.ts.

--auto-revert undoes the no-effect entries through the same code path as
codeburn act undo. It never touches partial or measuring entries, and
never a claude-md-rule - those land in whatever directory the user
happened to be in, the same reason --yes skips them.

--apply now names when the re-measure happens, and --format json carries
appliedFixes[] (add-only).
2026-08-18 03:15:07 -07:00
iamtoruk
8d8848d805 optimize: detect recurring context pasted at the start of sessions
Groups sessions by their opening block (whitespace/ANSI-normalized, hashed
over the first 2 KB) and flags a block of at least 1.5 KB that opens five or
more sessions. Class nudge: CodeBurn will not move the user's own text into
CLAUDE.md, so the fix asks Claude to give the block a permanent home. Only
the repeats count as savings, sized from the block's bytes because provider
usage is per API call and cannot isolate the paste. The opener comes from
the session scan that already runs, so nothing extra is read.
2026-08-18 03:13:40 -07:00
iamtoruk
0aed84f4ca Merge remote-tracking branch 'origin/main' into feat/optimize-finding-class
# Conflicts:
#	tests/dashboard.test.ts
2026-08-18 02:40:53 -07:00
iamtoruk
5660909801 optimize: per-group subtotals in every finding render
Each class header now carries its own token/dollar subtotal and finding
count, so the apply-able slice is never mistaken for the whole board; the
headline savings line names that slice explicitly. CLI and TUI share one
classHeaderLine helper, the desktop app reads the same numbers from the
new summary.byClass in --format json (add-only; the three subtotals sum to
findingCount and potentialSavingsTokens).

Also scopes the SHELL_PROFILE_SCOPE comment to what is actually true: the
MCP deferral plans refuse to rewrite a shell profile, but bash-output-cap
appends its own marker block to one.
2026-08-18 02:40:00 -07:00
iamtoruk
7c54cf85c2 optimize: classify findings as fix/nudge/keep and mark measured vs estimated
Every finding now resolves to a class (apply-able fix, habit nudge, or
informational keep) and a basis (measured from provider-counted usage, or
estimated from a schema/heuristic model), both from one table next to the
FindingId union. The class follows the plan layer: an id is 'fix' only when
buildPlan routes it, and an instance drops to 'nudge' when it lacks the
payload or cause its builder needs.

CLI, TUI and the desktop app group findings under Fix now / Habits / FYI
with continuous numbering; the CLI header reports 'N measured · M
estimated' in place of the blanket 'Estimates only.' footer. The JSON
report gains class + basis per finding and summary.measuredSavingsUSD;
existing fields are unchanged. The menubar's top three follow the same
order, since every surface reads the sorted findings list.

Sessions whose cost the provider never reported leave the cost-outliers
peer math; when nothing else is priced the comparison falls back to them
and the finding reports itself as estimated instead of disappearing.
2026-08-18 02:26:57 -07:00
iamtoruk
e2007c5e2f test(dashboard): wait for the Optimize scan on real event-loop turns, not fake-timer hops
The Optimize scan does real fs I/O (readdir/stat) that only resolves on a
real event-loop turn, but the wait loop counted 20 vi.advanceTimersByTimeAsync
hops under full fake timers, which flush fake timers + microtasks but never
give real I/O a chance to complete. Under load that read a stale
"Scanning Today..." frame. Scope fake timers to just what the 60s
auto-refresh interval needs, leave setImmediate/Date real, and wait on a
real wall-clock deadline instead of a fixed hop count.
2026-08-18 02:10:24 -07:00
Resham Joshi
d5b3720079
Merge pull request #1001 from MiloMMIN/feat/dsh-provider
feat: add DeepSeek Harness (dsh) provider
2026-08-18 01:54:48 -07:00
ozymandiashh
525b3c1d71 fix(dash): keep the disambiguator visible, make the bootstrap safe by construction
Three follow-ups from an adversarial pass over this branch.

The title cap was sized against the wrong number. 80 code points was chosen
"for both the max-w-40 legend and the tooltip", but max-w-40 is 160px and the
legend renders at text-[10px], which shows roughly 32 characters. Everything
past that is clipped -- and that is exactly where the short session id, the
provider and every collision-tier suffix lived. Two sessions in one repository
whose AI titles share a 32-character prefix rendered as the same legend entry,
which is worse than main and is the scenario #997 is about. The label now leads
with the disambiguator so it is always inside the visible width, and both the
legend and the tooltip carry title= so the full label is reachable on hover.

injectDashboardBootstrap was not safe by construction. Extracting the helper
fixed the $-substitution problem but left the security-critical '<' escaping at
the call site 94 lines away, and the new test called the helper with raw
JSON.stringify output -- so deleting that escape left every test green while
the served page became injectable through any project, device or model name.
Nothing in tests/ asserted that escaping at all. The escaping moves inside the
helper, with a test that pushes </script> through a payload value.

preferredSessionTitle picked alphabetically, not most recently. types.ts
documents title as the last ai-title entry, so when one session id yields two
summaries the legend could show the superseded one. It now picks the greatest
lastTimestamp, keeping the alphabetical order only to break exact ties so the
result stays deterministic. Entries are also ordered by key before the
collision tiers run, so the same corpus cannot emit a different label set
depending on input order.
2026-08-18 05:49:16 +03:00
ozymandiashh
a32761880f fix(sqlite): survive a read-only database parent instead of reporting no sessions
`openDatabase` opens provider databases with `readOnly: true`, which is not
enough for a WAL-mode database: SQLite has to create `<db>-shm` and an empty
`<db>-wal` in the database's own directory unless they already exist. Two
things follow, and both are real.

The open writes. On a writable directory it succeeds and leaves two files
behind in the user's provider directory, which is not what "CodeBurn only reads
your session logs" implies.

On a non-writable parent it fails outright with "attempt to write a readonly
database". It is conditional on -shm being absent, which is exactly the state
after the tool exits cleanly, so the symptom is intermittent: a provider's
spend disappears whenever that tool is not running. Both discovery sites
swallowed it with a bare `catch { continue }`, so the provider reported zero
sessions with nothing on stderr - indistinguishable from the tool not being
installed. It covers cursor, cursor-agent, opencode, goose, warp, kilo-code,
zerostack and the copilot agent-traces DB.

The direct open stays the fast path and is unchanged when it succeeds: no stat,
no permission probe. Only when SQLite reports SQLITE_READONLY does the fallback
run, copying the database and its -wal/-shm siblings into the CodeBurn cache
directory and opening the copy there. The copy is fingerprinted the same way
session-cache fingerprints a SQLite source, so an unchanged database is not
copied twice, and there is one bounded entry per source path.

The discovery sites now tell SQLITE_READONLY apart from ENOENT and emit one
notice per source path rather than per session, matching what the parse-time
paths already do.

This is not specific to any one sandbox: it applies to a database on read-only
media, a restrictive-permissions setup, and both the Flatpak and snap
confinements. The snap was narrowed to a read-only personal-files plug in #978
and is likely affected; I have no snap install to confirm that on.
2026-08-18 05:45:45 +03:00