Commit Graph
26 Commits
Author SHA1 Message Date
HomerandClaude Opus 5 a16510aba8 The interface speaks Slovak
969 strings, an instance default and a per-person choice, and no half-done corner:
the admin prose is translated too. Design and reasoning: LLeMbas.wiki/Translations.

KEYED BY THE ENGLISH SENTENCE

A missing entry renders the key, which is the English -- so an untranslated string
looks as it always did, an English instance is byte-for-byte 1.6.0, and a
half-finished catalogue is a half-translated page rather than a page of dotted key
names. The cost is that editing an English sentence orphans its translation
silently, which is what tests/test_translations.py asserts in both directions.

No gettext: .po -> .mo is a build step and this project does not have one.

A JINJA GLOBAL, AND THEREFORE A CONTEXTVAR

`t()` is a global for the reason `brand` already documents -- render() is bypassed
by 25 TemplateResponse calls and 8 get_template().render() calls, the latter being
the SSE frames, which have no Request at all. A global is bound once at import and
the language is per person, so the active language is a ContextVar set per request.

🚨 `get_current_user` had to become `async def`. FastAPI runs a sync dependency in a
threadpool, and anyio copies the context in and discards it on the way out -- so
the language was set where nothing could see it and every page rendered in the
instance's language whatever anybody had chosen, with no error anywhere.

NOT TRANSLATED, ON PURPOSE

Everything a model reads: the 60 prompt fragments, and the dates in harness.py,
schedule/runner.py and schedule/compile.py. Only `i18n.stamp` is localised, and
only where a person reads it -- with the *format* translatable as well as the
words, because "26. septembra 2026" is a different pattern rather than the same one
with different words in it. A process locale is not an option: global, not
thread-safe, two people's pages at once.

A second fragment telling models to answer in the reader's language was written
during this work and removed: `core.style` has said it since long before, and
test_an_empty_override_turns_a_fragment_off caught the duplicate.

THE BULK PASS

1213 sites wrapped by a one-off script that only touched patterns it could not
misread. It got three wrong in a way that mattered -- `t('…')` inside `attr="…"`
where the sentence held an apostrophe, closing the Jinja string and 500ing two
pages whose partials no test renders. tests/test_translations.py now compiles all
110 templates. It also wrapped the product's own name, an SSH key header, a
keystroke hint and an example URL, all taken back out: a string is not
translatable just because it is a string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 14:46:33 +00:00
HomerandClaude Opus 5 da0797ccad A crowd in one chat
The chat's own model answers, then each other member in order, then the order runs
backwards asking each whether it disagrees, ending at the main model, which either
closes or sends them round again. Design and reasoning: LLeMbas.wiki/Crowd-chats.

THE SPEAKER SEAM, WHICH IS ALSO A BUG FIX

`chat_service.speaker_for` makes the *message* name the answering model and the
chat only the default. That closes a live half-wired feature -- `wake_chat` takes a
model override and `schedule/runner` passes one, and it reached the row and never
the request, so a schedule naming another model got the chat's model wearing the
other one's name.

The seam is wider than `build_request`: `{{model_name}}`, the authored prompt's
model layer, `vision` (where a wrong answer makes the endpoint reject the whole
request), the effort vocabulary (which raises inside the model's own chat template,
and whose refusal narrows every Model row sharing the id), `resolve_tools`,
`context_length` -> `_too_big`, and `ToolContext.model_id`. `resolve_endpoint` may
now only write back `chat.connection_id` when the speaker *is* the chat's model.

WHY N CHAINED REPLIES

`Generation` is one reply's state and `_follow` streams per message, so one
generation cannot stream into nine bubbles and `ensure` would not know which of the
nine it was after a restart. A subagent per speaker cannot work either: its answer
comes back as a tool result and tool results are never replayed, so speaker 3 could
not see speaker 2 -- which is the whole point. Chained, exactly one incomplete row
exists at a time, and `tests/test_crowd_chain.py` asserts that at every
observation.

The round lives on `Message.crowd_json`, not on the chat: the row is the authority,
and chat-level state would describe turns a rewind or a restart had removed.
`crowd.next_turn` is pure, so all eight refusals are tested with no endpoint.

THREE RULES, EACH A BUG WRITTEN THE OTHER WAY ROUND

- `if not _advance_crowd(g): _drain(g)` -- advancing must *suppress* draining, or a
  queued human turn puts a second incomplete row beside the next speaker's.
