Skip to content

hal0 v1.3.0

Choose a tag to compare

@github-actions github-actions released this 16 Sep 22:15
· 291 commits to main since this release
0984f53

Highlights

  • User-installed MCP servers can now actually reach an agent. An installed
    streamable-http/sse server gains secrets, a ToolPolicy, and an
    exposure flag; flipping exposure.hermes joins it into Hermes's
    config.yaml and mirrors its tool policy into the agent seed, driven by
    hal0 mcp test|allow|gate|block|expose and matching REST. stdio servers
    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 on GET /api/oauth/{provider}/callback and 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 new hal0 oauth CLI manages
    the accounts; tokens go through the secrets store, never into TOML.
  • Settings are schema-driven, and every save previews first.
    GET /api/settings/fields returns one documented row per operator-editable
    config key — including the previously unreachable [brain_chat].tool_model
    and a new Realtime page for [realtime] — and POST /api/settings/preview
    computes the exact diff PUT /api/settings would 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.json now triggers a
    live re-probe instead of booking a real GPU box's resident slot memory as
    ram_mb; /dev/kfd is 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 clamp context_size to a host memory
    envelope instead of shipping the seed files' fixed values (up to 65536).
    (#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
    typed slot.seam_timeout (HTTP 504) naming the command, its budget and
    whatever the child printed before being killed. hal0 slot load|unload|restart|swap shows an elapsed counter and the slot's live state
    while it waits, instead of blocking silently for up to ~40 minutes.
    (#1869, #1870)
  • hal0 update re-asserts the /usr/local/bin/hal0 and
    /usr/local/bin/hal0-agent PATH symlinks on every activation
    , so a box
    upgraded exclusively through the updater can no longer end up with a hal0
    on PATH pointing at a stale venv shim. /api/status also gained a
    build_sha field naming the git tree the running process imports from
    (null on a normal release install, which has no git checkout),
    which scripts/deploy.sh now 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 with require_auth off.

    Until this release require_auth = false meant 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.sh seeds HAL0_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: Bearer or
    ?api_key= is answered 401 auth.required; one carrying a client-tier key
    is answered 403 auth.forbidden, and a WebSocket upgrade closes with code
    4403. ADMIN covers every mutating route plus GET /api/docs,
    /api/openapi.json and /api/auth/exposure. OPEN and CLIENT reads —
    /v1 inference, 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-out AuthClass.BOOTSTRAP already makes for
    first run — hal0 doctor warns 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 on makes the posture
    explicit, and HAL0_BIND_HOST=127.0.0.1 in /etc/hal0/api.env returns the
    box to loopback-only. One further consequence on the same boxes: the
    cross-site Origin check that previously ran only under require_auth now
    also runs on these requests, so a browser-driven state-changing call from an
    origin outside HAL0_ALLOWED_ORIGINS is answered 403 auth.origin_forbidden. (#1822)
  • A ComfyUI slot's image_pin is now honoured, so a pinned img slot can
    start a different container image after the upgrade.

    ComfyUIProvider.image_ref resolved only the legacy image key and then
    the runner-registry default, so a slot that persisted and displayed an
    image_pin silently launched the resolver default anyway. Resolution now
    matches every other provider: image_pin (top-level or [slot]-nested,
    honoured verbatim) → the legacy image string → the resolved runner image.
    Because hal0 slot migrate-flags folds an old image value into
    image_pin, a box may be carrying a pin that has never actually taken
    effect. Check /etc/hal0/slots/img.toml before upgrading and clear
    image_pin if 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} with enabled: true
    and a model whose weights are absent now returns 409
    capability.model_not_downloaded, naming the POST /api/models/{id}/pull
    remediation, before anything is persisted and before any container is
    spawned. It previously returned 200 "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 with enabled: false — which remains
    legal. NPU-trio selections are exempt. (#2026, #2232)
  • ctx_max is no longer reported for slots that have no context window.
    GET /api/slots and GET /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 on type == "llm". A bound img, tts or
    transcription slot omits the ctx_max key unless its own TOML sets a
    context_size, and embed and rerank slots report the raw ceiling from
    their own TOML. Clients that read
    ctx_max unconditionally must tolerate its absence. (#1859)
  • PUT /api/user/dashboard-layout accepts 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: v must equal 3, and
    any other body is rejected 422 with code: "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 on GET, reconciled under the v2 rules.
    (#2168, #2225)

Added

  • User-installed MCP servers can now actually reach an agent. ADR-0015
    extends InstalledServer with secrets (a reference into hal0's own
    /etc/hal0/api.env store, never a literal), tool_policy (the same
    ToolPolicy allow/gated/blocked scheme bundled agents use, disjoint and
    empty-by-default), and exposure (hermes/brain honoured; openwebui/
    opencode reserved, rejected with 501 mcp.exposure_unsupported).
    Flipping exposure.hermes on an enabled streamable-http/sse server
    joins it into Hermes's config.yaml and mirrors its tool policy into the
    /etc/hal0/agents/hermes.toml seed so classify() 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_servers block. 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, and add/remove as
    aliases of install/uninstall. New REST: POST /api/mcp/{id}/test,
    PATCH /api/mcp/{id}/tools, PATCH /api/mcp/{id}/exposure; GET /api/mcp/servers now reports real reachability for installed
    streamable-http/sse servers 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/.brain reject with 409 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}/callback and 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. New hal0 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 oauth through 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/fields returns one labelled
    row per operator-editable Hal0Config key — group, label, a
    consequence-first description, type/enum/range, default, current value,
    reload class, secret flag, and (for a hal0/<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/preview computes the exact diff a PUT /api/settings with
    the same body would apply — before/after per key, added/changed/removed,
    reload class, and services affected — without writing anything. PUT /api/settings now returns the same payload under _hal0.changeset
    (additive alongside the existing _hal0.apply_plan), since both routes
    call the one compute_settings_changeset function. 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 error in
    hal0 slot list (#2130). systemctl stop sends SIGTERM and podman
    propagates the container's graceful shutdown as exit code 143, which the
    generated unit — having no SuccessExitStatus — recorded as
    Failed with result 'exit-code'; the state probe then painted every slot
    the documented migrate-flags --apply --stop-services path 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
    error converges back to offline once the unit's own properties show it
    was stopped on purpose (a clean stop or systemctl reset-failed) — while a
    genuinely crashed or crash-looping unit keeps its error verdict, breaker
    bookkeeping, and last_crash_line evidence untouched.

  • make release-test tears down the slots it creates again.
    remote_slot_create recorded each slot in CREATED_SLOTS, but every row
    captured its result with SLOT="$(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 stubbed ssh to pin that cleanup unloads and deletes each created
    slot on both passing and failing runs. (#2262)

  • make release-test resolves the installed hal0 CLI, not a stale
    /opt/hal0 path.
    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.service runs), then hal0 on the remote PATH, and a new
    HAL0_TEST_BIN variable (also a make release-test knob) overrides both.
    Pre-flight logs the resolved binary's --version and 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 with require_auth off.
    Previously
    require_auth = false meant 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-out AuthClass.BOOTSTRAP
    already makes, and the installer mints no key — so the shipped keyless
    default remains open to the LAN and hal0 doctor now 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/CLIENT reads 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 stuck sudo hal0-systemctl or a wedged podman/netavark
    call blocked the executor thread forever with nothing bounding it
    server-side (#1869). A new hal0.system.seam.bounded_call() wraps every
    such call and turns a subprocess.TimeoutExpired into 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 as error with that
    message, same as any other load failure. hal0 slot load|unload|restart| swap and hal0 update --restart-slots now 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
    --json flag — instead of blocking silently for up to
    ~40 minutes with no output (#1870). (#1869, #1870)

  • scripts/deploy.sh now verifies the served build before printing
    success, instead of trusting git rev-parse HEAD against the checkout's
    files alone. /api/status gained a build_sha field — the short git SHA
    of the tree the running process actually imports hal0 from, or null on
    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 a journalctl hint 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 a migrate-flags/migrate-caps/
    migrate-hw --apply command that fails outright when hal0-api or a
    hal0-slot@* unit is still active — detect_pending_ownership_migrations
    now decides server-side whether --stop-services is required (reusing the
    same live-unit check hal0 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 (and scripts/deploy.sh's dev-deploy) now re-assert the
    /usr/local/bin/hal0 and /usr/local/bin/hal0-agent PATH symlinks on
    every activation, the same way refresh_privileged_wrappers already
    re-asserts the root-owned sudo wrappers — install.sh was previously the
    only writer of these links, so a box upgraded exclusively through
    hal0 update (or an editable checkout kept current via deploy.sh, which
    never runs an FHS activation at all) could end up with a hal0 on PATH
    pointing at a stale or rebuilt venv shim. A new hal0 doctor wrappers --fix command 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.json used to make a
    real GPU box book resident slot memory as ram_mb instead of vram_mb
    (hal0.slots.capacity._host_has_capable_gpu trusted 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
    as hal0 doctor all's new hardware_freshness row (#1862). derive_device's ROCm-lane check
    required rocm-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/utility slots now clamp
    context_size to a new memory-envelope function
    (hal0.hardware.memory_envelope, mirroring ODS's usable_memory_gb)
    instead of shipping the seed files' fixed values verbatim (65536 for
    agent/brain/utility, 32768 for coder, 4096 for embed/rerank),
    with a hal0 doctor all finding (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 reports up:false, detail:"unmonitored" — never a
    fabricated up — via asyncio.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 and services/mdns.py's own docstring both promised "the installer
    writes it when avahi is present," but nothing ever did, so
    base_advertised was permanently false. (H13)

  • A pulled model's runtime family and pooling type now reach the model
    registry
    , not just detect()'s in-memory result — a fresh Kokoro/
    Moonshine/Qwen3-TTS pull registered with an empty backends list, and a
    GGUF embedding/reranking pull registered with no metadata.pooling_type,
    so hal0.model_meta.modality.derive_modalities_from_model_info could
    never recognise the model. A bound tts/transcription/embedding slot's
    LoadedSlot.modalities stayed 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's backends=["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) on GET /api/slots and
    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 a RichSelect over 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 shipped hal0/agent default 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 is error. The tool-result wrapper's failure
    sentinel keyed on status == "error" alone, which collides with the slot
    payload's own lifecycle status field — slot_status, slot_by_name and
    slot_by_id returned isError: true with the correct record stringified
    and structuredContent dropped, while REST answered 200 for the identical
    resource. The sentinel now requires the MCP layer's full failure envelope
    (an error dict carrying a string code), a shape no slot payload can
    own; genuine failures (unknown slot, REST 4xx/5xx, bad args) still raise
    isError: true with their typed code. (#2025)

  • A memory retain that extracts zero facts now says so instead of
    finishing as a bare status=completed, error_message=null while nothing it
    stored is ever retrievable. The terminal operation record — served by
    GET /api/memory/banks/{bank}/operations/{id} and the
    memory_operation_get MCP tool — is annotated with facts_extracted
    (from the engine's result_metadata.unit_ids_count, falling back to the
    document's memory_unit_count on older engines) plus nothing_learned: true and a human-readable notice when the retained document holds zero
    memory units. A zero-yield retain of an already-known document (duplicate
    content_hash dedup, adjudicated correct by rc-validate) is distinguished
    via document_fact_count and 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 stays completed: it is a legitimate outcome made visible, not
    reclassified as an error. (#2030)

  • ComfyUIProvider.image_ref now honors the slot-level image_pin escape
    hatch (#2172). The provider resolved only the legacy image key 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) → legacy image string (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-rocm backend for
    registry models on hosts that cannot run it. The rocm tag branch in
    _backend_variants appended gpu-rocm unconditionally — no
    available_backends() intersection, unlike its vulkan/llama-server and
    provider-runtime siblings — so every hal0 model add-registered GGUF
    (which carries rocm in its default backend tags) surfaced an unrunnable
    gpu-rocm entry at fit_status: allowed on kfd-less hosts. The branch now
    applies the same host intersection: no compute-capable AMD GPU, no
    advertised ROCm lane. The deliberate qwen3-tts voice.tts engine row is
    injected separately and is unaffected. (#2029)

  • hal0 update --rollback now 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
    returned rolled_back_to / previous_now / schema_warning, but the CLI
    kept printing only rolled 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,
    renders schema_warning as 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 new running_version field, falling back to
    /api/health against 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 explicit restart_required flag 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 in memory.jsx after the v2 rewrite (MemHindsightCard,
    MemBankCard, MemTimeseries, MemOperations, MemBankDetail, plus their
    private helpers and orphaned styles) were removed — MemBankDetail carried
    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-guarded DELETE /api/memory/banks/{bank_id} route. The Add modal's
    mental-model refresh-schedule picker, which silently discarded whatever the
    operator picked (useMentalModelCreate sends only {name, source_query}),
    was removed rather than left inert. (#2107)

  • A capability apply naming a model whose weights are not on disk
    (downloaded: false in 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 409 capability.model_not_downloaded naming the
    POST /api/models/{id}/pull remediation — 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 (instant failed to open GGUF file crash 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 2048 diffed
    against a profile carrying --batch-size 1024 reads as one changed pair
    again — not an added pill plus a struck ghost — and no longer inflates the
    ◆ N diverged count. 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_URL override is now disclosed on every surface
    that names the update channel, instead of silently reporting a channel it
    never consulted (#2128). hal0 update --check marks the channel as
    overridden, prints the override URL with a note that the configured channel
    is ignored, and no longer claims up to date against a pinned manifest;
    /api/updates/check and /api/updates/state carry a new
    releases_url_override field (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.ServiceDef gains category, source
    (builtin/extension), modalities, and a probe_tcp field 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
    batched POST /api/models/feasibility fired 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 background POST /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-layout no 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>.json snapshot name — every snapshot written since per-tag pulls
      (#2048) is a per-tag <id>@<tag>.json file.
    • 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/hal0 PATH
    entry stale — run sudo hal0 doctor wrappers --fix once afterwards.
    This
    release makes hal0 update re-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 a hal0
    on PATH left pointing at an old venv shim by an earlier install.sh stays
    pointed there — hal0 --version keeps reporting the stale version while the
    API and the dashboard correctly report 1.3.0. sudo hal0 doctor wrappers --fix re-installs the link from the active release immediately; a later
    hal0 update also 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_version stays 1), no database migration is added, and
    neither capabilities.toml nor profiles.toml changes shape. The one thing
    this upgrade rewrites on disk is each slot's generated systemd unit:
    hal0 update re-installs the hal0-systemctl seam wrapper and then
    re-renders every unit through the new code in the same activation, so the
    SuccessExitStatus=143 fix lands with no migrate-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 as offline immediately rather than after
    its next load.

  • Upgrading from 1.1.0 and skipping 1.2.0 still takes 1.2.0's
    profiles.toml migration.
    The file gains legacy_seeds_migrated and
    legacy_seeds_adopted on the first load after the upgrade — automatic,
    nothing to run — and ProfilesConfig rejects unknown keys on 1.1.0, so the
    migrated file will not parse there. Back up /etc/hal0/profiles.toml before
    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 gains secrets, tool_policy and exposure,
    and serialises its policy under a [tools] table while the old integer
    count moves to tools_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 from hal0 mcp list and a direct read
    answers 400 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 test only 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 update never invokes it. On an
    already-provisioned box, run hal0 agent reprovision hermes so the agent
    knows the connect-poll-confirm sequence; the persona drives hal0 oauth
    through the agent's terminal tool, so that tool must be enabled. Every shipped provider also ships
    an empty client_id, so each provider needs its own app credentials
    configured before a connection can be made at all.