Skip to content

v2026.9.14

Choose a tag to compare

@houko houko released this 13 Sep 19:06
· 228 commits to main since this release
57ad923

Full Changelog

203 PRs from 5 contributors since v2026.8.30.

Highlights

  • Goals system — a new goal command 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_template on 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}/start auto-spawns a disposable worker agent when none is set, and, when the goal's loop_engineering flag 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 configurable evaluator_model can judge goal completion instead of trusting the agent's self-reported GOAL_DONE marker alone, and GOAL_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 new librefang 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 /goal slash command in the Telegram and Slack sidecars (#6505) (@DaBlitzStein)
  • Add agent_spawn ephemeral 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-ephemeral is the HTTP entry point, and the dashboard's Quick Run drives it (#7875, #7903) (@DaBlitzStein)
  • Add write operations (POST/PUT/DELETE) to the /api/templates surface, 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 own agent.toml has always been spawnable-from and is listed alongside it.
    Only the first is writable, so every row carries an editable flag 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_create tool 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 same Workflow::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 the workflow-creator skill — 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 when VITE_SKILLHUB_REGISTRY_URL names 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.
    ModelOverrides carried no context_window at 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, and resolve_context_window honours 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, and librefang 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 goal command and a Goals tab in the TUI, both against the existing /api/goals surface.
    librefang goal "<description>" --agent <name-or-uuid> creates the goal and starts the run, --max-iterations caps it, and --watch polls 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, because POST /api/goals/{id}/start refuses 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 from GET /api/goals/{id}/run when 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's status, which is set to in_progress when 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's agent.toml carries operator-private data the registry validator does not check for — absolute workspace paths, api_key_env / base_url credentials on the model and every fallback, external workspace mounts, host-specific exec_policy, literal context_injection text, and instance metadata — none of which belongs in a public git history, where it stays.
    sanitize_for_publication returns a publishable copy with that removed and the portable agent-type half untouched; scan_for_publication independently reports what it found, with a bounded preview per finding, including in fields the sanitiser deliberately keeps, like system_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 destructures AgentManifest with 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 (the reasoning_mode key 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 at high no matter what any agent was configured to want.
    The mode is now translated per provider: DeepSeek V4 gets thinking: {"type": "disabled"} for none and thinking: {"type": "enabled"} plus reasoning_effort otherwise, OpenRouter-routed models get the nested reasoning: {effort} form with max as xhigh, and other OpenAI-compatible endpoints keep the top-level reasoning_effort.
    The two spellings are mutually exclusive per request because OpenRouter answers HTTP 400 to a payload carrying both.
    Leaving reasoning_mode unset preserves the budget_tokens bucket 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 nested reasoning: {effort} rather than the top-level reasoning_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 lets auto_memorize, auto_retrieve and the extraction model be edited and saved back through PATCH /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 in agent_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 default AgentId is 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 /agent routing.
    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 the agents table 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's AgentId::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, through workspaces_dir and the agent's own workspace override, 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 the agents/ 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 — alpha owning shared while a live beta owns shared/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 named my.bot or 研究员 can be purged like any other.
    The command prompts for confirmation over the plan it just computed rather than a fixed warning (with --yes to skip — required when stdin is not a TTY), asks nothing when there is nothing to purge, and offers --dry-run to print the exact PurgeReport it 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; --force is 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.toml that 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_path and data_dir exactly 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 to agent_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 own purge() 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 /goal worked in Telegram and nowhere else.
    The catalog is now derived from COMMAND_REGISTRY, so the menu gains /goal, /agents, /budget, /peers, /a2a, /context, /queue, /verbose and /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 declared input_schema and renders one editable row per parameter, validates required ones before submitting, and types the payload by param_type (numbers as JSON numbers, booleans from the canonical true/1/yes set).
    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.
    Only path-based declarations become rows; a mount entry (absolute host directory) is preserved verbatim on save instead of being rewritten as an empty path and dropped.
    Duplicate folder names and paths that escape workspaces_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 edit config.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 in config.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 enters config.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 stored api_key_env is 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 whose Model field is overridden by [media] audio_model and 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's name and its channel_type are different identifiers, secrets belong in secrets.env under 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-hr and slack_hr are two distinct instances that would have shared one secrets.env namespace 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 in librefang 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's agent binding on every save because an absent agent field 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, but POST /api/channels/sidecar/{name}/configure always 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 an instance_name and a per-instance agent field, rejects a name already used by a different channel type, and namespaces a second-plus instance's secrets under <INSTANCE>__<KEY> in secrets.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 AuxTask side-tasks, with chain entries suggested from the live provider/model catalog, and the task list served by the kernel on GET /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 accepted start_date / end_date since #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 configured usage.retention_days so 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/performance gained a p95_latency_ms field — 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 by GET /api/agents/{id}/stats: usage_events.latency_ms arrived as NOT 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 translated goals.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 main acquired a Co-authored-by: Claude trailer despite a commit-msg hook that forbids exactly that.
    Two gaps let them through: core.hooksPath is 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, appending Co-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 existing scripts/hooks/commit-msg once per commit rather than restating its rules, because a second copy of that regex is the drift its own comments warn about.
    It runs on pull_request on purpose: once the trailer is on main the 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} and DELETE /api/vault/keys/{key} let the dashboard and the TUI store the credentials the daemon resolves at runtime, closing the gap that made GITHUB_TOKEN reachable 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 one resolve_github_token reads 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 effective source of unset, vault or environment alongside 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 nor LIBREFANG_VAULT_KEY the write fails with a 503 naming the reason rather than silently downgrading to a cleartext secrets.env line.
    All three routes are Owner-only, including the listing.
    An Admin who could replace GITHUB_TOKEN would 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/set and per-user provider-key writes already withheld from Admin; the listing is gated for the reason the equally value-free GET /api/users/{name}/provider-keys is, 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 from GET /api/config/schema, and every write goes to POST /api/config/set — the same pair the dashboard's ConfigPage uses — so a field added to KernelConfig appears 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_repo steers the "Propose to Registry" flow and was settable from the dashboard and from POST /api/config/set, while an operator working over SSH had no way to set it short of hand-editing config.toml and reloading the daemon.
    Settings the server marks non-writable render read-only rather than offering an edit that comes back 403, 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 the oneOf-of-variants form that most KernelConfig enums use — reading only the flat form would have left mode, reload.mode, privacy.mode and 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 answers 200 for 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 at vertex_ai.credentials_path or pairing.ntfy_url cannot write the redaction marker over the real value. (#8184) (@DaBlitzStein)
  • Expose the live step progress of a workflow run — current_step_index and total_steps on the run plus each step's resolved variables snapshot — through GET /api/workflows/runs/:id and GET /api/workflows/:id/runs, and persist total_steps in 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 through WorkflowRun::live_step_index, which answers None unless 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 that steps_completed on the run list counts step executions while total_steps counts 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_uploads cap 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 editing config.toml by hand and restarting.
    It now renders as a general row and accepts writes through POST /api/config/set, matching max_request_body_bytes and max_upload_size_bytes — restart-required all three, which config_reload.rs already reports (#8185) (@DaBlitzStein)
  • Add CustomImageConfig and CustomVideoConfig to MediaConfig, plus video_provider / video_model fields, 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}/history lists snapshots; POST /api/templates/{name}/history/{id}/restore restores the manifest recorded in that version.
    Skills already tracked version history via .evolution.json and are unchanged. (#8047) (@DaBlitzStein)

Fixed

  • Saving an agent's MCP server allowlist as ["*"] no longer fails with Unknown 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 a Json<serde_json::Value>, which happily deserializes an array, string, number or bool, and then wrote body["name"] = ... to pin the manifest name to the URL path segment — but Value's IndexMut<&str> only handles Null and Object, so every other variant panicked.
    Any caller could trip it on an existing agent type with a one-line body of [] or 42, no authentication bypass required.
    The route now takes a typed AgentTypeSpec, 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 tools field 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 with tools = ["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 own available_tools is the ceiling, the agent type's declared tools narrows 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_create now records the calling agent instead of discarding it.
    The caller_agent_id parameter was threaded all the way from the tool call through KernelHandle::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 sibling workflow_run (also in the default catalogue) can immediately execute whatever steps[].agent the 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's owner, or config.toml's default_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_create agent tool no longer reports success when the definition never reached disk.
    WorkflowEngine::persist_definition logged every serialisation, directory, write and rename failure and returned nothing, so register_unique_name handed 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_name propagates it as RegisterWorkflowError::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/workflows and 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_create now 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 schema maxItems is advisory only, so a model driving the call could simply ignore it.
    A single call could previously register an arbitrarily long steps array, or a total_timeout_secs up 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, returning InvalidParameter when exceeded; the schema also documents the same limits as maxItems / maximum hints (#7857) (@DaBlitzStein)

  • /think on and /think off on 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 by agent_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 /compact already use — the session_scope helper and SessionId::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 on fully repairs.
    Adds a regression test pinning the isolation directly: toggling /think in 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: python3 is tried first, python second, and --user / --break-system-packages are omitted inside virtualenv or Conda environments, where pip rejects --user installs. (#7306) (@DaBlitzStein)

    Interpreter availability probing runs on Tokio instead of a synchronous std::process::Command call, 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.com redirects every API path to the skillhub.cn single-page app, which answers 200 OK with an HTML shell instead of JSON — no verified replacement API endpoint exists, so a URL swap was not the fix.
    librefang-skills now detects a markup response before handing it to serde_json and returns a distinct SkillError::MarketplaceUnavailable with an actionable message; the API layer maps it to 503 Service Unavailable instead of 502/404.
    The fix lives at the shared ClawHubClient parsing boundary too, so get_skill (used by both ClawHub and Skillhub) benefits without duplicating the check.
    The base URL is now configurable via LIBREFANG_SKILLHUB_URL so 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 the skill_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_TOOLS did not help: it controls which schemas ship eagerly in lazy-load mode, not which tools an agent has.
    The whole family goes in rather than workflow_create alone, because that tool's schema tells the model to call workflow_list before choosing a name and says the result runs via workflow_run / workflow_start.
    A narrow capabilities.tools declaration is overridden as well, on the same reasoning as the evolve gate; per-agent tool_blocklist, a non-empty tool_allowlist and the kernel-wide tool_policy all still apply afterwards, so operators keep an opt-out. (#7407) (@DaBlitzStein)

  • librefang security audit-reanchor recovers 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 entire audit_entries table, destroying every entry including the ones before the break that verified cleanly.
    audit-reanchor diagnoses the first break (prev_hash link mismatch or content hash mismatch) against the raw database, then — with --confirm — truncates only the broken entry and anything after it and writes a ChainReanchored marker 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 the seq INTEGER PRIMARY KEY constraint (a colliding writer's INSERT fails 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.db around 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 with std::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 in AgentManifest — 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 through AgentTypeSpec::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-facing agent_type_create tool) 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.md frontmatter fails to parse now answers 400, not the handler's fallback status. Install returned a 500 whose body is scrubbed, so the message naming the broken file and its syntax error was replaced with "Internal server error"; detail returned 404, which said the skill does not exist when it exists and is unusable. (#7753) (@DaBlitzStein)

  • Agent types (the reusable manifests behind POST /api/templates, the agent_type_create tool, 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, the agent_type_create tool, 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 in agent-types/, and workspaces/agents/ only ever contains agents you actually deployed.
    For an existing install, nothing already on disk is touched: agents the old sync previously materialized under workspaces/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 under templates/ is moved or deleted; if any of them look like an agent-type manifest (they declare a [model] table), boot now logs a WARN naming them and the canonical directory, so you can decide whether to recreate them under agent-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 /models listing — the same probe the periodic loop and GET /api/providers already run — instead of only reading the probe cache it never filled, so a /api/models call 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 the role="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 --watch no longer claims the run failed when a single poll of GET /api/goals/{id}/run returns a transient 5xx.
    A poll the CLI cannot classify — a failed HTTP status, or a body whose running=false carries 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 terminal GoalRunPhase decides 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_tokens and .temperature were 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 now Option with None meaning "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_effort deliberately 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 — a warnings entry on PATCH /api/agents/{id}/config, red in the editors, a WARN in the daemon log — never clamped (#7781).
    (#7787) (@DaBlitzStein)

  • PATCH /api/agents/{id}/config silently ignored the provider field whenever the request omitted (or emptied) model.
    The handler only ever called set_agent_model inside the if let Some(model) = req.model branch, 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 on provider instead of api_key_env / base_url.
    A provider-only PATCH now falls back to the agent's current model so set_agent_model still runs its provider-change cleanup (prefix stripping, canonical-session reset, and clearing of stale per-agent api_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 resolved provider = "default" to the operator's configured default provider, but never applied the same resolution to model, so a fallback entry with an explicit provider and no explicit model (the common shape — only provider gets set, model is left at its zero value) reached the driver unresolved.
    The fix resolves model first, then infers provider from the resolved model name when provider itself 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_fetch no 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 — <p rewrote <pre> as a paragraph looking for a </p> that is not there, and <b made <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 a class="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_page no longer folds code blocks into the prose around them.
    The walk had no branch for pre, 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.
    A pre is now taken as text rather than walked, and emitted as a fenced block carrying any language- or lang- class from the element or from an inner code.
    The fence is emitted for any pre, not only pre > code, because Python's documentation marks listings up as highlighted spans with no code element, the RFC series uses a bare pre, and so do diagnostics.
    Measured against pages loaded in a live browser, counting pre elements 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_effort no longer go silent for five minutes on every turn.
    litellm was returning 400 UnsupportedParamsError: openai does not support parameters: ['reasoning_effort'] because ThinkingConfig defaults 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's UnsupportedParamsError, OpenAI's unsupported_parameter code, and proxy unexpected keyword argument shapes — strips reasoning_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 bare None, and the engine renders None as "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 through agent-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 a StepAgentResolveError that 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 to workspaces/agents/<name>/agent.toml under names the boot registry sync also installs into the canonical agent-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 in config.toml but not yet connected) is now accepted and persisted as a pending declaration, exactly like spawn already does — saving it never installs, connects, or otherwise mutates config.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 as LibreFangError::InvalidInput (400) with a message naming the rejected entry, instead of the misleading Internal variant.
    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 the memories_fts index 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 — certified fts5 | 0.0000 | +0.8709 [+0.7864, +0.9351] significant for 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 optional memory_id<TAB>text column 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-cache load-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_fts indexes soft-deleted rows — a soft delete is an UPDATE, so the v50 trigger re-indexes the tombstone rather than dropping it — and the harness resolved those ids against a corpus loaded with deleted = 0, so a matching tombstone raised KeyError part-way through a judging pass that costs tens of minutes.
    Two silent-wrong-number holes left over from that work are closed alongside it.
    The memory_id<TAB>text heuristic 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 raised ValueError out 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 usable complex_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, lower simple_threshold and complex_threshold to 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 inferred supports_vision from 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's supports_vision / supports_function_calling, OpenRouter's architecture.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 a WARN naming the model, the number of blocks replaced and the override that fixes it.
    librefang everyapi connect was doing the same laundering through a different door — a gateway model missing from the bundled snapshot was written to providers/everyapi.toml as 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_error was only ever called from the connect and reconnect paths, so should_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 while GET /api/mcp/health reported 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 synthetic list_resources / read_resource tools, which ride the same transport a wedge breaks (#8084) (@houko)

  • Stable mode no longer logs a per-turn WARN for workspace skills when there is nothing to load, and no longer describes a frozen registry as a missing skill.
    load_workspace_skills checked the freeze before checking whether the call had any work to do, so an empty <workspace>/skills directory returned Ok(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 at WARN, 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 reused SkillError::NotFound, so Skill not found: Skill registry is frozen read 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 own SkillError::RegistryFrozen, reported only when a skill really would have been loaded, and logged at debug because 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 global skills_dir only, 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 declared sidecar_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 an include = [...] 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, reporting removed while 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 inline sidecar_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) while configure'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 the injected_footprint_tokens field (older daemon, future rename) was read as a confident zero via unwrap_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-one spawn_* 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 3 for a broken marketplace skill, skill not found on registry for a slug that no longer exists, Re-issue with confirm=true to proceed for 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 shared daemon_response helper now sits in front of all of them.
    It reads the reason out of both error envelopes in service — the nested error.message of ApiErrorResponse, which is what backups, sessions, memory KV, model overrides, provider keys and hands answer with, and the bare error string 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's source() chain so Connection refused and operation timed out stop rendering as the same sentence.
    Running a workflow was the last screen answering an unreachable daemon with a hardcoded English Error: … 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_effort is 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 answered UnsupportedParamsError for reasoning_effort on 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-memory DashMap read 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 of config.toml on 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.
    Esc also 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_send is 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_send reads 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 into cron or autonomous, 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_send instruction 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 through channel_send when 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_channel also kept a third hand-copied list of the cron/autonomous/webui literals behind a circular-dependency excuse that no longer held (librefang-runtime already depends on librefang-channels); it now delegates to librefang_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 own is_non_interactive_turn over named constants instead of testing membership of the reserved list.
    webui is reserved but a live browser session is waiting, so deriving the two from one list meant a future interactive surface added for SessionId reasons 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)

  • /goal carried Scope::CLI in the command registry with no matching arm in the TUI chat runner, so typing /goal ship the release in librefang chat passed the pre-flight lookup and hit unreachable!(), 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.
    /think and /exit were pinned to dashboard execution paths (Backend and Client respectively) that do not exist — the dashboard's WebSocket command handler has no think arm, and the SPA has no client-side /exit branch — so selecting either from the slash menu rendered an error or spent an LLM turn on the literal text; both now carry dashboard_exec: None, the same state as /status, until real handlers exist.
    A comment on /new claimed 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 resolve librefang-registry's agent-templates directory under either name: the canonical agent-types/ (preferred) or the legacy agents/ (fallback, with a warning).
    Previously each site did a bare registry_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 declare base = "<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-toolchain installed each target on stable, but rust-toolchain.toml pins the workspace to its MSRV and cargo builds with that toolchain instead, so every cross target failed with "can't find crate for std" 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.toml pinned ghcr.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 no SHA256SUMS, 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 chacha20 off the yanked 0.10.0 so the cargo-deny (advisories) gate passes again.
    The job had been failing on main since 2026-08-28 with error[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.
    chacha20 is transitive (rand 0.10.2 and the cipher-family crates pull it in), so this is a lockfile bump to 0.10.2 and nothing else. (#8035) (@houko)

  • file_list and file_search now 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 as Upstream 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 concurrent os.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_search swallowed 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 @name shorthand the kernel writes into every agent's TOOLS.md, instead of treating it as an ordinary path segment.
    An agent that followed its own generated instructions and called file_list with @vault/notes got Failed to list directory '~/.librefang/workspaces/agents/<agent>/@vault/notes': No such file or directory — a path it never asked for, naming a literal @vault directory 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 @vault that 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 named slack-hr on type slack registered as ["slack-hr", "slack-hr:slack-hr"] and was then addressed as slack + account slack-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_dm and every auto-filled channel_send were 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=1 that 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 synced researcher agent type shadowed the deliberately-broken templates three step-agent tests seed — turning their expected errors into successful spawns and reddening main.
    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 on POST /api/config/reload instead of silently requiring a daemon restart: the section was classified restart-required while AuxClient is 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 as x-aux-tasks on 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 back 401 or 500 used 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 (403 on a non-writable path, 423 under a managed config, 400 on 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-changes labels are applied again when a review is submitted.
    Splitting the applier's permissions per job in #7546 narrowed the mutating job to pull-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 with Resource 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-edit config.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_results took AuxClient::driver_for(AuxTask::Fold) unconditionally, and with no [llm.auxiliary] entry that returns AuxClient's primary — the kernel's process-wide default_driver, not the triggering agent's chain.
    The batched summariser is called with the agent's model, so for any agent overriding provider / model the request went to the default provider carrying a model only the agent's provider can serve: reported as a LiteLLM 403 team_model_access_denied naming deepseek-v4-pro on 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 the used_primary flag AuxClient::resolve already returns and falls back to the caller's own driver, which is what ContextCompressor has always done for AuxTask::Compression (#8093) (@houko)

  • Session and cron compaction no longer send an agent's model to the kernel's default provider.
    Both call sites took AuxClient::driver_for(AuxTask::Compression) unconditionally and paired it with the agent's own model, and with no [llm.auxiliary] entry that driver is AuxClient's primary — the process-wide default_driver, not the triggering agent's chain — so any agent overriding provider / model had its model sent somewhere that cannot serve it, degrading silently to the fallback summary behind one WARN.
    The rule now lives in a single side_task_driver helper: 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