- `_advance_crowd` refuses unless the finishing row is the newest, or regenerating
  member 2 creates a second member 3 and two chains race down one turn.
- an error skips one speaker and two in a row end the round: the usual failure is a
  small member's window overflowing, and `_drain`'s stop-on-error would kill every
  crowd at whichever member is smallest.

Each other speaker's turn is relabelled as attributed user content, which is both
how a model can disagree with words it did not write and how the history keeps
alternating. The per-speaker instruction is payload-only -- as a row it could be
dropped from the request by a `created_at` tie, and every later speaker would answer
it. Compaction, titling and the notification are gated to once per turn; `_inject`
is off during a round; the way back gets no tools and a member is treated as
unattended.

Membership stores the model as text with no foreign key: "Test & refresh" deletes
and recreates Model rows, and a cascade would empty the crowd out of every chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 13:38:52 +00:00
HomerandClaude Opus 5 ac51dd46cc A personality belongs to a person
Owner's correction to 1.4.0: a model's character is per (model, person), and only
the description and the notes stay instance-wide. Two people talking to one model
are not talking to the same personality, and neither can see the other's.

The administrator's box becomes the DEFAULT, resolved by `personas.effective` as
a fallback and never as a layer -- two personalities at once contradict each
other with nothing to say which is losing, which is the reasoning behind "system
prompts replace, never stack". `persona_write` takes no argument naming a model
or a person; both come from the ToolContext, so it can only write the character
it has with whoever it is talking to, and it never touches the default.

Impressions move to their own table. Not a `kind` column: 1.4.0 shipped
`UNIQUE(model_key, owner_id)`, SQLite cannot alter a constraint and this schema
is additive-only, so a discriminator would leave an upgraded instance unable to
hold both rows for one pair. That leaves the first MANUAL_STEPS entry this
project has had -- the two shapes are indistinguishable, so nothing rewrites
them: a repair would be guessing at text that is read back in the first person.

TWO BUGS FROM A PHONE

`min-width` beats both `width` and `max-width` -- CSS clamps width to max-width
and then raises the result to min-width -- so `.canvas` and `.terminal` were
384px wide on every screen narrower than that, their `min(…, 100vw)` cap
overruled, and `.inspector` had no cap at all on a width that is a preference
draggable to 2400px. None of it scrolled sideways, because all three are
`position: fixed` and fixed overflow does not extend the scrollable area -- which
is exactly why the 1.1.0 narrow pass reported these pages clean. `min-width: 0`
in the overlay query, full width below the phone breakpoint, tablet column kept.

And the install button now says why it is absent. Measured against the live
instance: the manifest meets every Chrome criterion and the blocker is a
certificate from a private CA, so the origin is not trustworthy, the service
worker is refused and no install is offered. `base.html` had been swallowing that
with an empty catch -- which kept the page working, the reason it was there, and
threw away the only evidence. It now records the outcome and `app.js` turns it
into a sentence naming the certificate, which is the cause the old hint did not
mention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 12:20:40 +00:00
HomerandClaude Opus 5 df52ec9d96 Models that know about each other, and have a self
Three features sharing one idea: a model here started from nothing every
conversation and had no notion that anything else existed.

THE ROSTER. `chat.roster_block` builds one line per model this *person* can
reach -- through `permissions.models_visible_to`, never the table -- and
`{{model_roster}}` carries it, gated on the `friend` family for the reason the
memories block is gated on `memory`: a list of peers a model cannot talk to is
context spent on nothing, and one checkbox is then the whole switch. New
`Model.notes` column, a column and not a `capabilities_json` key for the reason
`context_length` and `reasoning_efforts` both carry.

ASKING A FRIEND. A second entry point in `services/subagent.py` rather than a
second module, so one place still owns the bounds and the lifecycle. `_create_
child` takes the friend's (model_id, connection_id) *pair*, because Model is
unique on both and an id alone does not say which endpoint. Three things differ
from a helper: the effort is the friend's own default and never the parent's (the
1.3.0 bug by another door -- the vocabularies differ and a level a model does not
take raises inside its chat template), the chat is ordinary even when the asker's
is an agent chat, and `scope_json["role"]` marks it so `core.friend` speaks
instead of `core.subagent`. `friend` joins the unattended withdrawal set: a
friend that could ask a friend is the same unbounded fan-out in politer clothes.
Budget, concurrency and quota are shared with helpers, so one reply cannot spend
the allowance twice.

