Repository navigation
v2026.9.14
Full Changelog
203 PRs from 5 contributors since v2026.8.30.
Highlights
-
Goals system — a new
goalcommand and Goals tab in the TUI let you define and track autonomous goal runs, with live phase badges, iteration counts, and inline error reporting. -
Agent token footprint & analytics — dashboards now surface each agent's injected token usage and recent call history, alongside a date-range picker with cost and SLO metrics.
-
Channel multi-instance support — a single channel adapter (e.g. Telegram) can now run as multiple named instances, each with its own configuration and secret namespace, manageable from both the TUI and dashboard.
-
Per-agent reasoning mode and aux LLM chain — DeepSeek V4 and OpenRouter agents can be given a per-task reasoning mode; a new UI lets you configure an auxiliary LLM chain for side tasks separately from the primary model.
-
Schema-driven config editor and vault write API — the Settings screen now provides a schema-driven configuration editor, and a new vault write API lets surfaces store daemon credentials securely without manual file edits.
-
Agents spawned from a template now carry the template name forward as
source_templateon the manifest, and the dashboard agent list shows it next to the schedule so operators can trace provenance (#8018) (@DaBlitzStein)
Added
- Autonomous goal runner with an opt-in "loop engineering" mode (#6505): a goal can now drive itself across iterations without a pre-assigned agent —
POST /api/goals/{id}/startauto-spawns a disposable worker agent when none is set, and, when the goal'sloop_engineeringflag is on, auto-spawns a verifier agent that judges each iteration's output before progress is accepted, auto-spawning a fresh reviewer after two consecutive rejections for a second opinion.
A separately configurableevaluator_modelcan judge goal completion instead of trusting the agent's self-reportedGOAL_DONEmarker alone, andGOAL_LEARNED: <text>markers captured during a run are accumulated, injected back into later iterations' prompts, persisted to shared memory, and turned into an auto-created skill on run completion.
Exposed via a newlibrefang goal "<description>" [--loop-engineering] [--watch]CLI command, a TUI Goals screen (list / create / edit / start / stop / delete, with a guided create form), a dashboard agent selector on the Goals page, and a/goalslash command in the Telegram and Slack sidecars (#6505) (@DaBlitzStein) - Add
agent_spawnephemeral mode for Claude Code-style disposable sub-agents — spawn a temporary worker that runs a single task and returns the result directly, with no workspace, no DB persistence, and no registry entry.
A worker's advertised tools are derived from the parent's own set, so a restricted parent cannot launder a privilege escalation through one.
POST /api/agents/spawn-ephemeralis the HTTP entry point, and the dashboard's Quick Run drives it (#7875, #7903) (@DaBlitzStein) - Add write operations (POST/PUT/DELETE) to the
/api/templatessurface, so an operator can create, update and delete named agent types from the dashboard instead of hand-editing TOML on the daemon host.
The listing is deliberately dual-source: an operator-authored agent type under~/.librefang/agent-types/is a standalone document that this API owns, while a live agent's ownagent.tomlhas always been spawnable-from and is listed alongside it.
Only the first is writable, so every row carries aneditableflag and a write aimed at the second is refused rather than silently creating a shadowing copy — a client can then render "managed via Agents" instead of offering a control that cannot work.
New dashboard page: Agent Types, a card grid with create / edit / delete plus Quick Run for an ephemeral spawn (#7859) (@DaBlitzStein) - Add the
workflow_createtool so agents can create reusable workflows directly during a conversation — previously workflows could only be created via the dashboard canvas, CLI, or HTTP API.
The tool validates names ([A-Za-z0-9_-], 1–64 chars) at both the runtime and kernel layers, runs the sameWorkflow::validate()semantic checks as the HTTP API, reserves the name atomically so two concurrent calls cannot both believe they created it, and ships in the default tool catalogue so an ordinary agent has it without configuration.
Add theworkflow-creatorskill — a prompt-only skill teaching agents when and how to design multi-step workflows, with step structure, execution modes, error handling, and worked examples (#7857, #7873) (@DaBlitzStein) - Workflow steps can declare
required_skills; a step whose resolved agent (or spawned agent type) cannot provide one fails before dispatch with a precise error distinguishing "not declared" from "declared but not installed" (via the pending-declarations plumbing).
All resolver surfaces (kernel sync/async, API background/resume/operator, channel bridge) carry the check, so a step cannot slip past it by being started from a different entry point (#7863) (@DaBlitzStein) - Ephemeral workers spawned from an agent type get a uid display name and a transient mission workspace — created at spawn, deleted when the run ends, success or failure.
The folder is passed as the agent workspace root, so a worker has somewhere to drop intermediates that is guaranteed to be cleaned up rather than accumulating under the operator's home (#7860) (@DaBlitzStein) - The agent-types editor now mirrors the agent editor: catalog-backed pickers for skills and tools (fuzzy search over installed skills / the tool catalog, free-text allowed), provider/model defaults with hints, and workspace-agent source entries are read-only ("managed via Agents").
The TUI templates screen lists operator-created types and spawns them from their real TOML rather than a hardcoded starter set (#7731) (@DaBlitzStein) - Skill cards on the Skills page now link out to the skill's page on the marketplace it came from, so the comments, ratings and full description are one click away — for browse results and for installed skills alike.
The link is only offered where a public page exists: ClawHub and its CN mirror always, a self-hosted SkillHub whenVITE_SKILLHUB_REGISTRY_URLnames its origin, and never for FangHub, which publishes no browsable skill page (#7751) (@DaBlitzStein) - A model's context window and max output tokens are now editable overrides that survive the registry sync which used to overwrite them.
ModelOverridescarried nocontext_windowat all, so an operator pointing LibreFang at an OpenAI-compatible gateway had no way to tell it how large that model's window really is — and no way to correct a catalog entry that reported the wrong one.
Both values now live in the sync-immune overrides side-table, andresolve_context_windowhonours the override even for a model with no catalog entry at all, which is the gateway case.
The API reports the effective value alongside the raw catalog figure so a surface can show which of the two an operator is looking at, andlibrefang models overrides <model>covers the same ground for scripting (#7818, #7990) (@DaBlitzStein) - Drive goals from the terminal, split out of the closed #6505: a
librefang goalcommand and a Goals tab in the TUI, both against the existing/api/goalssurface.
librefang goal "<description>" --agent <name-or-uuid>creates the goal and starts the run,--max-iterationscaps it, and--watchpolls every two seconds and prints the phase, iteration, and progress until the run ends — exiting non-zero when it stops, is rate-limited, or hits the cap, so a shell&&chain does not treat an abandoned goal as success.
The agent is resolved and validated before anything is created, becausePOST /api/goals/{id}/startrefuses a goal with no agent assigned and the previous order left an unrunnable goal behind on every mistyped invocation.
The TUI tab (Alt+G, the first free mnemonic — every numeric slot is already bound) lists goals with a status badge and progress, searches with/, creates with a three-field wizard, and starts, stops, or deletes the selected goal.
Run state — phase, iteration, and cap — is fetched per goal fromGET /api/goals/{id}/runwhen the detail pane opens, since the goal document served by the list endpoint has never carried it; the start/stop toggle keys off that live phase rather than the document'sstatus, which is set toin_progresswhen a run starts and never cleared when it ends (#7784) (@DaBlitzStein) - Add
librefang-types::manifest_privacy, the privacy pass a deployed agent's manifest crosses before it is published to the shared registry as a public agent type.
A live instance'sagent.tomlcarries operator-private data the registry validator does not check for — absolute workspace paths,api_key_env/base_urlcredentials on the model and every fallback, external workspace mounts, host-specificexec_policy, literalcontext_injectiontext, and instance metadata — none of which belongs in a public git history, where it stays.
sanitize_for_publicationreturns a publishable copy with that removed and the portable agent-type half untouched;scan_for_publicationindependently reports what it found, with a bounded preview per finding, including in fields the sanitiser deliberately keeps, likesystem_prompt, where no structural rule separates portable configuration from an internal hostname someone pasted into free text.
Keeping the two halves apart is the point: each finding says whether confirming is enough or the operator has to edit the value by hand, so the operator decides rather than having their file quietly rewritten.
The classification cannot silently rot either — the sanitiser destructuresAgentManifestwith no rest pattern and rebuilds it field by field, so a new manifest field fails to compile until someone decides whether it is publishable (#7819) (@DaBlitzStein) reasoning_mode—none/low/high/max— settable per agent (agent.toml[thinking]), globally (config.toml[thinking]), and per task (thereasoning_modekey on both message endpoints), resolved per-call > per-agent > global > compiled default.
Telling a model "answer fast, don't overthink" in the prompt is not a substitute for a wire-level toggle: the reasoning tokens are still generated, still consumed from the output budget, and still billed, so a prompt-level workaround costs exactly as much as the reasoning it was meant to avoid.
There was previously no way to express the choice at all — and for DeepSeek V4, direct or via OpenRouter, LibreFang sent no reasoning fields whatsoever, so every request ran at the provider default of thinking-on athighno matter what any agent was configured to want.
The mode is now translated per provider: DeepSeek V4 getsthinking: {"type": "disabled"}fornoneandthinking: {"type": "enabled"}plusreasoning_effortotherwise, OpenRouter-routed models get the nestedreasoning: {effort}form withmaxasxhigh, and other OpenAI-compatible endpoints keep the top-levelreasoning_effort.
The two spellings are mutually exclusive per request because OpenRouter answers HTTP 400 to a payload carrying both.
Leavingreasoning_modeunset preserves thebudget_tokensbucket fallback everywhere it applied, so no agent changes how hard it reasons without opting in.
The one shape change for an existing setup is the spelling: an OpenRouter-routed request with a configured budget now carries that bucket as the nestedreasoning: {effort}rather than the top-levelreasoning_effort, because the two cannot coexist and OpenRouter's own form is the one it documents.
(#8083) (@houko)- The agent detail view now shows what an agent actually costs to run: the tokens the daemon injects into every one of its requests, and the last five LLM calls with their input/output split and price.
Until now that number lived only in the metering tables, so the usual way to find out why one agent burns more than another was to guess at its identity files, tool list and skill registry.
The dashboard renders it in the agent drawer; the TUI puts it behind$on the agent detail screen.
The figures are always the open agent's own: the panel clears them when the selection moves, and an answer that arrives after the operator has moved on is discarded rather than painted under the new agent's name (#7976) (@DaBlitzStein) - The Memory tab of the TUI had no way to see or change which model writes memories, so an inherited default that ran after every reply and blew past its own ceiling held every answer for minutes with no surface naming the cause.
The tab now has a configuration panel that shows the resolved extraction model and its provenance alongside the search provider, and letsauto_memorize,auto_retrieveand the extraction model be edited and saved back throughPATCH /api/memory/config.
The unsaved-changes marker survives until the daemon confirms the write, so a refused save reads as unsaved work with the status code and body that refused it, not as "everything is saved"; attached in-process, where there is no HTTP surface to PATCH, the panel says so instead of sitting on "Saving..." forever. (#7985) (@DaBlitzStein) - New
librefang purge --agent <name>CLI command removes every trace of an agent the operator already deleted: any roster entry left over (cascading to its sessions, memories and KV rows), rows a previous partial delete orphaned even when the roster entry is already gone, the agent's canonical UUID record inagent_identities.toml, cron jobs and event triggers keyed to its id, channel and conversation routing bindings keyed to its name, the workspace directory, and any agent-type template of the same name.
Cron, trigger and routing residue matters because all three are recovered from the agent's name — the defaultAgentIdis derived deterministically from it — so leaving them behind meant a new agent later spawned under the purged name silently inherited the old one's schedule, event wiring and/agentrouting.
The orphan scan walks the same shared table list the substrate's agent cascade deletes from, so residue in tables the cascade only learned about later —pending_approvals(v26),goal_runs(v42),usage_events— is found rather than reported as "no trace in this installation", which is the state older installations are actually in.
Live agent ids are read straight from theagentstable rather than through the hydrating roster loader, which drops a row whose manifest fails to deserialize or whose UUID is unparseable — exactly the damaged-row situation that leads an operator to run purge in the first place, and one where that live agent's rows would otherwise look like an orphan to cascade.
Candidates are recovered even when the name currently has a live roster entry, so residue left by an earlier incarnation of the same name (recreated under a different id since) is removed instead of surviving under the report "nothing to purge".
An id that has rows but cannot be attributed to any name — a child or ephemeral agent'sAgentId::new(), never registered and already gone from the roster — is reported as present and deliberately left untouched, because purge cannot prove it belongs to the requested name and the safer error is to under-delete.
The workspace directory is resolved the way spawn resolves it, throughworkspaces_dirand the agent's ownworkspaceoverride, so a relocated workspace is purged instead of quietly surviving a run reported as successful; a hand agent's absolute override (<workspaces>/hands/<hand>/<role>) resolves against the whole workspaces root rather than failing validation against theagents/subtree alone.
A directory is left standing when deleting it would destroy more than this agent: the workspaces root itself (workspace = "."), a directory another registered agent resolves to, one containing another agent's —alphaowningsharedwhile a livebetaownsshared/team— or one another agent declares in its[workspaces]table, which is the second way an agent gets a directory and resolves under the same root.
An orphan has no manifest left to read, and the report says the location was not resolvable rather than claiming there was nothing there.
Agent names are only required to be a single path component, so an agent namedmy.botor研究员can be purged like any other.
The command prompts for confirmation over the plan it just computed rather than a fixed warning (with--yesto skip — required when stdin is not a TTY), asks nothing when there is nothing to purge, and offers--dry-runto print the exactPurgeReportit would produce without changing anything.
It refuses to run its destructive path while a daemon is answering for the installation, since a daemon holding the agent in memory re-persists a resurrected roster entry on its next save;--forceis the override for an operator who has already stopped it by other means.
A configuration that cannot be loaded aborts the command with a nonzero exit status before any database is opened, rather than falling back to the default installation's settings: for--config /srv/instance-b/config.tomlthat fallback would have moved the deletion to the default installation and purged its unrelated agent of the same name, which no warning can make safe.
The target database is resolved through[memory] sqlite_pathanddata_direxactly as the daemon resolves it, so a relocated layout is purged against its live database rather than a stale one freshly created at the default path.
The plan the operator confirms names both the database and the home directory it is about to delete from, because removal categories say what kind of thing goes and only the paths say whose — and on a relocated installation those are two unrelated trees.
A failed write toagent_identities.toml(a read-only home directory, say) is surfaced as a purge failure rather than reported as a successful removal, since the registry's ownpurge()only logs a warning on that failure and still returns success (#7986) (@DaBlitzStein) - Offer the full slash menu in the dashboard chat, which previously drew on a hand-written list that nobody kept in sync with the command registry — the reason
/goalworked in Telegram and nowhere else.
The catalog is now derived fromCOMMAND_REGISTRY, so the menu gains/goal,/agents,/budget,/peers,/a2a,/context,/queue,/verboseand/info, and a command registered with dashboard scope appears there without a second edit.
Registry entries carry how the dashboard runs each command, which is what lets the menu distinguish the ones the SPA answers locally from the ones it dispatches over the chat WebSocket (#7996) (@DaBlitzStein) - Running a workflow from the TUI used to hand the operator one free-form JSON blob and the responsibility for knowing the workflow's parameter names, types and which ones are required.
The run-input screen now fetches the workflow's declaredinput_schemaand renders one editable row per parameter, validates required ones before submitting, and types the payload byparam_type(numbers as JSON numbers, booleans from the canonicaltrue/1/yesset).
A workflow that declares no parameters keeps the bare-string input, and a schema that cannot be consulted — in-process mode or an unreachable daemon — says so in the status line instead of silently degrading to the blob the screen used to show. (#7998) (@DaBlitzStein) - The dashboard agent editor gains a Shared Folders section (#8013), bringing it to parity with the TUI editor from #7835: an operator can name, map and mode a
[workspaces]declaration without editing raw TOML.
The form emits the[workspaces]table header only after every top-level scalar — a header inside the scalar block would scope the remaining bare keys into the table, so the first save with a folder would have silently deleted the agent's tags, skills, MCP servers and schedule.
Onlypath-based declarations become rows; amountentry (absolute host directory) is preserved verbatim on save instead of being rewritten as an emptypathand dropped.
Duplicate folder names and paths that escapeworkspaces_dir(absolute or containing..) are refused at save time with a per-row message, so the operator finds out before the daemon's parser does. (#8013) (@DaBlitzStein) - Surface the four custom media endpoints — STT, TTS, image and video — in the dashboard's Models tab, alongside the LLM catalogue, with a type chip on every row.
An operator pointing LibreFang at a self-hosted Whisper for transcription or a local llava for image and video description previously had to know that those endpoints live in[media.custom_stt]/[media.custom_image]/[media.custom_video]/[tts.custom]and editconfig.toml— a different place from where every other model is managed, and one where an unconfigured modality was invisible because nothing rendered it.
All four rows now render whether configured or not, each showing the endpoint URL, the model id and the config path that owns it, and each editable in place through the config API that already served them.
The provider selector that arms a custom endpoint (media.audio_provider,tts.provider, …) is part of the same form, because an endpoint edited without it stays inert.
Nothing moves inconfig.toml: this is a surface over the existing sections, not a config migration.
The API-key environment-variable name is shown but read-only — the key itself never entersconfig.toml, and repointing a credential variable stays an on-disk edit so it leaves a file trail.
Saves go one non-secret leaf at a time (media.custom_stt.base_url, …) rather than replacing the whole table, so the storedapi_key_envis neither echoed back in a request nor dropped from the file, and the hand-written comments in a[media.custom_stt]section survive an edit from the dashboard.
A row whose modality is switched off, or whoseModelfield is overridden by[media] audio_modeland friends, says so, because a form that reports success on a change the runtime ignores is worse than no form (#8079) (@houko) - Agent types can be promoted to the public registry as a GitHub PR via
POST /api/templates/{name}/promote, the dashboard Promote button, or the TUI[p]key.
The manifest is sanitized for publication before submission, and promotion is refused with a 409 while a privacy finding still sits inside a field the sanitizer keeps, reusing the same fork/push/PR machinery the skill propose flow uses.
(#8043) (@DaBlitzStein) - A Channels screen in the TUI that manages
[[sidecar_channels]]instances directly — list them grouped by adapter type with per-instance health and assigned agent, add one, edit one, delete one.
The backend has accepted several instances of a single adapter type for a while, so two Telegram bots bound to two different agents were always possible; what was missing was any way to say so other than opening~/.librefang/config.toml.
That mattered more than a convenience gap, because a hand-written instance is easy to get subtly wrong: the block'snameand itschannel_typeare different identifiers, secrets belong insecrets.envunder a per-instance prefix rather than inline, and an edit only takes effect after a channel reload.
The screen sends the adapter where the API wants an adapter and the instance name where it wants an instance name, so the two cannot be transposed, and it refuses a name that collides with an existing instance before issuing a request rather than surfacing the daemon's 409.
That collision check is on the per-instance secret prefix rather than on the literal name, because the prefix uppercases the name and folds every non-alphanumeric character to_:slack-hrandslack_hrare two distinct instances that would have shared onesecrets.envnamespace and been handed each other's tokens, which the daemon reported only as a WARN once the credentials were already on disk.
The same distinction fixed four real bugs inlibrefang channel setup, which sent a configured row's instance name into a path that only accepts a catalog adapter (a 404 for any instance not named after its own adapter), dropped the instance'sagentbinding on every save because an absentagentfield means "clear it", promised that a blank required secret would keep the stored one when the daemon rejects that with a 400, and deleted a non-secret setting whose bracketed default the operator accepted with a bare Enter, because an omitted key is a removal rather than a no-op (#8082) (@houko) - Add dashboard support for running multiple named instances of the same channel type (e.g. two Telegram bots, each routed to a different agent).
The backend already stored several[[sidecar_channels]]entries of one type, butPOST /api/channels/sidecar/{name}/configurealways wrote under the catalog type's own name, so a second save silently overwrote the first instance's config and its secret.
The endpoint now accepts aninstance_nameand a per-instanceagentfield, rejects a name already used by a different channel type, and namespaces a second-plus instance's secrets under<INSTANCE>__<KEY>insecrets.env(reusing the existing per-instance secret precedence from #6169) so two bots never share a token.
The Channels page groups configured instances by type, the Add picker always offers every catalog type so a second instance can be started, and the configure form gained instance-name and default-agent fields (#8046) (@DaBlitzStein) - Auxiliary LLM chain editor on the dashboard Config page (and an Auxiliary sub-tab in the TUI Settings screen): operators can view and edit per-task model chains for all nine
AuxTaskside-tasks, with chain entries suggested from the live provider/model catalog, and the task list served by the kernel onGET /api/config/schema(x-aux-tasks) so a newly added side-task appears without a UI change (#8059) (@DaBlitzStein) - Date-range picker and the missing cost/SLO metrics on the Analytics page (#8062), because an operator running eight per-team deployments had given up on the built-in page and was maintaining a hand-written HTML dashboard against the same
/api/usage/*endpoints — the endpoints have acceptedstart_date/end_datesince #7891, but the page only ever asked for a fixed seven-day window, so "what did we spend last month, per model" was not a question the dashboard could answer.
The window is now a first-class filter with quick presets (today / yesterday / 7d / 14d / 30d / this month / last month / all) plus explicit from/to inputs, and it applies to every metric on the page rather than only the trend chart; because the server filters on UTC calendar days, the picker resolves its presets in UTC and labels itself so, instead of silently reporting a window shifted by the viewer's offset.
Newly surfaced: total tool calls, cost per call, first-event date paired with the configuredusage.retention_daysso a pruned history is distinguishable from a young deployment, a per-agent table carrying tool calls and the prompt/completion token split that labels hands rather than hiding their spend, the same input/output split on the model table, a range summary under the trend chart, and a CSV export named after the window it covers.
GET /api/usage/by-model/performancegained ap95_latency_msfield — the one metric the issue asked for that the API genuinely did not have, and the number a latency SLO is actually written against, since the average hides the tail and the maximum is a single outlier.
It is computed over the events that actually carry a measured latency, matching the percentile already reported per agent byGET /api/agents/{id}/stats:usage_events.latency_msarrived asNOT NULL DEFAULT 0, so on a database upgraded across that migration every older row reads zero, and ranking those as though they were fast calls would have reported roughly the median of the real latencies as a 95th percentile (#8081) (@houko) - The Goals page shows each goal's autonomous run phase as a badge on the run's info row, for the active run and for runs that already ended (finished, max-iterations, rate-limited, stopped), so the outcome of a run is visible without starting another one to find out.
The phase is drawn from the page's single phase-to-appearance map, so the same phase cannot come out one colour beside the play button and a different colour on the line below it, and its label comes from the existing translatedgoals.run_phase_*keys.
A phase this build does not know renders under the neutral variant asking for its own key, so it picks up a translation as soon as one is added rather than borrowing another phase's label and reporting a run as something it is not (#8067) (@DaBlitzStein) - CI now refuses a pull request whose commits carry Claude / Anthropic attribution, closing the route by which 31 of the last 500 commits on
mainacquired aCo-authored-by: Claudetrailer despite acommit-msghook that forbids exactly that.
Two gaps let them through:core.hooksPathis opt-in per clone, so a fresh clone or an agent worktree pushes with no git-side enforcement at all; and GitHub composes the squash-merge commit server-side, appendingCo-authored-by:for every non-primary author in the branch — so one Claude-authored commit anywhere stamps the merge commit with attribution no local hook can see.
The check drives the existingscripts/hooks/commit-msgonce per commit rather than restating its rules, because a second copy of that regex is the drift its own comments warn about.
It runs onpull_requeston purpose: once the trailer is onmainthe history is public and the remedy is a force-push to a protected branch, whereas the PR stage is one rebase. (#8147) (@houko) GET /api/vault/keys,PUT /api/vault/keys/{key}andDELETE /api/vault/keys/{key}let the dashboard and the TUI store the credentials the daemon resolves at runtime, closing the gap that madeGITHUB_TOKENreachable only from the CLI even though skill proposal and agent-type promotion already fall back to reading it from the vault.
Writes take effect on the running daemon: they go through the kernel's cached vault handle, the same oneresolve_github_tokenreads from, so a promotion attempted right after storing the token uses the new value instead of waiting for a restart.
Values are never returned — the listing reports key names, whether the vault holds each one, and where the daemon would actually resolve it from, and there is no read-back endpoint at all — and only the keys on an explicit allowlist may be written, so the shared vault namespace the MCP OAuth flow also lives in cannot be overwritten or enumerated through this surface.
That last piece matters on any host that already exports the credential in the daemon's environment: the environment is read first, so a listing that answered "is it in the vault" would report the key as unconfigured while promotion works, and report the same after a delete that revoked nothing.
Every response now carries an effectivesourceofunset,vaultorenvironmentalongside the vault-presence flag, so a surface can distinguish a credential that is genuinely missing from one that was stored but is inert.
On a host with neither an OS keyring norLIBREFANG_VAULT_KEYthe write fails with a503naming the reason rather than silently downgrading to a cleartextsecrets.envline.
All three routes are Owner-only, including the listing.
An Admin who could replaceGITHUB_TOKENwould make skill proposal and agent-type promotion push under a token they chose and could break both by deleting it, which is the same class of action as the/api/config/setand per-user provider-key writes already withheld from Admin; the listing is gated for the reason the equally value-freeGET /api/users/{name}/provider-keysis, since it enumerates the credentials an operator has configured. (#8186) (@DaBlitzStein)- The TUI can now edit configuration sections, under a new Configuration sub-tab of the Settings screen (
6).
It holds no field list of its own: sections, field names, types and the writable/read-only verdict all come fromGET /api/config/schema, and every write goes toPOST /api/config/set— the same pair the dashboard's ConfigPage uses — so a field added toKernelConfigappears in the terminal without a TUI change, and the two surfaces cannot drift on which paths are writable.
This closes a gap that was easiest to see with registry promotion:skills.registry_reposteers the "Propose to Registry" flow and was settable from the dashboard and fromPOST /api/config/set, while an operator working over SSH had no way to set it short of hand-editingconfig.tomland reloading the daemon.
Settings the server marks non-writable render read-only rather than offering an edit that comes back403, lists and tables stay edit-on-disk, and clearing a value removes the key so the field falls back to its compiled default.
Enum settings are editable whichever way the schema spells them, including theoneOf-of-variants form that mostKernelConfigenums use — reading only the flat form would have leftmode,reload.mode,privacy.modeand a couple of dozen more claiming to be tables the terminal cannot edit.
A save reports what actually took effect rather than a blanket "Saved": the endpoint answers200for a clean apply, for a save that needs a daemon restart, and for a save whose hot-reload failed, and only the first means the value on screen is the one now running.
Fields whose current value comes back redacted open an empty prompt and refuse an untouched submit, so looking atvertex_ai.credentials_pathorpairing.ntfy_urlcannot write the redaction marker over the real value. (#8184) (@DaBlitzStein) - Expose the live step progress of a workflow run —
current_step_indexandtotal_stepson the run plus each step's resolvedvariablessnapshot — throughGET /api/workflows/runs/:idandGET /api/workflows/:id/runs, and persisttotal_stepsin SQLite (schema v56) so a run recovered after a daemon restart reports real progress instead of "step X of 0".
Both executors report it, the sequential one per step and the DAG one per layer, so a workflow that declares dependencies — the shape whose progress is worth watching — is not the one that reports nothing.
A finished run never advertises a step it is no longer executing: the index is cleared on every transition out of Running, and the two endpoints read it throughWorkflowRun::live_step_index, which answersNoneunless the run's state actually says it is running.
The variable snapshot is the bindings a step was dispatched with, not the ones its siblings went on to write, and each value is capped at the same 200 characters as the module's other debug traces — a run's persisted size would otherwise grow with the square of its own step count, every step carrying a full copy of every earlier step's output.
Note thatsteps_completedon the run list counts step executions whiletotal_stepscounts declared steps; a loop step contributes one execution per iteration, so the two are documented as not being a fraction. (#8177) (@DaBlitzStein) - Surfaced the new
max_concurrent_uploadscap in the dashboard ConfigPage, next to the two size caps it works against.
The setting shipped as a config-file-only knob, which left an operator who hit a 429 with no way to see the limit that produced it, let alone raise it, without editingconfig.tomlby hand and restarting.
It now renders as ageneralrow and accepts writes throughPOST /api/config/set, matchingmax_request_body_bytesandmax_upload_size_bytes— restart-required all three, whichconfig_reload.rsalready reports (#8185) (@DaBlitzStein) - Add
CustomImageConfigandCustomVideoConfigtoMediaConfig, plusvideo_provider/video_modelfields, completing the custom-endpoint pattern across all four media understanding domains (STT, TTS, image description, video description).
Custom image providers are routed through the existing OpenAI-compatible vision dispatch; custom video providers resolve config but remain placeholder-only until a real provider is integrated. (#8012) (@DaBlitzStein) - Survive the registry's
agents/→agent-types/directory rename from either side, and resolve~/.librefang/agent-types/as the installed agent-type store (@DaBlitzStein) - Template (agent-type) version history: every create, edit, and restore is snapshotted in SQLite so operators can see how a template changed over time and one-click restore a prior configuration from the dashboard.
GET /api/templates/{name}/historylists snapshots;POST /api/templates/{name}/history/{id}/restorerestores the manifest recorded in that version.
Skills already tracked version history via.evolution.jsonand are unchanged. (#8047) (@DaBlitzStein)
Fixed
-
Saving an agent's MCP server allowlist as
["*"]no longer fails withUnknown MCP server '*'.
"*"is the allowlist's own vocabulary for "every connected MCP server", distinct from the empty list, but the save path validated it as if it were a server name and looked it up in config.toml and the installed catalog — which it is never in.
Choosing "all servers" from the dashboard was therefore the one option the route could not accept.
An unknown name alongside the wildcard is still rejected. (#5855) (@DaBlitzStein) -
PUT /api/templates/{name}no longer panics the daemon on a non-object JSON body.
The handler took aJson<serde_json::Value>, which happily deserializes an array, string, number or bool, and then wrotebody["name"] = ...to pin the manifest name to the URL path segment — butValue'sIndexMut<&str>only handlesNullandObject, so every other variant panicked.
Any caller could trip it on an existing agent type with a one-line body of[]or42, no authentication bypass required.
The route now takes a typedAgentTypeSpec, so a body that is not an object is rejected by deserialization before any handler code runs, and the name is pinned by assigning the path segment to the manifest rather than by indexing into untrusted JSON (#7859) (@DaBlitzStein) -
An agent type's
toolsfield now actually reaches the ephemeral worker spawned from it.
The worker's tool list was resolved from the spawn request alone, so an agent type saved withtools = ["shell_exec"]handed Quick Run a worker with no tools unless the caller repeated the list in the request body — silently making the "Tools" field on the Agent Types form a no-op for that flow.
The worker's set is now narrowed in three stages: the parent's ownavailable_toolsis the ceiling, the agent type's declaredtoolsnarrows it when the run comes from a template that declares any, and the request narrows it further when it asks.
Taking the parent's set as the ceiling is what makes the containment provable rather than merely intended — a worker can never call something its parent may not, so a restricted parent cannot launder a privilege escalation through one — and both an empty list and["*"]still read as "unrestricted", the same way they do for a permanent agent's manifest (#7875) (@DaBlitzStein) -
workflow_createnow records the calling agent instead of discarding it.
Thecaller_agent_idparameter was threaded all the way from the tool call throughKernelHandle::create_workflow's trait signature into the kernel implementation, then ignored there — leaving zero audit trail for a tool that ships in the default catalogue, so an ordinary agent has it without configuration, and whose siblingworkflow_run(also in the default catalogue) can immediately execute whateversteps[].agentthe new workflow names.
The kernel now logs it on creation, so a security investigation can trace which agent turn produced which workflow, and separately resolves an owner from the authenticated caller, the agent manifest'sowner, orconfig.toml'sdefault_owner, logging explicitly when all three are absent rather than recording an ownerless workflow in silence.
The caller and the owner are deliberately two different questions: the first is which turn assembled the spec and stays a log-only trace, the second is who the workflow belongs to (#7857) (@DaBlitzStein) -
The
workflow_createagent tool no longer reports success when the definition never reached disk.
WorkflowEngine::persist_definitionlogged every serialisation, directory, write and rename failure and returned nothing, soregister_unique_namehanded back an id regardless and the agent was told the workflow "outlives the conversation" when it would not survive a restart.
The write path now returns a result,register_unique_namepropagates it asRegisterWorkflowError::NotPersisted, and the tool surfaces that as an internal error rather than a name conflict — a collision is the agent's to fix by renaming, a failed write is not.
A failed write also rolls the in-memory reservation back, so the name is free for the retry instead of being held by a workflow that will not survive and cannot be found.
PUT /api/workflows/{id}stops swallowing a serialisation failure without even a log line, now that it shares the one writer instead of carrying a copy of it.
Still open, and now stated in the source rather than implied:POST /api/workflowsand template instantiation remain best-effort, so the CLI, the TUI and the dashboard can still answer success for a definition that did not land. (#6943) (@DaBlitzStein) -
workflow_createnow rejects an oversized workflow instead of accepting it unbounded.
The tool ships in the default tool catalogue — an ordinary agent has it without configuration — and neither its JSON schema nor the workflow engine capped step count or timeout length; a schemamaxItemsis advisory only, so a model driving the call could simply ignore it.
A single call could previously register an arbitrarily longstepsarray, or atotal_timeout_secsup to the one-year panic-safety clamp already enforced elsewhere in the engine, tying up engine-tracked state and an async-task slot indefinitely.
The handler now enforces a 50-step cap, a 3600-second per-step timeout ceiling, and an 86400-second total-timeout ceiling, returningInvalidParameterwhen exceeded; the schema also documents the same limits asmaxItems/maximumhints (#7857) (@DaBlitzStein) -
/think onand/think offon a channel now scope the extended-thinking preference to the conversation the command was issued in, instead of to the agent as a whole.
The adapter stored the preference keyed only byagent_id, so toggling it in one Telegram chat silently changed the agent's behaviour in every other chat, every group, and every other channel that agent talks on — even though the acknowledgement text already promised "for this chat".
The fix keys the preference by the same(agent_id, channel, chat_id)scope/new,/reboot, and/compactalready use — thesession_scopehelper andSessionId::for_sender_scope— so a toggle in one conversation cannot bleed into another.
The preference remains process-local (an in-memory map on the channel adapter) rather than persisted to the memory substrate: it is a lightweight per-conversation runtime toggle, not durable agent configuration, and a daemon restart resetting it to the manifest default is a reasonable trade-off against adding a persistence path for something a single/think onfully repairs.
Adds a regression test pinning the isolation directly: toggling/thinkin one chat_id must not be readable from a different chat_id, a different channel, or the bare channel scope (#7854) (@DaBlitzStein) -
The plugin install and install-deps paths now share one Python interpreter and pip-argument resolver:
python3is tried first,pythonsecond, and--user/--break-system-packagesare omitted inside virtualenv or Conda environments, where pip rejects--userinstalls. (#7306) (@DaBlitzStein)Interpreter availability probing runs on Tokio instead of a synchronous
std::process::Commandcall, so the async install paths never block on a process spawn. (#7306) (@DaBlitzStein) -
Skillhub search, browse, and detail lookups now degrade gracefully instead of failing with a cryptic "expected value at line 1 column 1" parse error.
skillhub.tencent.comredirects every API path to theskillhub.cnsingle-page app, which answers200 OKwith an HTML shell instead of JSON — no verified replacement API endpoint exists, so a URL swap was not the fix.
librefang-skillsnow detects a markup response before handing it toserde_jsonand returns a distinctSkillError::MarketplaceUnavailablewith an actionable message; the API layer maps it to503 Service Unavailableinstead of502/404.
The fix lives at the sharedClawHubClientparsing boundary too, soget_skill(used by both ClawHub and Skillhub) benefits without duplicating the check.
The base URL is now configurable viaLIBREFANG_SKILLHUB_URLso an operator can point at a working mirror without recompiling once one exists.
Local skill install/search on FangHub and ClawHub are unaffected (#7387) (@DaBlitzStein) -
The
workflow_*tools are now injected into every agent's tool set, mirroring theskill_evolve_*gate.
No named profile (research, coding, messaging, automation, minimal) lists any of them, so a profile-scoped agent never saw one in the set handed to the model, and a model that named one anyway got "Unknown tool" back from the dispatcher.
ALWAYS_NATIVE_TOOLSdid not help: it controls which schemas ship eagerly in lazy-load mode, not which tools an agent has.
The whole family goes in rather thanworkflow_createalone, because that tool's schema tells the model to callworkflow_listbefore choosing a name and says the result runs viaworkflow_run/workflow_start.
A narrowcapabilities.toolsdeclaration is overridden as well, on the same reasoning as the evolve gate; per-agenttool_blocklist, a non-emptytool_allowlistand the kernel-widetool_policyall still apply afterwards, so operators keep an opt-out. (#7407) (@DaBlitzStein) -
librefang security audit-reanchorrecovers from a broken audit hash chain (e.g. the "chain break at seq 204" reported on proteo) without discarding forensic history.
The daemon already logged the exact seq, expected hash, and found hash on boot and kept starting rather than aborting — that detection and degrade-not-crash behavior is now covered by regression tests.
What was missing was recovery: the only existing tool,audit-reset, unconditionally wipes the entireaudit_entriestable, destroying every entry including the ones before the break that verified cleanly.
audit-reanchordiagnoses the first break (prev_hashlink mismatch or contenthashmismatch) against the raw database, then — with--confirm— truncates only the broken entry and anything after it and writes aChainReanchoredmarker so the chain resumes from the last entry that verified, preserving every entry before the break.
Root cause of the specific proteo break was not determined from the code: the concurrent-daemon write race the original report suspected is blocked by theseq INTEGER PRIMARY KEYconstraint (a colliding writer'sINSERTfails closed and the entry is dropped rather than corrupting the chain), and the existing single-process write-ordering hardening (tip only advances after a durable commit) rules out the other obvious mechanism.
Confirming the actual trigger needs the proteo host's daemon/journal logs spanning the seq-204 write and any out-of-band operations (binary swaps, backups, restores) touching~/.librefang/librefang.dbaround that time (#7702) (@DaBlitzStein) -
Editing an agent type from the WebUI Agent Types page no longer silently discards data.
PUT /api/templates/{name}rebuilt the entire manifest from the flat JSON shape the editor sends, then overwrote the file on disk withstd::fs::write.
Every field outside the seven keys that shape carries —[compaction],max_history_messages,[[triggers]],[resources],[autonomous],mcp_servers,tool_allowlist,session_mode,workspaces, and everything else inAgentManifest— was silently reset to its default on every single save made through the WebUI editor, with the endpoint still returning 200.
The route now reads the existing manifest off disk first, then applies only the fields the request body actually supplies throughAgentTypeSpec::apply_to, leaving every other field untouched.
A manifest that fails to parse is now refused outright rather than falling back to a blank one, because overwriting a file the daemon could not read is exactly the data loss this handler exists to prevent.
The create path (POST /api/templates, and the agent-facingagent_type_createtool) is unaffected — building a brand-new manifest from a flat JSON body was always the correct behavior there; the bug was reusing that same rebuild-from-scratch logic for updates (#7859) (@DaBlitzStein) -
A marketplace skill whose own
SKILL.mdfrontmatter fails to parse now answers400, not the handler's fallback status. Install returned a500whose body is scrubbed, so the message naming the broken file and its syntax error was replaced with "Internal server error"; detail returned404, which said the skill does not exist when it exists and is unusable. (#7753) (@DaBlitzStein) -
Agent types (the reusable manifests behind
POST /api/templates, theagent_type_createtool,save-as-agent-type, and the registry sync) now read and write~/.librefang/agent-types/— a directory that exists for exactly this purpose — instead of two directories that were never meant to hold them.
The registry sync used to copy each synced agent type straight into~/.librefang/workspaces/agents/, the directory that holds DEPLOYED agent instances with their own sessions and memory; on a real install that manufactured dozens of agents nobody asked to run.
The CRUD API, theagent_type_createtool, and the ephemeral/persistent spawn resolvers used to write and read~/.librefang/templates/instead, a name already claimed by unrelated starter-TOML scaffolding.
For a fresh install this is the whole fix: agent types land inagent-types/, andworkspaces/agents/only ever contains agents you actually deployed.
For an existing install, nothing already on disk is touched: agents the old sync previously materialized underworkspaces/agents/are left exactly where they are — they are your agents now, and deleting or moving them is your call, not this fix's.
Likewise, no file undertemplates/is moved or deleted; if any of them look like an agent-type manifest (they declare a[model]table), boot now logs aWARNnaming them and the canonical directory, so you can decide whether to recreate them underagent-types/yourself (#7758) (@DaBlitzStein) -
GET /api/models?provider=<gateway>returned zero models for a self-hosted OpenAI-compatible gateway (litellm, a vLLM or llama.cpp proxy registered under its own provider id) even when the gateway was reachable and actively serving completions.
Only OpenRouter and EveryAPI got a live catalog refresh; every other provider was served from the checked-in registry snapshot, which by definition cannot ship an operator's own model ids.
The handler now probes the gateway's/modelslisting — the same probe the periodic loop andGET /api/providersalready run — instead of only reading the probe cache it never filled, so a/api/modelscall that arrived before either of those had run no longer reports the gateway as empty.
Where the gateway also answers litellm's optional/v1/model/info, the declared capability and capacity figures are used; where it doesn't, the model still appears with the catalog's "unknown" sentinel rather than being hidden, because a missing figure is not evidence the model is absent (#7816) (@DaBlitzStein) -
The model parameter form no longer fades the switch that activates each row, so a model with no overrides stops looking like a screen that failed to load.
Every row starts inactive until the model carries an override for that field, and the inactive styling sat on the row container — which also holds therole="switch"button, drawn in the divider token and then faded to 40% on top of that.
The only affordance that could undo the fade was the least visible thing on the row, and with all seven rows inactive by default that was the form's opening state.
The dimming now applies to the label, the number input, the slider and the ticks, the switch keeps full opacity in both states, and its off state uses a token that reads as a control rather than as a hairline (#7782) (@DaBlitzStein) -
librefang goal --watchno longer claims the run failed when a single poll ofGET /api/goals/{id}/runreturns a transient 5xx.
A poll the CLI cannot classify — a failed HTTP status, or a body whoserunning=falsecarries no recognized terminal phase — is retried for up to five consecutive failures before the watch gives up with an explicit outcome-unknown message, so only a real terminalGoalRunPhasedecides the exit code. (#7784) (@DaBlitzStein) -
An agent's own inference settings now win over the per-model override instead of silently losing to it.
Two instances of one agent type could not run the same model at different temperatures:ModelConfig.max_tokensand.temperaturewere plain numbers, so every agent carried a concrete 4096 / 0.7 whether or not anyone chose them, and letting the manifest win would have made per-model overrides unreachable for every agent in existence.
Both fields are nowOptionwithNonemeaning "inherit", and the chain runs agent manifest → per-model override → system default; the three preference knobs that existed only per model (top_p,frequency_penalty,presence_penalty) gain a per-agent equivalent, and the two endpoint limits (context_window,max_output_tokens) are editable per agent from the API, CLI, TUI and dashboard.
reasoning_effortdeliberately keeps the opposite rule — it is a fact about the endpoint rather than a preference, so the model level still wins there (#7770).
Asking for more tokens than a model allows is reported at save time — awarningsentry onPATCH /api/agents/{id}/config, red in the editors, aWARNin the daemon log — never clamped (#7781).
(#7787) (@DaBlitzStein) -
PATCH /api/agents/{id}/configsilently ignored theproviderfield whenever the request omitted (or emptied)model.
The handler only ever calledset_agent_modelinside theif let Some(model) = req.modelbranch, so a request like{"provider": "litellm"}returned 200 while changing nothing — the same #2380 failure class (a field the OpenAPI schema advertises, silently discarded), just onproviderinstead ofapi_key_env/base_url.
A provider-only PATCH now falls back to the agent's current model soset_agent_modelstill runs its provider-change cleanup (prefix stripping, canonical-session reset, and clearing of stale per-agentapi_key_env/base_url), leaving the model untouched (#7796) (@DaBlitzStein) -
A fallback chain slot left with
model = "default"sent that literal string to the provider instead of resolving it, producing errors like "Invalid model name passed in model=default".
The fallback resolution loop already resolvedprovider = "default"to the operator's configured default provider, but never applied the same resolution tomodel, so a fallback entry with an explicit provider and no explicit model (the common shape — onlyprovidergets set,modelis left at its zero value) reached the driver unresolved.
The fix resolvesmodelfirst, then infersproviderfrom the resolved model name whenprovideritself is"default"or empty — inferring from the raw unresolved"default"string always failed silently and masked a real per-model provider hint (#7798) (@DaBlitzStein) -
web_fetchno longer loses every code block on a documentation page, no longer renders a blockquote as bold text, and no longer drops a>that a page wrote in prose.
All three came from the same shape: text resembling markup was destroyed by a rule meant for something else.
The conversion rules match an opening tag by prefix and run in sequence over the whole document, so a shorter tag name consumed a longer element before that element's own rule ever saw it —<prewrote<pre>as a paragraph looking for a</p>that is not there, and<bmade<blockquote>bold; matching now requires the character after the name to end it.
A preformatted block is lifted out before conversion and restored after it, since whitespace is the content there and every line was being trimmed, and the inline<code>rule was putting backticks inside the fence.
The fence is emitted for any<pre>, not only<pre><code>, because Python's documentation, the RFC series and compiler diagnostics all mark listings up without a<code>element, and aclass="language-…"hint now reaches the fence.
Measured as<pre>elements in the source against fenced blocks in the output: Python docs 0 of 35 to 35, RFC 7231 0 of 101 to 100, the Rust book 0 of 15 to 15, the Rust Wikipedia article 0 of 29 to 29, SQLite's manual 0 of 3 to 3, and a GitHub README 1 of 12 to 12, with pages carrying no preformatted content unchanged (#7821) (@nevgenov) -
browser_read_pageno longer folds code blocks into the prose around them.
The walk had no branch forpre, so a listing fell through to the text-node branch, which trims every line: the text survived, but nothing separated it from the surrounding prose and its indentation was gone, which is what makes a compiler diagnostic unreadable.
Apreis now taken as text rather than walked, and emitted as a fenced block carrying anylanguage-orlang-class from the element or from an innercode.
The fence is emitted for anypre, not onlypre > code, because Python's documentation marks listings up as highlighted spans with nocodeelement, the RFC series uses a barepre, and so do diagnostics.
Measured against pages loaded in a live browser, countingpreelements in the DOM against fenced blocks in the extraction: the Rust book 0 of 15 to 15, Python's tutorial 0 of 35 to 35, and the Rust Wikipedia article 0 of 29 to 29, with link counts identical before and after on every page (#7822) (@nevgenov) -
Agents behind a litellm gateway fronting an OpenAI-shaped model group that doesn't forward
reasoning_effortno longer go silent for five minutes on every turn.
litellm was returning400 UnsupportedParamsError: openai does not support parameters: ['reasoning_effort']becauseThinkingConfigdefaults to a non-zero budget on every request, and the driver treated any non-retryable error as a provider-health signal for the circuit breaker.
The OpenAI-compatible driver now recognises an unsupported-parameter rejection — litellm'sUnsupportedParamsError, OpenAI'sunsupported_parametercode, and proxyunexpected keyword argumentshapes — stripsreasoning_effort, retries transparently, and records the rejection against that provider and model so the next turn does not pay for the same 400 again.
Crucially the provider circuit breaker (ProviderCooldown) now excludes this specific, deterministic 400 class from its failure count: a genuine bad-request, auth, or model-not-found 400 still opens the circuit as before, but a gateway that merely declines one optional field is no longer mistaken for a gateway that is down (#7852) (@DaBlitzStein) -
A workflow step that names an agent type now reports why starting that type was refused, instead of claiming the type does not exist.
The find-or-spawn resolver collapsed every outcome other than success into a bareNone, and the engine rendersNoneas "no template file and no registered agent with that name" — so a step whose run owner holds fewer capabilities than the type it names failed with a message that sent the operator looking throughagent-types/for a file that was sitting right there.
The refusal itself is correct and unchanged: a step agent whose manifest asks for more than the run's owner holds is still rejected by the capability-inheritance gate, which is what stops a restricted agent from acquiring an unrestricted proxy through a workflow.
The resolver now returns aStepAgentResolveErrorthat tells "nothing matches this reference" apart from "a template was found and the spawn was refused, for this reason", and the run error carries the reason verbatim.
Found while fixing it and fixed here too: the by-type test fixtures wrote toworkspaces/agents/<name>/agent.tomlunder names the boot registry sync also installs into the canonicalagent-types/directory, which is read first — so the fixtures were silently ignored and those tests ran against whatever the upstream registry shipped that day, over the network (#7858) (@DaBlitzStein) -
Saving an agent's MCP server or skill allowlist from the dashboard no longer fails with a 400 "Internal error" when the list includes a name that is declared but not yet installed or connected.
A declared-but-not-installed MCP server (present only in the local MCP catalog synced from the registry, or configured inconfig.tomlbut not yet connected) is now accepted and persisted as a pending declaration, exactly likespawnalready does — saving it never installs, connects, or otherwise mutatesconfig.toml.
Skills get the equivalent treatment for a skill directory present on disk but not yet loaded into the running registry (SkillRegistry::unloaded_on_disk_dirs); this is narrower than the MCP fix because skills have no locally cached catalog of everything that could be installed the way MCP servers do.
A name absent from every known source is still rejected, now asLibreFangError::InvalidInput(400) with a message naming the rejected entry, instead of the misleadingInternalvariant.
Previously the whole allowlist save failed the moment it hit the first unrecognized name, so an agent that had accumulated even one pending declaration (e.g.fetch, shipped as a default MCP server on some agent-type templates but never installed) could not be edited from the dashboard at all (#7898) (@DaBlitzStein) -
The memory retrieval eval harness reported a wrong number with a confidence interval attached, which is worse than reporting nothing: a missing number sends you looking, while a confident one ends the investigation and gets pasted into an issue as evidence.
On a corpus copied from a database below migration v50 thememories_ftsindex does not exist, so the lexical arm returned nothing for every query, scored 0.0000, and the paired bootstrap — resampling differences that were just the leader's own scores — certifiedfts5 | 0.0000 | +0.8709 [+0.7864, +0.9351] significantfor an arm that issued no query, while the fusion arm silently became the cosine arm under a second name because fusing a ranking with an empty list returns that ranking.
The index is now probed before any judging is spent and those arms are refused with the migration version they need, any arm that comes back empty for every counted query is withheld from scoring rather than certified, and the refusal travels with the table so a pasted result carries it.
Two calibration errors went with it:verdict()compared the noise floor against the interval bound furthest from zero, so[+0.01, +0.20]against a 0.040 floor was called a result when its own pessimistic end says the effect is unmeasurable, and the documented recipe for building a queries file produced queries that retrieve themselves — LibreFang stores every user turn verbatim in a[Past exchange]row, worth 0.1514 nDCG on the reporter's corpus, nearly four times the noise floor the harness declares for itself.
Queries files now take an optionalmemory_id<TAB>textcolumn whose row is excluded from that query, the docs lead with a recipe that builds the file that way plus a temporal-split alternative, and the runtime guidance says what per-pair judging costs (~30x pooled, which is what makes--judge-cacheload-bearing rather than convenient) and that grades are uncalibrated across documents.
The lexical arm also stopped killing the run outright on any real corpus:memories_ftsindexes soft-deleted rows — a soft delete is anUPDATE, so the v50 trigger re-indexes the tombstone rather than dropping it — and the harness resolved those ids against a corpus loaded withdeleted = 0, so a matching tombstone raisedKeyErrorpart-way through a judging pass that costs tens of minutes.
Two silent-wrong-number holes left over from that work are closed alongside it.
Thememory_id<TAB>textheuristic reads a whitespace-free first field as an id list, so a real user message whose first word happened to be followed by a tab (TODO<TAB>fix the retry budget) silently lost that word and gained an exclusion id that matched nothing; every exclusion id is now checked against the corpus, and a run that cannot find one stops instead of scoring a query the harness truncated.
And a run in which every query was dropped raisedValueErrorout of the reporting layer as a traceback rather than reporting that it counted nothing, which is the one case the withholding logic already claimed the report handled. (#8077) (@houko) -
The
[routing]tier router no longer scores tool count or system-prompt length, so its decision is a property of the request rather than of the agent's configuration.
Both terms are constant across every turn of a given agent, so they shifted the whole score distribution instead of separating easy turns from hard ones: an agent with ~50 MCP tools collected +1000 from tool count before a single character of the message was read, past any usablecomplex_threshold.
Raising the threshold could not recover it, because trivial messages on that agent were already above it from tool count and system-prompt length alone, so routing degenerated into pinned-complex for exactly the tool-rich agents where cheap-tier routing would have saved the most — and at least one deployment gave up and pinned a model by hand instead.
Scores on existing agents will now be lower; if your tiers no longer separate, lowersimple_thresholdandcomplex_thresholdto match, since they are no longer inflated by a constant. (#8075) (@houko) -
Stop guessing whether a model behind an OpenAI-compatible gateway can see images, and stop dropping the images when the guess says no.
An operator names a LiteLLM or vLLM deployment whatever suits them —team-default,fast, an internal ticket id — and nothing in such a name says anything about vision, but the catalog inferredsupports_visionfrom it anyway and the agent loop acted on that inference: every image was replaced by a placeholder holding the file's path, and the turn then succeeded.
That is worse than an error, and worse than the HTTP 400 a genuinely text-only endpoint would have returned, because a failure gets diagnosed while a plausible answer gets believed — the model describes the picture from its filename and nobody has any reason to look.
A catalog miss had always failed open and kept the images, so the only models this could reach were the ones the catalog thought it knew about.
Capability flags a gateway publishes are now read and believed in both directions (LiteLLM'ssupports_vision/supports_function_calling, OpenRouter'sarchitecture.input_modalities), an inferred flag is recorded as the guess it is and resolves to "unknown", and unknown sends the images exactly as a miss does.
Only a model something actually declared text-only is still redacted, and that redaction now logs aWARNnaming the model, the number of blocks replaced and the override that fixes it.
librefang everyapi connectwas doing the same laundering through a different door — a gateway model missing from the bundled snapshot was written toproviders/everyapi.tomlas declared blind — and now records it as unknown too (#8087) (@houko) -
Report MCP transport failures from the tool-call path, so auto-reconnect can actually engage for a server that wedges after it has connected.
Nothing could move a connected server into an error state:report_errorwas only ever called from the connect and reconnect paths, soshould_reconnect— which requires that state — was unreachable during normal operation, and the 60s health loop woke up every minute and did nothing.
The reported case ran for roughly two days across two different MCP servers and two agents, burning 19 tool calls to timeouts whileGET /api/mcp/healthreported both servers healthy, and only a daemon restart recovered them.
A failed tool call is now classified as transport-level (a timeout, a closed transport, a dead stdio pipe) or application-level (anything the server answered with a well-formed JSON-RPC error) and only the former reaches the health monitor, after three consecutive failures, so a slow tool or a bad argument never tears down a working server.
The reporting covers the per-agent workspace-scoped MCP pool as well as the daemon-global one — an agent with its own workspace dispatches through the former, so wiring only the latter would have left the reported configuration still unable to recover — and the syntheticlist_resources/read_resourcetools, which ride the same transport a wedge breaks (#8084) (@houko) -
Stable mode no longer logs a per-turn
WARNfor workspace skills when there is nothing to load, and no longer describes a frozen registry as a missing skill.
load_workspace_skillschecked the freeze before checking whether the call had any work to do, so an empty<workspace>/skillsdirectory returnedOk(0)in normal mode and an error in Stable mode — for an outcome that is identical either way: zero skills loaded.
Both call sites logged that atWARN, once per turn per agent, which produced 39-100 lines a day on one deployment, all of it describing a no-op that was skipped rather than a failure.
The wording made it worse: the error reusedSkillError::NotFound, soSkill not found: Skill registry is frozenread like a broken skill and invited exactly the wrong investigation — the reporter went looking for a skill that did not exist.
A frozen registry is now its ownSkillError::RegistryFrozen, reported only when a skill really would have been loaded, and logged atdebugbecause a configured steady state is not a fault.
So that a genuinely skipped workspace skill is not simply dropped in silence instead, spawn now warns once per agent naming the directories Stable mode will not pick up — the #6540 reload report covers the globalskills_dironly, never per-agent workspaces. (#8076) (@houko) -
Deleting a sidecar channel now clears every adapter the channel owns, not a guessed one.
The orphan-reconcile branch removed the plain adapter key plus a{name}:{name}qualified key, but the bridge registers qualified adapters as{name}:{account_id}using the adapter's own account id — so{name}:{name}only ever matched the coincidence where the two are equal, and on any multi-account deployment the real entry survived every delete and every reload, which is precisely the non-converging orphan that branch was added to resolve.
Separately, the recursive scan that decides which files a delete may rewrite enforced only two of the three rules the kernel applies to a config include: it rejected absolute paths and..components, both of which are string checks, but never verified that the resolved path is still inside the including file's directory once symlinks are followed.
A relative symlink in the config directory pointing outside it passed both checks, resolved, parsed, and — if it declaredsidecar_channels— joined the list the handler rewrites, so the endpoint could edit a file the kernel itself would refuse to merge.
The doc-comment claimed the opposite in as many words; it and the code now agree (#7971) (@DaBlitzStein) -
DELETE /api/channels/sidecar/{name}used to look for the channel's[[sidecar_channels]]block in the root config.toml only, so a sidecar declared through aninclude = [...]file was deleted from nowhere and the endpoint answered 404 for a channel that was running at that moment.
The delete now walks every config file that declares the name — root and all includes — because a name declared in both at once made the early return leave the survivor behind and the reload re-merge it, reportingremovedwhile the channel came back.
A running daemon that outlived its config edit still holds the channel in memory with nothing left to strip; that state is now reconciled with a config reload plus a forced bridge restart (which stops the orphaned child process) instead of 404, and only a name declared nowhere still answers 404.
The include scan itself was first-level and substring-only, so a channel declared two includes deep, or through the inlinesidecar_channels = [...]form, was invisible to it and still 404ed while live; it now resolves includes recursively and parses each file instead of matching text, staying within the same absolute-path and..-traversal limits the kernel enforces, and a missing include is tolerated on delete (it cannot declare a block) whileconfigure's conflict check keeps failing closed on one.
The cross-file strip was atomic per file but not across the set, so a later file failing to write could leave an earlier file's block already gone with no way to retry; every candidate file is now snapshotted before the first write and restored on any failure.
A stale channel adapter left with no config anywhere never cleared on its own, because the map is normally cleared only by a config-reload diff that this exact state produces empty, so every repeat delete tore down and restarted every other sidecar for nothing; the reconcile path now drops the stale adapter key directly.
A duplicate[[sidecar_channels]]entry for the same name in one file was only ever stripped once, leaving the survivor to be re-merged on reload; every matching entry in a file is now removed.
(#7971) (@DaBlitzStein) -
The agent detail drawer's token-footprint panel disappeared entirely for a tools-disabled agent with no
system_prompt, because the guard treated a genuine zero the same as "no data" and also hid the independently-sourced recent-calls list.
The TUI's equivalent fetch had the same class of bug one level deeper: a response missing theinjected_footprint_tokensfield (older daemon, future rename) was read as a confident zero viaunwrap_or(0)instead of failing loud, and the panel had no in-process-kernel arm at all, unlike every sibling per-agent fetch on the same screen.
The panel could also render off the bottom of a typical terminal with no scroll offset, and the$key that opens it was never added to the hint bar in any locale, making the whole feature undiscoverable outside the source. (#7976) (@DaBlitzStein) -
A failed operation in the TUI now says what the daemon reported, everywhere the TUI talks to the daemon.
Twenty-onespawn_*helpers collapsed every non-success outcome into a single_ =>arm and printed a generic line of their own, discarding the reason the daemon puts in the response body —YAML parse error at line 3for a broken marketplace skill,skill not found on registryfor a slug that no longer exists,Re-issue with confirm=true to proceedfor a delete that needs confirming.
A generic failure line costs the operator a whole debugging round trip: it reads like the daemon fell over, so they go looking for a fault in their own installation while the daemon has already named a fault somewhere else, and the answer was sitting in the response the screen threw away.
That arm also erased the difference between the daemon rejected this and the request never reached the daemon — two problems with two different next steps, reported identically.
One shareddaemon_responsehelper now sits in front of all of them.
It reads the reason out of both error envelopes in service — the nestederror.messageofApiErrorResponse, which is what backups, sessions, memory KV, model overrides, provider keys and hands answer with, and the bareerrorstring of the ad-hoc route tuples — falls back to the status line rather than inventing a cause when the body carries neither, and walks a transport failure'ssource()chain soConnection refusedandoperation timed outstop rendering as the same sentence.
Running a workflow was the last screen answering an unreachable daemon with a hardcoded EnglishError: …that no locale could translate; it now reports through the same workflow-run failure line every other outcome of that call already used (#8086) (@houko) -
A gateway that rejects
reasoning_effortis now remembered for ten minutes instead of forever, so reconfiguring it no longer needs a daemon restart.
The driver's negative cache keeps the field off the wire for a rejected model, but it used to be a permanent per-instance set: once litellm answeredUnsupportedParamsErrorforreasoning_efforton a model, every later turn of every agent using that model silently lost its thinking budget, and only a daemon restart brought it back.
Entries now carry the rejection time and expire after ten minutes (an in-memoryDashMapread one key at a time, no cross-request serialization), after which the driver re-probes the gateway by sending the field again at the cost of one relearned 400.
(#7984) (@DaBlitzStein) -
The TUI's memory config panel (Memory screen,
c) no longer reports a save as clean when it was not.
Accepting a typed extraction model used to overwrite the boot-resolved model actually running before any PATCH was sent, so the panel claimed extraction had already moved to it; the resolved value is now left alone until a save confirms it, with the typed model shown as its own pending indicator.
An emptied model draft used to be accepted and then silently dropped by the save, since the endpoint has no way to unset that key; it now stays in the editor instead.
Pressing save with nothing edited used to PATCH the whole file anyway, which strips every comment out ofconfig.tomlon a full round trip through the endpoint's read-modify-write; it is now a no-op.
A second edit made while an earlier save was still in flight used to be folded into that save's "Saved" outcome and lost; the panel now only clears the unsaved marker when nothing changed since the save it belongs to.
The save request also inspected only the HTTP status, but the endpoint always returns 200 and reports a failed live reload through the response body — that case now shows as a failure instead of a silent "Saved", and the request carries a timeout sized for the reload it triggers rather than a plain GET.
A successful save then made the model it had just written disappear from the panel: the pending indicator was tied to whether this session had typed it, which the save cleared, so the row went back to naming only the old model while the footer said "Saved".
The panel now refetches after a clean save and distinguishes the two ways the setting can be out of step with reality — not written yet, versus written and waiting for a restart, which is the state every extraction-model change is in until the daemon restarts, because a config reload swaps the table onto the running store without rebuilding the extraction driver.
Escalso only discarded the unsaved marker while leaving the edited values on the panel, where they read as configuration the daemon had reported; they were corrected solely because re-entering the panel happens to refetch. It now puts back what the daemon last answered.
(#7985) (@DaBlitzStein) -
The instruction a cron or autonomous turn receives about
channel_sendis now the same sentence #8149 writes into the same block, which it was not before: one branch offered the tool with an explicit channel and recipient while the other forbade it outright, and only one of those can be true.
It is the offer:channel_sendreads its target channel and recipient straight from its own arguments and dispatches to that adapter, so the sentinel channel the turn itself runs under never enters into the decision.
What is genuinely impossible is replying intocronorautonomous, which have no adapter, and that is what the prompt now says.
Whichever of the two merges second, the resolution is textual rather than a choice between two contradictory instructions, and a test on each side asserts the shared sentence by name (#7995) (@DaBlitzStein) -
The system-channel
channel_sendinstruction suppressed the tool for cron and autonomous turns as well as webui, but that was both false ("you are on the LibreFang web interface" is not true for a cron firing or a background tick) and a capability regression: cron and autonomous turns can still deliver media throughchannel_sendwhen they name a real channel and recipient explicitly, since the #6117 cross-chat guard only fires when the turn's own channel matches the target.
The suppression now applies to webui only, and its wording states what actually happens — the browser renders a generated file only when the reply text embeds the/api/uploads/...URL the generating tool returned — rather than claiming media is attached to the response automatically, which nothing in the runtime does.
channel_registry::is_system_channelalso kept a third hand-copied list of the cron/autonomous/webui literals behind a circular-dependency excuse that no longer held (librefang-runtimealready depends onlibrefang-channels); it now delegates tolibrefang_channels::types::is_reserved_system_channel, which is drift-guarded against the kernel constants.
The prompt builder asks a different question from that one — whether anybody is waiting on the reply — so it now has its ownis_non_interactive_turnover named constants instead of testing membership of the reserved list.
webuiis reserved but a live browser session is waiting, so deriving the two from one list meant a future interactive surface added forSessionIdreasons would silently start telling a real user their turn is a background one.
The two comparisons also normalise the same way now, so a"WebUI"no longer falls past the webui arm into the background-turn one. (#7995) (@DaBlitzStein) -
/goalcarriedScope::CLIin the command registry with no matching arm in the TUI chat runner, so typing/goal ship the releaseinlibrefang chatpassed the pre-flight lookup and hitunreachable!(), panicking with the terminal left in raw mode; it now creates and starts the goal when attached to an in-process kernel, and answers with a clear message instead of a nonsense reply when attached to a remote daemon, which has no endpoint for it yet.
/thinkand/exitwere pinned to dashboard execution paths (BackendandClientrespectively) that do not exist — the dashboard's WebSocket command handler has nothinkarm, and the SPA has no client-side/exitbranch — so selecting either from the slash menu rendered an error or spent an LLM turn on the literal text; both now carrydashboard_exec: None, the same state as/status, until real handlers exist.
A comment on/newclaimed it resets the agent session the same way on every surface, which is the opposite of what dashboard/new(mints a new session id) and TUI/new(clears history at the same session id) actually do.
The test guarding non-channel commands against leaking into the channel-visible set re-derived its own filter predicate and could never fail; it now compares against an explicit golden list, the same pattern already used for the channel-visible set itself. (#7996) (@DaBlitzStein) -
Registry sync, hand
base = "<template>"resolution, and the hands registry's agent-base lookup all now resolvelibrefang-registry's agent-templates directory under either name: the canonicalagent-types/(preferred) or the legacyagents/(fallback, with a warning).
Previously each site did a bareregistry_cache.join("agents")+.exists()check, so the day upstream renames that directory the check silently returns false and the whole block is skipped — a fresh install would end up with zero preinstalled agents and no warning or error anywhere, and hands whose agents declarebase = "<template>"(e.g.devteam) would drop out of routing.
The new shared helper,librefang_types::registry_paths::resolve_agent_types_dir, also logs loudly when neither directory exists, so that failure mode is now distinguishable from a registry that genuinely ships no agent templates (#8000) (@DaBlitzStein) -
Release builds produce their cross-compiled artifacts again.
dtolnay/rust-toolchaininstalled each target onstable, butrust-toolchain.tomlpins the workspace to its MSRV and cargo builds with that toolchain instead, so every cross target failed with "can't find crate forstd" while the native ones passed — v2026.8.30 shipped 23 of the 48 assets its predecessor did.
The target is now added to the toolchain that actually builds, in every workflow that declares one — including the manual re-release workflows any repair of a broken release has to run through.
(#8007) (@houko) -
The official Fly demo deploys the release being cut instead of a hardcoded July build.
deploy/fly/fly.tomlpinnedghcr.io/librefang/librefang:v2026.7.31, which had since been removed from the registry, so the deploy failed with "Could not find image" — and while it existed, the demo served that stale build after every release.
(#8008) (@houko) -
A GitHub Release stays a draft until its artifacts are all present and signed.
It used to be published before a single job had run, so any failure left it permanently public, incomplete and unsigned — v2026.8.30 shipped that way with 23 of 48 assets and noSHA256SUMS, while the job that refuses to sign an incomplete platform set failed to no effect, because nothing depended on it.
Publication now waits on the signature and the desktop bundles, and the two formulae that bake a public download URL wait on publication; best-effort mobile jobs and the package-registry mirrors deliberately do not gate it.
(#8009) (@houko) -
The Shared Folders section of the dashboard's agent-create form recognized only the literal mode value "r" as read-only, while the kernel's canonical serialized form for a read-only workspace — the one written by every manifest save and by every published agent template — is "readonly", so opening a template's read-only shared folder in Form mode and saving it silently upgraded it to read-write; the form now matches the kernel's full alias set (r, read, read-only, readonly).
It also flags a half-filled folder row (a name with an empty path, or a path with an empty name) as an error instead of creating the agent while silently dropping the folder from the manifest.
It rejects a folder name containing '/', whitespace, or '@', which the agent could declare but could never address through@name/....
It wires the duplicate-name check against a template's mount-based folders into the only call site that runs it, which previously left that check dead outside its own unit test.
It reddens the title of the (collapsed by default) Shared Folders section on a validation error, instead of leaving the Create button looking dead with no visible explanation.
It stops a preserved folder name that itself contains a dot from losing everything after the second dot on save, which silently overwrote a sibling declaration (#8013) (@DaBlitzStein) -
Move
chacha20off the yanked 0.10.0 so thecargo-deny (advisories)gate passes again.
The job had been failing onmainsince 2026-08-28 witherror[yanked]: detected yanked crate (try 'cargo update -p chacha20'), which meant a supply-chain gate was red on every branch cut after that point — and a gate that is always red is one nobody reads, so a genuine advisory landing behind it would have arrived looking exactly like the noise.
chacha20is transitive (rand 0.10.2and thecipher-family crates pull it in), so this is a lockfile bump to 0.10.2 and nothing else. (#8035) (@houko) -
file_listandfile_searchnow retry a directory read the OS interrupted (EINTR) instead of failing the tool call.
An interrupted syscall means the call was cut short by a signal before doing anything and is expected to be reissued by the caller — Python has done this transparently since PEP 475 — but LibreFang surfaced the first interruption asUpstream error: Interrupted system call, which reads like the mount died.
On a cloud-synced directory (macOS FileProvider, iCloud-backed~/Documents) the read stays in the kernel long enough that a signal is likely to land mid-call, so the failure was effectively deterministic: an agent hit it 6/6 times on a path a concurrentos.listdir()loop read successfully 160/160 times, then gave up through the repeated-tool-failure guard while operators went looking at a sync daemon that was healthy the whole time.
file_searchswallowed the same error by design and so had been silently dropping whole directories from its results rather than failing. (#8071) (@houko) -
The file tools now understand the
@nameshorthand the kernel writes into every agent'sTOOLS.md, instead of treating it as an ordinary path segment.
An agent that followed its own generated instructions and calledfile_listwith@vault/notesgotFailed to list directory '~/.librefang/workspaces/agents/<agent>/@vault/notes': No such file or directory— a path it never asked for, naming a literal@vaultdirectory under its private workspace.
The kernel had been advertising a syntax the runtime did not implement, so the model reasonably concluded the alias was unsupported and burned turns guessing other spellings; worse, an agent working around it could create a real directory called@vaultthat then silently shadowed the intended mount.
Expansion happens at the dispatch boundary, before the sandbox resolver and the read-only pre-checks run, so an alias grants nothing the absolute path would not: an unknown alias is rejected with the list of aliases the agent actually has,..in the remainder is refused, and a write through a read-only alias is denied exactly as a write to its absolute path is. (#8074) (@houko) -
Deliver outbound channel messages to a channel instance whose name differs from its adapter type, which silently swallowed every reply on any fleet using a
<adapter>-<team>naming convention.
The channel bridge keys its adapter registry by the[[sidecar_channels]] name, while every inbound turn is stamped with the adapter type, so an instance namedslack-hron typeslackregistered as["slack-hr", "slack-hr:slack-hr"]and was then addressed asslack+ accountslack-hr— a pair matching neither key.
The visible damage was worst after an approval: the tool ran, the agent composed its follow-up, and the reply was persisted to session history and never sent, so the human who had just tapped Approve saw nothing at all.
channel_dmand every auto-filledchannel_sendwere dropped the same way, as were the interactive approval notification — which fell back to buttonless plain text, costing the approver the Approve / Reject buttons that are the notification's whole point — and every owner notification, whose registry pre-check rejected what the send itself would have delivered.
Resolution now falls back to matching the adapter's channel type, refusing to guess whenever two instances of one type could both answer, so a reply can still never cross into another tenant's chat (#8080) (@houko) -
A test helper that overrode an environment variable erased it on cleanup instead of putting back what it held, which disarmed the
LIBREFANG_REGISTRY_OFFLINE=1that CI sets for the whole macOS test job.
The macOS lane's Mach-port guard runs every kernel unit test as threads in one process, so every kernel booted after that point performed a live registry sync, and the syncedresearcheragent type shadowed the deliberately-broken templates three step-agent tests seed — turning their expected errors into successful spawns and reddeningmain.
Guards now restore the previous value, and the step-agent fixture freezes the registry sync in its own config rather than trusting the ambient environment.
(#8058) (@houko) -
Auxiliary LLM chain edits (
[llm.auxiliary]) now take effect onPOST /api/config/reloadinstead of silently requiring a daemon restart: the section was classified restart-required whileAuxClientis rebuilt unconditionally on config swap, so the reload plan carried neither a hot action nor a no-op change and the new config was discarded — the operator's chain edit did nothing until restart (#8059) (@DaBlitzStein) -
The TUI's Auxiliary settings tab now reports what went wrong instead of showing a result that quietly misdescribes the operator's config.
The list of available auxiliary tasks is served by the daemon asx-aux-taskson the config schema, and a failed request or a schema without that field both fell through to an empty list with nothing on screen to distinguish "the daemon could not be reached" from "you have configured everything there is".
Those two cases are now reported separately, and the tasks the config document does carry are still rendered, so a transport hiccup does not blank the pane.
The messages are also visible now: the pane never drew its own status line, so every message it wrote went nowhere.
Beyond the task list, a config request that came back401or500used to be read as success and rendered every task as "not configured" — telling an operator with a rejected request that their chains were empty — and a save that the daemon refused (403on a non-writable path,423under a managed config,400on a chain that does not deserialise) was discarded silently, so a rejected edit looked exactly like one that did not take.
A failed fetch also left the whole Settings screen spinning, since none of its four panes clear the loading flag on error.
(#8059) (@DaBlitzStein) -
The
ready-for-review/needs-changeslabels are applied again when a review is submitted.
Splitting the applier's permissions per job in #7546 narrowed the mutating job topull-requests: read, and since the labels endpoint is gated on the pull_requests scope when the issue number resolves to a pull request, every run since has failed withResource not accessible by integration— 24 consecutive failures, and no PR has been labelled from a review since 2026-08-15.
The guard script that was supposed to catch this was never wired into any workflow, so it is now run by CI's workflows lane alongside an assertion on the permission itself.
(#8061) (@houko) -
A configured sidecar channel's Configure drawer could open on a form with nothing in it and still offer a live Save, and even with fields in it, saving an edit was rejected unless every workspace token was re-pasted from scratch.
A drawer that opens but cannot be edited is worse than no drawer at all: the gear icon is a promise that this is the place to change the setting, so an operator who finds an empty panel with an enabled Save concludes their configuration is corrupt, or clicks Save and gets an error naming a token they never touched — and either way they still have to go and hand-editconfig.toml, having first spent time proving the dashboard would not do it.
A configured row now publishes the same schema provenance a discovery row always has, so the drawer explains an empty form and disables the Save that could only have failed; and a required secret left blank is accepted whenever the instance already has one stored, which is what the drawer's own "leave blank to keep" placeholder had been promising.
(#8078) (@houko) -
The Add-channel picker on the Channels page lists every catalog type again once a first instance is configured.
The picker filtered on unconfigured rows, so a type with one configured instance had nowhere left to click to add a second one.
One row per type is shown, taken from the catalog's discovery row, with a per-type configured-instance count riding on each row; a[[sidecar_channels]]entry on a channel type that is not in the bundled catalog stays out of the picker, since it has no schema to render and no adapter for the configure endpoint to save against.
The create-vs-edit mode of the configure drawer is now passed explicitly by its two entry points (picker → create, card gear → edit) instead of being inferred from the row, so picking an already-configured type opens the form in create mode and forces a distinct instance name.
Add is disabled until the catalog has actually loaded, because the button sits above the page's loading and error states and an unreachable daemon would otherwise open a picker whose empty state blamed configuration for a fetch that never happened. (#8091) (@DaBlitzStein) -
History folding no longer sends an agent's model to the wrong provider when no auxiliary chain is configured.
fold_stale_tool_resultstookAuxClient::driver_for(AuxTask::Fold)unconditionally, and with no[llm.auxiliary]entry that returnsAuxClient's primary — the kernel's process-widedefault_driver, not the triggering agent's chain.
The batched summariser is called with the agent's model, so for any agent overridingprovider/modelthe request went to the default provider carrying a model only the agent's provider can serve: reported as a LiteLLM403 team_model_access_deniednamingdeepseek-v4-proon an agent configured to reach DeepSeek directly, after which every fold silently degraded to the fallback stub and the operator saw nothing but a WARN.
The fold path now branches on theused_primaryflagAuxClient::resolvealready returns and falls back to the caller's own driver, which is whatContextCompressorhas always done forAuxTask::Compression(#8093) (@houko) -
Session and cron compaction no longer send an agent's model to the kernel's default provider.
Both call sites tookAuxClient::driver_for(AuxTask::Compression)unconditionally and paired it with the agent's own model, and with no[llm.auxiliary]entry that driver isAuxClient's primary — the process-widedefault_driver, not the triggering agent's chain — so any agent overridingprovider/modelhad its model sent somewhere that cannot serve it, degrading silently to the fallback summary behind one WARN.
The rule now lives in a singleside_task_driverhelper: an explicitly configured aux chain still wins, and otherwise the task runs on the agent's own driver, which is the behaviour the comment at the compaction call site already claimed.
Changelog truncated — the release body is capped at 125,000 characters.
Read the full section in CHANGELOG.md.
Install / Upgrade
Homebrew (macOS):
brew install librefang # CLI (stable) — official homebrew-core
brew tap librefang/tap # pre-release CLI + desktop app
brew install librefang-beta # CLI (beta channel)
brew install librefang-rc # CLI (rc channel)
brew install --cask librefang # Desktop (stable)
brew install --cask librefang-beta # Desktop (beta channel)
brew install --cask librefang-rc # Desktop (rc channel)CLI (Linux/macOS): curl -fsSL https://librefang.ai/install.sh | sh
npm: npm install -g @librefang/cli · pip: pip install librefang · cargo: cargo install librefang
Docker: docker pull ghcr.io/librefang/librefang:latest
Coming from OpenClaw / OpenFang? librefang migrate --from openclaw (or --from openfang)
Documentation · Discord · Contributing Guide
Full diff: v2026.8.30...v2026.9.14