Repository navigation
hal0 v1.3.0
Highlights
- User-installed MCP servers can now actually reach an agent. An installed
streamable-http/sseserver gains secrets, aToolPolicy, and an
exposureflag; flippingexposure.hermesjoins it into Hermes's
config.yamland mirrors its tool policy into the agent seed, driven by
hal0 mcp test|allow|gate|block|exposeand matching REST.stdioservers
install and configure, but wait on a process supervisor
(409 mcp.exposure_needs_supervisor). (#305) - Hermes skills that need OAuth are connected with a consent link. The
provider's redirect lands onGET /api/oauth/{provider}/callbackand hal0
exchanges the code itself (PKCE where supported), so the operator never
copy-pastes an authorization code and the agent never handles a raw code or
client secret. The newhal0 oauthCLI manages
the accounts; tokens go through the secrets store, never into TOML. - Settings are schema-driven, and every save previews first.
GET /api/settings/fieldsreturns one documented row per operator-editable
config key — including the previously unreachable[brain_chat].tool_model
and a new Realtime page for[realtime]— andPOST /api/settings/preview
computes the exact diffPUT /api/settingswould apply from the same
function, so a preview cannot promise something the apply does not keep.
(#2108, #1967, #2195, #2203, #1511) - Hardware truth stays fresh, and the ROCm lane no longer depends on
rocm-smi. A missing or stale/etc/hal0/hardware.jsonnow triggers a
live re-probe instead of booking a real GPU box's resident slot memory as
ram_mb;/dev/kfdis enough on its own to put a box on the ROCm lane, so a fresh LXC with the
device forwarded no longer seeds every llama.cpp slot onto the slower Vulkan
lane; and freshly seeded slots clampcontext_sizeto a host memory
envelope instead of shipping the seed files' fixed values (up to65536).
(#1862, #2216, #1966, #1868) - A slot load, unload or restart can no longer hang forever. Every
systemctl/podman seam call is now bounded, and a timeout surfaces as a
typedslot.seam_timeout(HTTP 504) naming the command, its budget and
whatever the child printed before being killed.hal0 slot load|unload|restart|swapshows an elapsed counter and the slot's live state
while it waits, instead of blocking silently for up to ~40 minutes.
(#1869, #1870) hal0 updatere-asserts the/usr/local/bin/hal0and
/usr/local/bin/hal0-agentPATH symlinks on every activation, so a box
upgraded exclusively through the updater can no longer end up with ahal0
on PATH pointing at a stale venv shim./api/statusalso gained a
build_shafield naming the git tree the running process imports from
(nullon a normal release install, which has no git checkout),
whichscripts/deploy.shnow polls before reporting success.
(#1550, #1844, #2019)
Breaking
- ADMIN-class routes now need an admin credential from off-box callers
whenever the box is bound past loopback — even withrequire_authoff.
Until this releaserequire_auth = falsemeant the enforcement middleware
waved every request through untouched. It now classifies the request first
and enforces ADMIN-class routes anyway whenever all three hold: an admin key
is configured (HAL0_ADMIN_KEY), the listener is not loopback-bound
(install.shseedsHAL0_BIND_HOST=0.0.0.0, so that is the shipped
default), and this request's own peer did not arrive over loopback. Such a
request carrying no admin session cookie,Authorization: Beareror
?api_key=is answered401 auth.required; one carrying a client-tier key
is answered403 auth.forbidden, and a WebSocket upgrade closes with code
4403. ADMIN covers every mutating route plusGET /api/docs,
/api/openapi.jsonand/api/auth/exposure.OPENandCLIENTreads —
/v1inference,GET /api/slots,GET /api/models, the health endpoints —
are untouched, as is anything arriving over loopback, so the CLI, the
operator at the console and every on-box service stay exactly as
frictionless as before. A box with no admin key configured is not
affected at all: the gate cannot arm where the requirement would be
unsatisfiable, the same carve-outAuthClass.BOOTSTRAPalready makes for
first run —hal0 doctorwarns about that posture rather than passing it.
To restore off-box access, sign in from the dashboard (a refused mutation
raises an admin-key challenge drawer instead of a dead-end error) or send
the admin key on the request;hal0 auth require onmakes the posture
explicit, andHAL0_BIND_HOST=127.0.0.1in/etc/hal0/api.envreturns the
box to loopback-only. One further consequence on the same boxes: the
cross-siteOrigincheck that previously ran only underrequire_authnow
also runs on these requests, so a browser-driven state-changing call from an
origin outsideHAL0_ALLOWED_ORIGINSis answered403 auth.origin_forbidden. (#1822) - A ComfyUI slot's
image_pinis now honoured, so a pinnedimgslot can
start a different container image after the upgrade.
ComfyUIProvider.image_refresolved only the legacyimagekey and then
the runner-registry default, so a slot that persisted and displayed an
image_pinsilently launched the resolver default anyway. Resolution now
matches every other provider:image_pin(top-level or[slot]-nested,
honoured verbatim) → the legacyimagestring → the resolved runner image.
Becausehal0 slot migrate-flagsfolds an oldimagevalue into
image_pin, a box may be carrying a pin that has never actually taken
effect. Check/etc/hal0/slots/img.tomlbefore upgrading and clear
image_pinif you want the resolver default. (#2172, #2233) - A capability apply naming weights that are not on disk is refused instead
of accepted.POST /api/capabilities/{slot}/{child}withenabled: true
and a model whose weights are absent now returns409
capability.model_not_downloaded, naming thePOST /api/models/{id}/pull
remediation, before anything is persisted and before any container is
spawned. It previously returned200 "warming"after blocking for the full
health wait while llama-server crash-looped on the bare model id. Automation
that applied a selection and then started the pull must pull first and apply
afterwards, or stage the selection withenabled: false— which remains
legal. NPU-trio selections are exempt. (#2026, #2232) ctx_maxis no longer reported for slots that have no context window.
GET /api/slotsandGET /api/slots/{name}ran the llama-only
effective-context resolver against every slot with a bound model, so an
embedding, reranking, transcription, TTS or image slot was handed the
resolver's 8192-token safe fallback — a window it never runs with. The
resolver is now gated ontype == "llm". A boundimg,ttsor
transcription slot omits thectx_maxkey unless its own TOML sets a
context_size, andembedandrerankslots report the raw ceiling from
their own TOML. Clients that read
ctx_maxunconditionally must tolerate its absence. (#1859)PUT /api/user/dashboard-layoutaccepts only the v3 body. The pre-#1061
v2 schema (order/enabled/spans/pinned), kept accepted since
1.0.0-rc.1 so a cached older dashboard bundle would not start failing its
saves, is removed with the v1.3.0 sunset tranche:vmust equal3, and
any other body is rejected422withcode: "layout.invalid". Only a
browser still running a pre-#1061 bundle is affected — hard-refresh the
dashboard and its saves work again. Reads are unchanged: a v2 layout file
already on disk still round-trips onGET, reconciled under the v2 rules.
(#2168, #2225)
Added
- User-installed MCP servers can now actually reach an agent. ADR-0015
extendsInstalledServerwithsecrets(a reference into hal0's own
/etc/hal0/api.envstore, never a literal),tool_policy(the same
ToolPolicyallow/gated/blocked scheme bundled agents use, disjoint and
empty-by-default), andexposure(hermes/brainhonoured;openwebui/
opencodereserved, rejected with501 mcp.exposure_unsupported).
Flippingexposure.hermeson an enabledstreamable-http/sseserver
joins it into Hermes'sconfig.yamland mirrors its tool policy into the
/etc/hal0/agents/hermes.tomlseed soclassify()governs it exactly
like the two bundled servers — the join recomputes and prunes on every
registry mutation, never touching an operator's own hand-added
mcp_serversblock. New CLI:hal0 mcp test <id>(probe + per-tool
allow/gated/blocked/unknown verdicts),hal0 mcp allow|gate|block <id> <tool>,hal0 mcp expose <id> --hermes/--brain, andadd/removeas
aliases ofinstall/uninstall. New REST:POST /api/mcp/{id}/test,
PATCH /api/mcp/{id}/tools,PATCH /api/mcp/{id}/exposure;GET /api/mcp/serversnow reports real reachability for installed
streamable-http/sseservers instead of a hard-coded"stopped". A
stdio-transport server's process supervisor is deferred (see
docs/adr/0015-mcp-supervisor-and-exposure.md) — install/configure work,
exposure.hermes/.brainreject with409 mcp.exposure_needs_supervisor
until it ships. (#305) - Agent-driven OAuth passthrough for Hermes skills that need OAuth
(Google Calendar, Spotify, GitHub). A skill is connected by sending the
operator a consent link — the provider's redirect lands directly on
GET /api/oauth/{provider}/callbackand hal0 exchanges the code for a
token itself (PKCE where the provider supports it), so the operator
never copy-pastes an authorization code and the agent never handles a
raw code or client secret. Newhal0 oauth {list,connect,disconnect, status,set-client-secret}CLI and a Hermes persona addendum that walks the
agent through the connect → poll → confirm flow (the addendum drives
hal0 oauththrough the agent's terminal tool, which is off by default).
A "Connected accounts" dashboard panel exists but is not reachable from
the dashboard navigation in this release (#2267); use the CLI. The provider registry
(/etc/hal0/oauth-providers.toml) is seeded from a shipped default
covering the common skill providers; tokens are stored through the
secrets store, never in TOML, never logged. Ported from ODS's OAuth
passthrough (Osmantic/ODS, Apache-2.0; permission to copy granted by its
author). - Schema-driven settings:
GET /api/settings/fieldsreturns one labelled
row per operator-editableHal0Configkey — group, label, a
consequence-first description, type/enum/range, default, current value,
reload class, secret flag, and (for ahal0/<slot>-shaped value) whether
it currently resolves to a loaded slot. Every leaf's description is a
hard requirement, enforced by a completeness ratchet
(tests/api/test_settings_fields.py) the same shape as the exposure
ratchet: a new config field ships with no dashboard row until it's
documented. Settings → Integrations gains a new Realtime page for the
previously entirely unexposed[realtime]WS voice-endpoint config
(#2108). - One ChangeSet for settings preview and apply:
POST /api/settings/previewcomputes the exact diff aPUT /api/settingswith
the same body would apply — before/after per key, added/changed/removed,
reload class, and services affected — without writing anything.PUT /api/settingsnow returns the same payload under_hal0.changeset
(additive alongside the existing_hal0.apply_plan), since both routes
call the onecompute_settings_changesetfunction. Closes the shape of
four related bugs (a hardcoded changed-fields list that lies on a no-op,
a preview that bills additions but not removals, a round-trip that can
silently drop a key, and a preview whose promise the apply doesn't keep)
as regression fixtures against the general settings surface (#1967,
#2195, #2203, #1511).
Fixed
-
A deliberate stop of a slot no longer reads as a red
errorin
hal0 slot list(#2130).systemctl stopsends SIGTERM and podman
propagates the container's graceful shutdown as exit code 143, which the
generated unit — having noSuccessExitStatus— recorded as
Failed with result 'exit-code'; the state probe then painted every slot
the documentedmigrate-flags --apply --stop-servicespath stopped as an
error on a healthy mid-upgrade box. The rendered unit now declares
SuccessExitStatus=143, the systemd probe recognises the plain
SIGTERM-stop signature on units that predate the directive, and a cached
errorconverges back toofflineonce the unit's own properties show it
was stopped on purpose (a clean stop orsystemctl reset-failed) — while a
genuinely crashed or crash-looping unit keeps its error verdict, breaker
bookkeeping, andlast_crash_lineevidence untouched. -
make release-testtears down the slots it creates again.
remote_slot_createrecorded each slot inCREATED_SLOTS, but every row
captured its result withSLOT="$(remote_slot_create …)"; command
substitution ran the append in a subshell, so the EXIT-trap cleanup saw an
empty list and every tier-gamma sweep left the slots it created behind,
still loaded, on the test box. The function now returns the name in a
global and is called directly, and a contract test drives the real script
against a stubbedsshto pin that cleanup unloads and deletes each created
slot on both passing and failing runs. (#2262) -
make release-testresolves the installed hal0 CLI, not a stale
/opt/hal0path. The remote binary fell back to
/opt/hal0/.venv/bin/hal0, which no current install has; it now prefers the
installer's FHS venv binary/usr/lib/hal0/venv/bin/hal0(the one
hal0-api.serviceruns), thenhal0on the remotePATH, and a new
HAL0_TEST_BINvariable (also amake release-testknob) overrides both.
Pre-flight logs the resolved binary's--versionand stops with exit 2 when
it cannot run, instead of failing every row at its first CLI call. (#2263) -
ADMIN-class routes are gated on a box that has an admin key configured
and binds past loopback, even withrequire_authoff. Previously
require_auth = falsemeant the middleware waved every request through
untouched, so on such a box any device on the LAN could drive every ADMIN
route — model pulls, slot deletes, config writes, approval execution — with
no credential. An ADMIN request now needs an admin session or key exactly as
if enforcement were on, but only when all three hold: an admin key is
configured, the listener is not loopback-bound, and this request's own peer
did not arrive over loopback. A box with no admin key configured is not
covered by this — the gate deliberately cannot arm where the requirement
would be unsatisfiable, the same first-run carve-outAuthClass.BOOTSTRAP
already makes, and the installer mints no key — so the shipped keyless
default remains open to the LAN andhal0 doctornow warns about that
posture rather than passing it. A loopback-bound box, and the operator at
the console of a LAN-bound one, stay exactly as frictionless as before;
OPEN/CLIENTreads are untouched. The dashboard answers a resulting 401
with an admin-key challenge drawer rather than a dead-end error. (#1822) -
Every
systemctl/podman seam call a slot load/unload/restart makes is
now bounded.ContainerProvider._run's callers (the Quadlet write,
daemon-reload,reset-failed, and — the one that actually wedged in the
field —systemctl restart, which spawns the container) used to pass no
timeout at all, so a stucksudo hal0-systemctlor a wedged podman/netavark
call blocked the executor thread forever with nothing bounding it
server-side (#1869). A newhal0.system.seam.bounded_call()wraps every
such call and turns asubprocess.TimeoutExpiredinto a typed
SeamTimeout(slot.seam_timeout, HTTP 504) carrying the command, its
budget, and whatever the child had already printed before being killed;
the error propagates to the slot state machine aserrorwith that
message, same as any other load failure.hal0 slot load|unload|restart| swapandhal0 update --restart-slotsnow show an elapsed counter and the
slot's live state while they wait (refreshed every ~2s on a TTY; one
summary line otherwise) and print a timeout remedy instead of a bare
read-timeout error (hal0 slot logs <name>/journalctl -u hal0-slot@<name>for the slot verbs;hal0 slot list/journalctl -u 'hal0-slot@*'for--restart-slots); the slot verbs also gained a
--jsonflag — instead of blocking silently for up to
~40 minutes with no output (#1870). (#1869, #1870) -
scripts/deploy.shnow verifies the served build before printing
success, instead of trustinggit rev-parse HEADagainst the checkout's
files alone./api/statusgained abuild_shafield — the short git SHA
of the tree the running process actually importshal0from, ornullon
a normal release install with no.git
(hal0.build_info.build_sha, cached once at process start so it reflects
what was true when the worker booted, not a live re-read) — and the
deploy script's health-check step now polls it after the service restart,
failing loudly with ajournalctlhint on a mismatch rather than
reporting a deploy complete that a stuck-on-old-code worker never picked
up. (#1550) -
hal0 update's "Convergence incomplete" panel (and the new Settings ▸
Updates dashboard panel) no longer print amigrate-flags/migrate-caps/
migrate-hw --applycommand that fails outright whenhal0-apior a
hal0-slot@*unit is still active —detect_pending_ownership_migrations
now decides server-side whether--stop-servicesis required (reusing the
same live-unit checkhal0 slot migrate-*itself refuses against,
hal0.cli.slot_commands.active_hal0_units) and both surfaces render that
exact string, never reassembling it client-side. (#1845) -
hal0 update(andscripts/deploy.sh's dev-deploy) now re-assert the
/usr/local/bin/hal0and/usr/local/bin/hal0-agentPATH symlinks on
every activation, the same wayrefresh_privileged_wrappersalready
re-asserts the root-owned sudo wrappers —install.shwas previously the
only writer of these links, so a box upgraded exclusively through
hal0 update(or an editable checkout kept current viadeploy.sh, which
never runs an FHS activation at all) could end up with ahal0on PATH
pointing at a stale or rebuilt venv shim. A newhal0 doctor wrappers --fixcommand exposes the same refresh (wrappers + PATH links) for the
editable-install case deploy.sh runs under. (#1844, #2019) -
Hardware truth stays fresh, and the ROCm lane no longer depends on
rocm-smi. A missing or stale/etc/hal0/hardware.jsonused to make a
real GPU box book resident slot memory asram_mbinstead ofvram_mb
(hal0.slots.capacity._host_has_capable_gputrusted the cache blindly);
it now falls back to a live re-probe when the cache is missing, carries no
probe data, was probed under a different kernel than the running one, or
was written in a previous boot cycle (hal0.hardware.freshness), surfaced
ashal0 doctor all's newhardware_freshnessrow (#1862).derive_device's ROCm-lane check
requiredrocm-smi's optional CLI to exit 0 in addition to
kfd_present()'s device-node truth, so a fresh LXC with/dev/kfd
correctly forwarded — hal0's own reference deploy shape — seeded every
llama.cpp slot onto the slower Vulkan lane;kfd_present()is now
sufficient on its own, matching the capability picker's GPU/ROCm badge and the
ComfyUI/Qwen3-TTS rows, which had the same gap (#2216, #1966). A new
apply_cpu_fallback()logs why a slot's lane fell back to CPU at
seed time (#1936, #1966); the pre-existing load-time refusal for a GPU
slot with zero devices mapped (require_kfd_for_gpu_slot) is now locked
with a regression test naming the exact #1936 shape. Freshly seeded
agent/brain/coder/embed/rerank/utilityslots now clamp
context_sizeto a new memory-envelope function
(hal0.hardware.memory_envelope, mirroring ODS'susable_memory_gb)
instead of shipping the seed files' fixed values verbatim (65536for
agent/brain/utility,32768forcoder,4096forembed/rerank),
with ahal0 doctor allfinding (seed_context_envelope) for a ceiling an
existing box can no longer afford (#1868). -
GET /api/services/health(the Overview card's stable feed) now includes
Hindsight and iterates the same typed service catalog the full Services
page already used, instead of a hardcoded three-branch construction that
could never surface a fourth service or a Hindsight outage. A service with
no wired probe reportsup:false, detail:"unmonitored"— never a
fabricated up — viaasyncio.gather(return_exceptions=True)so one hung
probe can't drop the rest. (#2028) -
The installer now writes the base
/etc/avahi/services/hal0.service
mDNS announcement it always documented — the Services page's Discovery
card andservices/mdns.py's own docstring both promised "the installer
writes it when avahi is present," but nothing ever did, so
base_advertisedwas permanently false. (H13) -
A pulled model's runtime family and pooling type now reach the model
registry, not justdetect()'s in-memory result — a fresh Kokoro/
Moonshine/Qwen3-TTS pull registered with an emptybackendslist, and a
GGUF embedding/reranking pull registered with nometadata.pooling_type,
sohal0.model_meta.modality.derive_modalities_from_model_infocould
never recognise the model. A bound tts/transcription/embedding slot's
LoadedSlot.modalitiesstayed empty, so OmniRouter's tool eligibility
(resolve_for_request) silently never routed to those slots on a real
deployment, even though the routing logic itself was correct. An
FLM-pulled model'sbackends=["npu"](a hardware-lane label, not a
runtime family) is now["flm"], the string the modality derivation
actually recognises. (#2192) -
The llama-only effective-context resolver no longer runs against
embedding/reranking/transcription/tts/image slots (FLM, Kokoro,
Moonshine, Qwen3-TTS, ComfyUI) onGET /api/slotsand
GET /api/slots/{name}. Those slot kinds have no context window; calling
the resolver anyway fabricated an 8192-token "window" (its safe fallback
for an unrecognised model) that the slot never runs with. (#1859) -
[brain_chat].tool_model— the one[brain_chat]key with real routing
consequences (where a tool-calling round reroutes when the chat model
can't emit tool calls this runtime parses) — had no dashboard path at
all; the Agent Chat settings page saved every other key but silently
dropped this one. It now renders as aRichSelectover every configured
chat-capable slot, an explicit "same as chat model" preset, and a
"Disabled" choice, with a plain-language banner when the saved value has
no live target — the shippedhal0/agentdefault ships unbound on a
fresh install, so the gap is now visible in the dashboard instead of
discovered mid-chat (#2108). -
MCP admin tools no longer report a tool failure for a successful read of a
slot whose lifecycle state iserror. The tool-result wrapper's failure
sentinel keyed onstatus == "error"alone, which collides with the slot
payload's own lifecyclestatusfield —slot_status,slot_by_nameand
slot_by_idreturnedisError: truewith the correct record stringified
andstructuredContentdropped, while REST answered 200 for the identical
resource. The sentinel now requires the MCP layer's full failure envelope
(anerrordict carrying a stringcode), a shape no slot payload can
own; genuine failures (unknown slot, REST 4xx/5xx, bad args) still raise
isError: truewith their typed code. (#2025) -
A memory retain that extracts zero facts now says so instead of
finishing as a barestatus=completed, error_message=nullwhile nothing it
stored is ever retrievable. The terminal operation record — served by
GET /api/memory/banks/{bank}/operations/{id}and the
memory_operation_getMCP tool — is annotated withfacts_extracted
(from the engine'sresult_metadata.unit_ids_count, falling back to the
document'smemory_unit_counton older engines) plusnothing_learned: trueand a human-readablenoticewhen the retained document holds zero
memory units. A zero-yield retain of an already-known document (duplicate
content_hashdedup, adjudicated correct by rc-validate) is distinguished
viadocument_fact_countand gets a "content was already known" notice
rather than a false alarm. The dashboard's Add-fact flow now watches the
async extraction to its terminal state and raises a warn toast — "nothing
was learned" — instead of leaving "Fact added" as the last word. Empty
yield stayscompleted: it is a legitimate outcome made visible, not
reclassified as an error. (#2030) -
ComfyUIProvider.image_refnow honors the slot-levelimage_pinescape
hatch (#2172). The provider resolved only the legacyimagekey and then
the runner-registry default, so a pinned img slot persisted (and displayed)
its pin but silently launched the resolver default at load/restart.
Resolution now matches every other provider's precedence contract
(spec-hw-slot-ownership §3):image_pin(top-level or[slot]-nested,
honored verbatim) → legacyimagestring (back-compat) →
resolve_runner_image("comfyui")(env var → manifest digest pin →
bundled tag). flm/kokoro/moonshine/qwen3tts already honored the pin and
are unchanged. -
The capability catalog no longer advertises a
gpu-rocmbackend for
registry models on hosts that cannot run it. Therocmtag branch in
_backend_variantsappendedgpu-rocmunconditionally — no
available_backends()intersection, unlike its vulkan/llama-server and
provider-runtime siblings — so everyhal0 model add-registered GGUF
(which carriesrocmin its default backend tags) surfaced an unrunnable
gpu-rocmentry atfit_status: allowedon kfd-less hosts. The branch now
applies the same host intersection: no compute-capable AMD GPU, no
advertised ROCm lane. The deliberateqwen3-ttsvoice.tts engine row is
injected separately and is unaffected. (#2029) -
hal0 update --rollbacknow renders everything the route returns — and
says loudly that hal0-api still needs a restart (#2027, #2145). The rc.1
fix for #1541 (7c9ed3d) shipped only its route half:/api/updates/rollback
returnedrolled_back_to/previous_now/schema_warning, but the CLI
kept printing onlyrolled back (<channel>)and discarded all three — so on
the emergency path the CLI was the one surface that never told the operator
the rolled-away-from version was still serving (the dashboard warns in two
places; the non-restart itself is by-design, adjudicated as
update-rollback-defers-hal0-api-restart). The CLI now names the version it
reverted to, shows what the previous-version slot holds after the bounce,
rendersschema_warningas its own callout, and when the API's reported
running version disagrees with the rolled-back tree says so and prints
systemctl restart hal0-api— suppressed when the API is unreachable, since
nothing stale is serving then. The running version is read from the API (the
rollback response's newrunning_versionfield, falling back to
/api/healthagainst an older daemon), never from the CLI's own in-process
hal0.__version__, which post-swap resolves through the rolled-back tree
and would compare the rolled-back version against itself. The route response
also gains an explicitrestart_requiredflag so every caller gets the
contract without version arithmetic. The route still deliberately does not
restart hal0-api: rollback is the only mutating updater operation that is
not a background job, and an in-route restart would kill the answering
process mid-response — the exact stale-terminal-state race #1540 fixed for
apply/commit by persisting the job before the restart. The rc.1 CHANGELOG
entry that described this CLI behaviour as already shipped is corrected in
place below. -
Memory dashboard: bank deletion has a live UI path again. The pre-v2
components stranded inmemory.jsxafter the v2 rewrite (MemHindsightCard,
MemBankCard,MemTimeseries,MemOperations,MemBankDetail, plus their
private helpers and orphaned styles) were removed —MemBankDetailcarried
the only bank-delete affordance, and it had been unreachable. Delete now
lives on the Bank workspace's bank bar as a trash action behind the shared
destructive confirm dialog (type the bank id to confirm, blast radius shown
from live stats — the same idiom as slot delete), backed by the existing
confirm-guardedDELETE /api/memory/banks/{bank_id}route. The Add modal's
mental-model refresh-schedule picker, which silently discarded whatever the
operator picked (useMentalModelCreatesends only{name, source_query}),
was removed rather than left inert. (#2107) -
A capability apply naming a model whose weights are not on disk
(downloaded: falsein the picker catalog, e.g. a pullable-but-never-pulled
row, or a registry entry whose file vanished) is now rejected up front with a
typed 409capability.model_not_downloadednaming the
POST /api/models/{id}/pullremediation — before anything is persisted and
before any container is spawned. Previously the orchestrator went straight to
SlotManager.load, launched llama-server with the bare model id as
--model(instantfailed to open GGUF filecrash loop), blocked the apply
for the full ~3 min health-wait, and then returned HTTP 200"warming".
Disabling a selection whose weights vanished still always succeeds, and
picking a model while the child is disabled remains legal staging — the gate
arms on enable. NPU-trio selections are exempt (they toggle the FLM anchor,
never spawn a bare-id process). (#2026) -
The model drawer's flags divergence (
diffFlags— the raw-mode diff, tune
pills, profile apply preview and seed consequence preview all consume it) now
matches pairs through the full llama-server alias table (FLAG_ALIASES)
instead of only the managed subset. A model tune carrying-b 2048diffed
against a profile carrying--batch-size 1024reads as one changed pair
again — not an added pill plus a struck ghost — and no longer inflates the
◆ N divergedcount. Rendered spellings still come from the operator's
own text; only the matching canonicalises. (#2211) -
Copy-to-clipboard buttons across the dashboard (command palette, model
drawer source paths, model detail path, benchmark commands, slot logs,
connections) now report the real outcome: the success toast waits for the
async clipboard write to resolve, and a rejected write — permission denial,
non-secure origin — shows a failure toast instead of a false success. All
call sites share one helper (@/lib/clipboard) that keeps the legacy
execCommand("copy")fallback for plain-http LAN dashboards. (#2214) -
An active
HAL0_RELEASES_URLoverride is now disclosed on every surface
that names the update channel, instead of silently reporting a channel it
never consulted (#2128).hal0 update --checkmarks the channel as
overridden, prints the override URL with a note that the configured channel
is ignored, and no longer claimsup to dateagainst a pinned manifest;
/api/updates/checkand/api/updates/statecarry a new
releases_url_overridefield (null when no override is set) so API
consumers and the dashboard's Settings → Updates page see it too. Which
URL is consulted is unchanged — the override still wins.
Changed
-
hal0.services.registry.ServiceDefgainscategory,source
(builtin/extension),modalities, and aprobe_tcpfield for a future
"tcp"probe kind — additive, no builtin service's behavior changes. This
is the one shape a future extension-manifest-provided service row is meant
to reuse, so the registry never grows a second, parallel service shape.
(#2028) -
The Inference pane's slot cards get the drawer's rich dropdowns. The card
model picker is no longer a bare<select>of model names: each row carries
the model's quantisation and modality tags, its size, which other slots are
bound to it, and a GTT fit chip (fits/tight/won't fit) from one
batchedPOST /api/models/feasibilityfired the first time the menu opens —
advisory only, so an unprobed or over-budget model stays selectable. Picking
a model still swaps it immediately, as before. The[ device | PROFILE ]
pill, which previously did nothing but open the slot editor, is now a real
profile picker filtered to the slot's type: a pick writes nothing by itself,
it opens a confirm that states the consequences first — the runtime, a lane
move, how many launch flags the profile's tune replaces, and whether applying
restarts the slot or, when it is stopped, starts it and loads its model — and
only Apply performs the same two writes the drawer's Save does,PUT /api/slots/{name}/config {profile}followed by a backgroundPOST /api/slots/{name}/restart. The pill's old gesture lives on as the picker's
last row ("✎ Edit slot…"), which also remains the only way to clear a slot's
profile. (#2219)
Removed
- The v1.3.0 sunset tranche (#2168) is executed — three internal compat
shims re-stamped to v1.3.0 at the 1.2.0 cut are deleted ahead of the bump:PUT /api/user/dashboard-layoutno longer accepts the pre-#1061 v2 body
(order/enabled/spans/pinned, kept for cached UI bundles) — v3 is the only
accepted write. A v2 layout file already on disk still round-trips on GET.- The runner-image pull-status disk read no longer consults the bare
<id>.jsonsnapshot name — every snapshot written since per-tag pulls
(#2048) is a per-tag<id>@<tag>.jsonfile. hal0.config.paths.bundle_chosen_marker()(the.bundle-chosen
bundle-picker marker path) is deleted — the picker stayed deferred and
nothing ever wired the marker.
Migrations
-
Upgrading from 1.2.0 or earlier leaves the
/usr/local/bin/hal0PATH
entry stale — runsudo hal0 doctor wrappers --fixonce afterwards. This
release makeshal0 updatere-assert that symlink on every activation, but
the re-assert lives in the updater itself, and the activation that installs
1.3.0 is still executed by the outgoing version's code. On a box coming
from a release that predates the fix there is nothing to run it, so ahal0
on PATH left pointing at an old venv shim by an earlierinstall.shstays
pointed there —hal0 --versionkeeps reporting the stale version while the
API and the dashboard correctly report 1.3.0.sudo hal0 doctor wrappers --fixre-installs the link from the active release immediately; a later
hal0 updatealso fixes it, because by then the re-assert is live. Boxes
already on 1.3.0 or newer never hit this. (#1844, #2019) -
Nothing to run by hand. The config schema version is unchanged
([meta] schema_versionstays 1), no database migration is added, and
neithercapabilities.tomlnorprofiles.tomlchanges shape. The one thing
this upgrade rewrites on disk is each slot's generated systemd unit:
hal0 updatere-installs thehal0-systemctlseam wrapper and then
re-renders every unit through the new code in the same activation, so the
SuccessExitStatus=143fix lands with nomigrate-flags-style pass. Slots
stopped before the upgrade need no rewrite either — the state probe
recognises a clean SIGTERM stop on a unit that predates the directive, so a
deliberately stopped slot reads asofflineimmediately rather than after
its next load. -
Upgrading from 1.1.0 and skipping 1.2.0 still takes 1.2.0's
profiles.tomlmigration. The file gainslegacy_seeds_migratedand
legacy_seeds_adoptedon the first load after the upgrade — automatic,
nothing to run — andProfilesConfigrejects unknown keys on 1.1.0, so the
migrated file will not parse there. Back up/etc/hal0/profiles.tomlbefore
upgrading if you may downgrade to 1.1.0. -
A rolled-back box does not read MCP server records that 1.3.0 rewrote.
An installed server's record gainssecrets,tool_policyandexposure,
and serialises its policy under a[tools]table while the old integer
count moves totools_count. 1.3.0 still reads a 1.2.0 record, but the
reverse does not hold: a record rewritten by 1.3.0 fails validation on
1.2.0, where the server disappears fromhal0 mcp listand a direct read
answers400 mcp.record_malformed. Any MCP write under 1.3.0 rewrites the
record — installing a server, changing its config or enabled state, its
tool policy or its exposure (hal0 mcp testonly reads) — but nothing is
rewritten automatically on load, so a box that upgrades and rolls back
without changing its MCP servers is unaffected. -
Hermes needs reprovisioning before it can drive the new OAuth flow. The
agent-facing half of the feature lives in the persona file, which only the
provisioning pipeline writes —hal0 updatenever invokes it. On an
already-provisioned box, runhal0 agent reprovision hermesso the agent
knows the connect-poll-confirm sequence; the persona driveshal0 oauth
through the agent's terminal tool, so that tool must be enabled. Every shipped provider also ships
an emptyclient_id, so each provider needs its own app credentials
configured before a connection can be made at all.