PERSONALITY. One table, two roles, `owner_id IS NULL` the discriminator: the
model's own persona, and its read of one person. Keyed on the model's *text* id
with no foreign key, because "Test & refresh" deletes a model the endpoint has
stopped listing and a personality must not be collateral. `PersonaRevision`
copies SkillRevision, and so does the argument: the safety story for a model
rewriting itself is a record and a way back, not a gate. The reflection is shown
to the person it is about, in their own settings, which is the whole of why
keeping one is acceptable. `persona` is withdrawn from any unattended chat --
a helper's task, a friend's question and a schedule's instruction are all words
nobody watched being written.

Two bugs found while reading for this, both silent:

`review_model_id` stored a `Model` primary key, so a refresh taken while an
endpoint was not listing that model unset the administrator's choice -- and
`_reviewer` then fell back to the chat's own model, so pictures were judged by
a model nobody chose. Now the text id, with the primary key still accepted.

`_messages_after` used a bare `>` on `created_at`, so a row sharing the edited
turn's microsecond survived a rewind -- and `_send` writes a user turn and its
placeholder back to back, which is exactly that tie. Deliberately NOT
`thread_tail`'s `(created_at, id)` tiebreak: ids are random UUIDs, so that
settles a tie by coin toss. A tie now reads as "later", which is the safe
direction for an operation whose purpose is to discard what follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 02:04:55 +00:00
HomerandClaude Opus 5 54fee49810 A page that could not save, and said nothing
The model page has been unable to save anything below the reasoning efforts
since 1.3.0. "Save changes" did nothing at all, so the description, the system
prompt, every capability and tool switch and the whole availability card
silently would not take -- while the fields above it saved normally, which is
what made the page look as though it worked.

"Detect from the endpoint" had stopped detecting too: it submitted the page as
an ordinary save carrying only the top half of the form, so every field below
took its empty default. Pressing it would have cleared that model's description
and system prompt and switched the model off with all of its tools disabled.

One HTML rule causes both. A form inside another form is not allowed, and rather
than complaining a browser discards the inner start tag and lets the matching
end tag close the *outer* form -- so from that point down the page was in no
form, and a button in no form does nothing. The detect form is now declared
before the main one and the button reaches it by id.

Nothing in the markup reads wrong, and no test that posts to a route can see
this, because such a test supplies the fields itself. tests/test_form_structure.py
reads every template the way a browser parses it instead, including that rule,
and was checked against the old markup before being trusted: it reports the same
orphaned "Save changes" that headless Chromium did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 01:21:18 +00:00
HomerandClaude Opus 5 0ed7dd9fc8 A list column backfilled with a dictionary
Reported as a 500 on a live instance, immediately after it updated, and read
off its journal rather than guessed at:

  ValueError: Attribute 'reasoning_efforts' does not accept objects of
              type <class 'dict'>

`Mapped[list[str]]` is not Optional, so the column is NOT NULL, so SQLite
demands a default for the rows that already exist. `_literal_default` chose one
by asking `column.type.python_type` -- and `MutableList.as_mutable(JSON)`
returns the *same* JSON type object with a listener attached rather than
subclassing it, so `python_type` is `dict` for both flavours. Every existing row
got '{}' in a list column, and MutableList refuses a dict while *loading*: not a
wrong value sitting quietly, an exception on every read of the table.

Model.reasoning_efforts was the first list-shaped JSON column this project had
ever added to a table that already had rows, so the flaw had been harmless since
the runner was written. 1.2.0 stepped on it.

The shape now comes from the column's Python-side default -- `default=list`
against `default=dict` -- which is the only thing that can tell the two apart.
And `repair_json_shapes` puts right what was already written, on start,
converging like ensure_fts beside it, narrow enough that a legitimate {} in a
dict column survives.

Why 1981 tests missed it: conftest builds a fresh database, where the column is
created from the model with its real default. The backfill only runs on a
database that already exists, so the suite had never once exercised the path
that broke. The new tests corrupt a row exactly as the migration did and assert
it loads again.

Verified against a backup of the reporting instance's own database: the load
raises before, eleven rows are repaired, all eleven models load after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 22:59:21 +00:00
HomerandClaude Opus 5 b1dbca7db6 Reading the answer instead of asking somebody to know it
llama-server publishes the loaded model's Jinja chat template on /props, and
that template is the very thing that rejects a reasoning effort it does not
recognise -- so the accepted set is written down, authoritatively, in a place
this application can simply read. There is a button on the model's page that
does.

The parser handles both shapes a template uses: the values inline in the test
that rejects them (Bonsai), and a named list set elsewhere with nothing near
the mention spelling them out (gpt-oss). It is deliberately conservative,
because a wrong answer here silently removes a level somebody is entitled to:
only known efforts count, an unrelated list of quoted strings is ignored, and a
single match is read as a default -- `{%- set reasoning_effort = 'medium' %}`
-- rather than as a vocabulary of one.

An endpoint with no such route says so. OpenAI and vLLM do not publish a
template, and "this cannot tell us" must not be recorded as "this model accepts
nothing".

/props sits at the server root, beside the OpenAI-compatible surface rather
than inside it, so a base URL written as .../v1 needs the suffix stripped.
Getting that wrong is a silent 404 that looks like detection simply not
working, so there is a test on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 22:23:22 +00:00
HomerandClaude Opus 5 32e2326d41 An effort the model had never heard of
Reported from a live instance, on Bonsai:

  Jinja Exception: Unexpected reasoning effort high. Supported types are
  xhigh (default), medium, and low.

Effort goes out two ways because no single field works, and the second --
chat_template_kwargs -- is not a parameter the server interprets. It is
rendered into the model's own chat template, which does not ignore a value it
does not know: it calls raise_exception, and the request dies before a token.
So a perfectly ordinary option, drawn by this application in its own menu, took
the whole reply with it.

The vocabulary is per model and nobody agrees. gpt-oss takes low/medium/high.
Bonsai takes low/medium/xhigh and refuses high. OpenAI has added minimal, xhigh
and max at different points, and which of them a given model accepts varies
again. One global tuple was going to be wrong for somebody whatever it held.

A model carries its own list now, and the picker, the slash command and the
request builder all read it. A column rather than a key in capabilities_json,
for the reason context_length is one: that dict is rebuilt wholesale from the
submitted checkboxes on every save.

And it corrects itself. A refusal retries the reply once without the effort
rather than losing it -- safe only because the template renders before any
token, so nothing has been emitted, and there is a guard that keeps it that way
-- then narrows the model's list. Bonsai's error states what it does take, so
that is what gets stored.

Note the parser bug, because it is a good one: "high" is a substring of
"xhigh", so reading the advertised list by substring learned `high` from a
sentence explaining that `high` is the problem. Whole words now, with a test
named after it.

/effort reads its levels off the picker instead of a second copy of the list
kept in the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 21:20:06 +00:00
HomerandClaude Opus 5 b7bf7d728b An admin area a phone could reach and not navigate
Administration has a nav of its own rather than the chat sidebar, and 1.1.0
gave every `.sidebar` the drawer behaviour -- starts closed, slides in --
without giving that one any of the drawer's furniture. No id for the toggle to
resolve, no toggle, no close, no scrim: it sat at left:-280 with nothing in the
application able to open it. The close button and the scrim are partials now,
used by both, and the test that guards it *finds* sidebars by scanning the
templates rather than working from a list, which is exactly why this one was
missed.

The chat, measured at 390px, spent forty pixels of side padding and a
forty-four pixel avatar column before drawing a word -- close to a quarter of
the screen on margin, so anything that could not wrap had to be reached
sideways. Padding halved and the avatar moved above the turn; a code block
gained about sixty pixels.

Worse in the same row: `.topbar__actions` asked for 317px of a 390px bar,
because the control that used to give in that row is display:none below a
tablet width, so the group went rigid and the title -- flex: 1 -- was squeezed
to exactly zero. And `.btn--icon` sets a width with no `flex: none`, so the row
shrank the button instead of the text: the sidebar toggle measured eighteen
pixels across. The picker gives now, and shows its avatar rather than its name
on a phone.

Also the instrument, which lied twice more: it could not see horizontal
overflow at all, because `.shell` is overflow:hidden and its "is this
contained" test therefore answered yes for everything on the page; and run from
a copy it resolved `STATIC` to a directory that did not exist, rewrote every
asset URL to a dead file:// path and reported the whole application overflowing
by thirty thousand pixels. It resolves from the imported package now and
asserts that what it rewrote to is really there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 19:27:39 +00:00
HomerandClaude Opus 5 201281d616 New markup over an old stylesheet
Reported from a desktop browser: a stray close button beside the logo, badly
drawn, and a page that would not scroll. None of it was in the code that was
running -- it was the code the browser had not fetched.

The worker caches /static/ under a cache named for the release while the files
in it carried no version, and a page is fetched network-first. That only ever
worked because the worker used to seize every open tab the instant it installed
and wipe the old cache. 1.1.0 stopped it doing that, rightly -- it was swapping
stylesheets out from under a streaming reply -- and a momentary mismatch became
a permanent one: new markup over the previous release's CSS for as long as the
old worker lived. `.sidebar__close` had no rule there, so `.btn--icon` made it
inline-flex: visible everywhere, placed by nothing.

Every /static/ URL carries the release now, written by `templating.asset` and
precached by `sw.js:versioned` -- both halves, because caches.match compares the
query too and precaching the bare path would cache entries nothing requests.
Self-correcting: updating is enough.

The header was also a brand with a button appended and margin-left:auto doing
the placing, which holds exactly while that button is last. Two slots now: a
brand that shrinks and truncates, and a rail on the trailing edge.

Verified before changing anything: with the current stylesheet the button is
display:none at 1280 and, with thirty chats and forty messages, both scrollers
scroll. The first measurement said the thread did not -- that was
scroll-behavior: smooth reporting where it started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 18:16:24 +00:00
HomerandClaude Opus 5 28390095a9 A phone, and how much of this could not be used on one
The sidebar was a 280px panel laid over the page below the phone breakpoint,
opened from first paint, with the only control that closed it underneath it --
and that control existed on /chat and on none of the seven other pages carrying
a sidebar, Settings included. It starts closed at that width now, slides, dims
the page behind it, and closes by tapping beside it, by Escape, or by its own
button, which is inside the drawer where it can be reached.

Everything a finger has to hit was 36px, or 28 for renaming a chat, every action
on a message and every panel's close button. Raising --control-h under a coarse
pointer is the only fix that reaches all forty of them, which is what that token
is for. The row and message actions were also hover-only, so on a phone they did
not exist at all.

Installing: the splash and the browser chrome follow the instance's theme rather
than always being Moria's near-black; there are screenshots, so the install
offer is a dialog rather than a one-line bar; a new release no longer takes over
a page somebody is reading; the notification badge is a silhouette rather than a
grey square; and a browser rotating its own subscription no longer ends
notifications for good.

Every request now says it is happening -- nothing did before, so anything slower
than a few milliseconds looked like a click that had not registered.

A chat can be archived. The column has been filtered on in four places since
folders arrived and written by nothing, which is what made it look built.

chat.css may contain media queries. The ban protected the composer toolbar from
being "fixed" with a breakpoint; that guarantee is asserted directly now, and
the old test would have passed a version of the file that wrapped the toolbar
without one.

scripts/shoot.py is the instrument all of this was found with: it renders a page
through TestClient into a real headless browser at a real size and refuses to
run if an asset URL was left pointing at testserver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 13:39:35 +00:00
HomerandClaude Opus 5 92070d7879 Six things that looked like they worked
None of these fails loudly and two of them correct themselves if you reload,
which is why five were found by reading rather than by anybody reporting them.

The reply that lost its author is the one worth knowing about: the frame that
replaces a bubble when a reply lands was looking the models up as nobody, and
"no user" answers "no models" rather than "all models" -- so every finished
reply swapped the model's avatar for the plain mark and put the instance's name
where the model's should be, until the next page load put it back.

Beside it: a concurrency quota enforced on two of the six paths that start a
reply, including neither of the two most used; a custom theme whose success and
warning colours moved the text and left the background behind; a phone shell
sized to one viewport inside a document sized to another, which is the reported
scroll past the bottom of Settings; a whole conversation's Markdown rendered on
every page load and read by nothing; a skip guard inert since it was written;
and an endpoint nothing has ever called.

The scroll fix folded five near-identical scroller rules into one, which is
also where the containment they were all missing now lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 12:49:26 +00:00
HomerandClaude Opus 5 d73a791c86 An installer that had only ever met Arch
`deploy/lxc-install.sh` had never been executed -- there was no Proxmox host
to run it on, and PLAN.md said so rather than letting it read as tested. It
was reviewed and `bash -n` checked, which is not the same claim. Running it
for the first time found two Arch-isms in `install.sh`, the script it wraps,
and only a Debian machine could have found either.

`python -m venv` is the one that mattered. On Arch `python` is Python 3, so
the bare name had worked on the only machine this had ever run on. Debian has
no `python` at all unless somebody installed `python-is-python3`, and the LXC
bootstrap installs `python3` -- so the install aborted at the virtualenv step,
with the service user, the bind mount and the clone already in place. It is
`python3` now, which is right on both.

`--shell /usr/bin/nologin` is the one that did not. That is where Arch keeps
nologin and not where Debian does, but nothing ever invoked it: `sudo -u`
execs the command directly and systemd's `User=` never reads a shell. The
account worked while pointing at a file that was not there. `/usr/sbin/nologin`
is correct on Debian and resolves on Arch too, whose `/usr/sbin` is a symlink
to `bin`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 19:42:28 +02:00
HomerandClaude Opus 5 25aa208d04 Documentation that points where the documentation is
The working notes, the roadmap and the eight topic notes now live on the wiki,
so the twelve places in the source that said "see CLAUDE.md" were pointing at
a file this repository no longer has. They say "see the working notes" now,
and the README opens onto the wiki rather than onto two files beside it.

Four references are deliberately untouched -- prompts.py, settings_store.py,
admin/agents.html and the whole of agent/instructions.py. Those name AGENTS.md
and CLAUDE.md as the file an agent chat looks for in *somebody else's* project
directory. Rewriting them would have broken the feature while looking tidy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:38:38 +02:00
HomerandClaude Opus 5 7ebdc9c722 A version that was not two spellings of itself
The Updates page read "v1.0.0 (reports 1.0.0)". That note exists to warn
that a tag was cut before the version bump -- a release nobody can
identify afterwards -- and it was firing on two ways of writing one
version, because `git describe` answers with the tag's name and tags here
carry a `v`.

Stripped in `_describe`, where `resolve_target` has always stripped it and
where the docstring already promised the stripped form. The mismatch check
then compares two things spelled the same way, and still reports a tag
that really does disagree; there is a test for each half.

Found by cutting the first release, which is the only place it could have
been found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:33:32 +02:00
HomerandClaude Opus 5 cdad9f0bc7 1.0.0
The version, the changelog entry, the plan and the README. Nothing else,
which is what makes this readable as a release rather than as work.

CHANGELOG.md's 1.0.0 entry is assembled from every version below it, as
that file has said it would be since it was written: those shipped as a
running deployment rather than as releases, and this is what they add up
to. It is also what an administrator reads -- /admin/updates takes release
notes out of the annotated tag, so the tag message is this entry.

It says what arrived, then the part worth reading: the nine things that
had shipped looking correct and were found by five audit passes. Then
where the edges are, because a first release should say what it does not
do before somebody finds out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:26:44 +02:00
HomerandClaude Opus 5 656bea2b20 What the audit is worth keeping, and where
Five passes produced a working document that said on its first line it was
temporary. This is it being spent rather than abandoned.

CLAUDE.md gains eleven paragraphs, each a thing that had shipped looking
correct: a handler bound to a shared variable rather than its own socket,
a script the base template already loads being loaded again, a control
that stays clickable while it awaits permission, "is this name taken?"
asked about visibility instead of ownership, root running a file the
service account can write, sourcing anything under $PREFIX, a read-only
command name that is not a read-only command, 0.0.0.0 being this machine,
a folder that is not a label, a file that is not deleted by the row that
named it, and a measuring harness that measured an unstyled page and
reported a dramatic finding that was entirely an artefact.

PLAN.md carries the seven things the audit found and deliberately did not
fix, each with why: they change what something does rather than fix what
it claims to do, which is not an audit's job.

deploy/README.md says why root runs a copy, and that a host installed
before this keeps the old wiring until the installer is re-run -- the
button cannot fix it, because the button runs the old unit.

docs/notes/audit-0.9.md is deleted, having been all three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:25:44 +02:00
HomerandClaude Opus 5 3d51ba061e Tests that found things reading did not
The testing pass: 2140 tests to 2283, and four bugs that no amount of
reading had turned up. Three came from driving the JavaScript under a
Node DOM stub, which is the practice CLAUDE.md sets out and this is the
reason it does.

The terminal dropped every keystroke after a reconnect. `onclose` closed
over the module-level socket rather than its own, and close() queues its
event -- so the old socket's close arrived after a new one was assigned
and nulled the live one. Output kept coming, because onmessage is bound
to the object, while every send gates on the variable. It also announced
"Disconnected" about a shell that had just reconnected.

Two scripts were loaded twice on /messages, once by base.html and again
by the page. Each is an IIFE with its own state, so four keyboard
shortcuts toggled their panel twice and therefore did nothing, /help
opened two dialogs, and an @ mention attached its file twice. A sweep
refuses any template re-loading what base.html has.

The microphone had no guard while the permission prompt was up, so each
click opened another stream and only the last was ever stopped. And a
skill shared with you took its name out of your own library: create
checked uniqueness against what is *visible* rather than what is owned,
against a (owner_id, name) constraint, and told you to edit a row you
cannot edit.

--ink-faint failed the contrast minimum in both themes -- 3.85 and 3.19
against 4.5 -- so the smallest text on every screen was the hardest to
read. Measured in a headless browser rather than judged by eye.

And the suite runs on 3.11 and 3.12 now as well as 3.14. It had only ever
run on 3.14 while the image ships 3.12 and the packaging claimed 3.11:
the interpreter most people would run was the one nothing had tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:41:45 +02:00
HomerandClaude Opus 5 32003bf8dd An installer that moved a channel nobody asked it to
The channel lives in two places -- lembas.env, which the page reads, and
the systemd unit, which the button obeys -- and a re-run keeps the env
file while rewriting the unit. Defaulting to stable therefore meant a
re-run for some unrelated reason silently moved one half and not the
other, leaving a host whose page named edge and whose button deployed
stable.

That mismatch already had an alert. An installer that causes the thing it
detects is the wrong end to be detecting it from, so it defaults to what
the host already follows. Parsed rather than sourced: that file holds the
secret key.

Found by running it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:09:15 +02:00
HomerandClaude Opus 5 96f269dadb Boundaries that were supposed to hold
The security pass. Six findings, none reachable by visiting the site and
every one a boundary this codebase says it keeps.

A subagent is pinned to a list of read-only commands, in every mode,
unattended, with no card anybody could approve -- and `find *` was on it.
find writes files with -fprintf, runs programs with -exec and removes them
with -delete, and none of that needs a character the metacharacter guard
refuses. A page the model had just read could ask for a helper and get a
key into authorized_keys, from Plan mode, which promises to change
nothing. Refused in `subject()` rather than trimmed from the list: a
pattern cannot say "and no dangerous flags", and "this one looks
read-only" is exactly what put find there.

The loopback guard missed `0.0.0.0`, which is not is_loopback but does
connect to localhost -- so it answered a *decided* False and skipped the
DNS half too. The one spelling of "this machine" that walked past a guard
whose whole job is that sentence.

Twice in the update helper, which is the one place this deliberately
crosses a privilege boundary: root ran a script the service account owns,
and root sourced a file that account can replace. Either turns a
compromise of the web application into root. The first needed no
compromise at all -- a pull happens as the service user and root runs
whatever it fetched, so control of the branch was control of root. The
old test asserted that exact ExecStart line and had pinned it in place.

Push endpoints skipped check_url, the only outbound request that did. And
a chat could be filed in another account's folder, which hands over its
system prompt -- `_new_chat` resolved the folder, discarded it when it was
not the caller's, and stored the raw id anyway.

An existing helper install keeps the old wiring until install.sh is
re-run; update.sh now says so when it finds itself inside the checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:45:59 +02:00
HomerandClaude Opus 5 4bcacee143 Air between what is running and the button that checks
The Check the remote button sat flush against the version and commit
above it, so the two read as one block.

Keyed on the list not being last rather than on the sibling's class:
three different things follow it there depending on the host's state --
the button, the version-mismatch alert, the not-a-checkout hint -- and
enumerating them is how the fourth gets missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:27:35 +02:00
HomerandClaude Opus 5 59739cc7fd Files that outlived the chats that held them, and a page that led with its footnotes
The second audit pass. Four things, and the first two were reported.

The Prompts page put a screen of variables and a screen of preview above
the editor, so the tabs began two screens down and switching one had to
drag the whole page to be any use -- and on a short tab it could not drag
far enough, leaving the panel stranded above a screenful of nothing.
Editor first, reference after, bar sticky. Custom themes were three fixed
slots: fifty-seven empty colour boxes on a fresh instance and no way to
make a fourth theme. One block per theme plus a blank one, colours behind
a disclosure. Both measured rather than argued about -- rendered through
TestClient and driven under headless Chromium, where the tab bar moved
385->642px before and does not move now, and the themes page went from
5495px to 2820px.

Asking where generated images go found the other two. Deleting a chat
cascades to the attachment rows and leaves every file on disk; the helper
written for exactly that was called from one place, and it was not the
delete button, a schedule's chat, a helper's chat or deleting an account.
Underneath it, `claim` bound message_id and never chat_id, so anything
picked before a chat existed kept an empty chat_id forever -- which six
readers filter on, so those files were also unnamed in the prompt,
unopenable in the canvas, and invisible to the one caller the cleanup had.

And folders nest now. The route has handled parent_id since folders
existed, with a cycle guard and a depth cap the move path never applied;
the sidebar has always drawn a tree. Nothing could ask for one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:20:59 +02:00
HomerandClaude Opus 5 e970f10cca A time in no particular zone, and a preview missing what it previews
The first audit pass: everything from 0.8.1 to 0.9.8 read as a whole rather
than one feature at a time, starting with what a model is actually told.

Four of these had shipped as correct. The date line carried a timezone
variable that resolves to nothing until somebody chooses one -- so every
default account was told times were "in  unless they say otherwise", while
two comments asserted the line disappeared instead. The prompt preview
built its variables without a chat, which is what eleven fragments are
gated on, so the whole agent surface was absent from it whatever was
ticked. Plan mode was instructed to keep its plan current with a tool that
mode withdraws. And knowledge_get returned a document whole where every
sibling reader caps and says so, its description promising exactly that.

The subagent guidance was wrong in both directions at once: it denied a
documented parameter and named seven of twenty-three allowed commands.
Both halves are pinned by tests against the real list and the real schema
now, because prose and a constant drift the moment one is edited alone.

docs/notes/audit-0.9.md carries the findings that are not fixed here, with
why -- the ones whose fix would change what a feature does are the user's
call, not this pass's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:13:54 +02:00
HomerandClaude Opus 5 0ce8026bd2 A helper that would have deployed a channel nobody named
The channel is declared twice: in lembas.env, which this process reads and the
page prints, and baked into the systemd unit, which is what the helper actually
deploys. install.sh writes both together so they agree by construction -- and
the moment somebody edits one by hand they diverge, with the page naming one
channel down every card and the button deploying the other. Nothing anywhere
would have said so.

It cannot be collapsed to one place. Reading it from lembas.env at deploy time
would mean the service account decides what gets deployed, since it owns that
file -- and "the request carries no channel" is the property the whole design
rests on. So the two stay, and the marker file the page already reads to know
the helper exists now carries the channel it was installed with. A disagreement
is an alert.

Display only, deliberately: the service account can write that marker, so a
compromised process could lie about what the helper will do -- but not change
it, because the helper's own channel lives in /etc where that account cannot
reach. Lying about the channel is a much smaller thing than choosing it.

An empty marker -- every host installed before this -- reads as unknown rather
than as a mismatch. Claiming one would put a red alert on every existing host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:44:35 +02:00
HomerandClaude Opus 5 f68caec849 A changelog, kept from now rather than assembled at the end
Every version bump gets an entry in the same commit. Not afterwards: the reason
a change was made is known while it is being made and gone a week later, and a
changelog assembled from commit subjects at release time is a list of things
nobody can act on.

Backfilled 0.8.2 through 0.9.8, because those shipped as a running deployment
rather than as releases and 1.0.0's notes have to be assembled from something.

The rule that earns the file its place is the last one in CLAUDE.md: a fix to
something that *looked* like it worked gets a line, always. Those are the
entries somebody stops working around a bug because of, and they are invisible
from outside -- nobody reports a control that silently does nothing, they just
quietly stop using it. Half of what is in here is that shape: a group delete
that left its grants, a share panel that only saved if you also saved the
resource, an update script that stopped after "== fetching ==".

A release is a signed annotated tag whose message is that version's entry, and
that is not decoration -- /admin/updates reads release notes out of the tag
object, so the tag message is literally what an administrator sees on the update
page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:36:44 +02:00
HomerandClaude Opus 5 8219bd9635 Release notes that are not forty lines of base64
Found by documenting it. `_notes_for` stripped `-----BEGIN PGP SIGNATURE-----`
from an annotated tag's contents and nothing else, and which header appears
depends on `gpg.format`: `openpgp` writes that one, `ssh` writes
`-----BEGIN SSH SIGNATURE-----`. This repository signs with an SSH key, so the
first signed release tag would have rendered its whole signature block as the
release notes on the update page.

`%(contents:subject)` and `%(contents:body)` would have avoided the question,
and would also have thrown away every blank line in a body written as a list --
which is what release notes are.

The suite caught the other half of the same change: `tag.gpgSign` makes a bare
`git tag <name>` behave as `-s`, so the lightweight tags a test was making now
wait for an editor it does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:29:42 +02:00