v1.4.6
This section describes what changed relative to the released 1.4.5, which shipped an independent remediation of the same 2026-07-06 review findings. Where both efforts fixed the same defect, the stronger variant was kept; entries below cover only behaviour that differs from 1.4.5 as released.
Changed
- Profile activation is now testable: the orchestration behind
loaddrives an explicit operations seam.LoadProfile's decision logic — the idempotency/drift check (ADR-0007), theauto_stop_server/auto_unloadpass (ADR-0004), and the managed/external activation fork — previously calledexec,lsof, process signals, and live HTTP directly, so none of it was covered by tests; only its pure leaf helpers were. The orchestration now runs against a package-private operations interface (ADR-0009) whose production adapter executes the same operations as before, and the first orchestration tests drive that same code against an in-memory fake: the idempotent no-op (with and without a drift notice),--restart, the auto-stop/auto-unload matrix including the foreign-occupant-on-a-shared-address case, and the external model swap/connect paths — without forking a process or opening a socket. The target address is also derived from the resolved profile once and carried through, instead of being re-assembled at each call site. No observable behaviour changes. unloadandstopnow share one orchestration — the CLI and menu are formatters over it. The "unload on a managed backend means stop the server" rule (ADR-0003/0004) was encoded twice, once in the CLI handler and once in the menu handler, along with duplicated managed-vs-external branching — deleting either handler would have resurrected the logic in the other. Both front ends now call a singleUnload/Stopentry point in the server lifecycle layer, built on the activation-operations seam (ADR-0009) and covered by fake-driven tests (managed backend → server stop, external backend → API unload with the server left running). The entry points return a result — the instance acted on, whether the server was stopped or only its model unloaded, and the steps taken — which each front end formats in its own style after the fact. Output text is unchanged; the one visible difference is that stop/unload progress steps now appear once the operation completes instead of streaming live (in the interactive menu, the transient progress popup is replaced by dimmed step lines printed above the outcome).- Which parameters the "Show model config" pop-up displays is now owned by each backend, not the menu. The pop-up renderer was the last place outside the backend files that branched on backend names, and it re-encoded each backend's parameter vocabulary by hand — a list that could (and did, before 1.4.5's LM Studio fix) drift from what a load request actually carries. Each LLM Server now exposes a
ParamSpecslist — label plus value formatter for exactly the parameters it applies when loading a model or launching a server — and the menu renders that list generically, so a new backend gets a truthful pop-up without any menu change and the compiler forces it to declare its spec. Shared spec definitions keep labels and formatting identical for parameters several backends honour. Two visible corrections ride along: Ollama profiles no longer displaycontext_size— Ollama's load request carries only the model name and a keep-alive, so the pop-up was showing a value the server never received — and llamacpp profiles now list the five sampling parameters 1.4.5 started sending (temperature,repeat_penalty,top_k,top_p,min_p), since each backend's spec shows exactly what it sends. log_retention: 0now disables cleanup instead of deleting every non-active log. In 1.4.5 a retention of0computed a zero-day age threshold, so every timestamped log not belonging to a running server was deleted on each server start. A retention of0now means cleanup is disabled (nothing is deleted), same as leavinglog_retentionunset — only a positive number of days enables age-based deletion. The running-server protection 1.4.5 added to the automatic path is unchanged.status --jsonentries are now grouped per backend. Each backend's running instances are followed directly by its idlerunning: falseentry, in sorted backend order; 1.4.5 listed all running instances (sorted) first and appended every idle entry after them. The entry set, field names, and exit codes are unchanged.
Fixed
- The
endpointsmigration error no longer instructs an impossible move. A config still carrying the pre-1.4endpoints:section was told "'endpoints' has been merged into 'servers' — move entries to the servers section", but a servers entry only acceptsenabled/api_key: a scalar address fails the bool decode, and anaddr:key in the mapping form is silently dropped, after which discovery probes the default address and the custom-port instance is never found. The error (and the matchingconfig checkproblem line) now names the real migration target: a non-default address is set viahost/portin thedefaultssection or on a profile. - Bare
startwith a managed default backend now fails fast instead of forking a doomed server. With only llamacpp enabled,llama-launcher startwithout--profileforkedllama-serverwith no model flag; the child exited immediately ("--model is required") and the user got an opaque "server exited immediately after start" error plus a log tail. A managed server bakes the Model into its start arguments (ADR-0003), so there is nothing to start without a Profile — the command now fails before forking, with the configuration-error exit code (2) and an actionable message (llamacpp requires a profile: llama-launcher start --profile <name>). External backends (Ollama, LM Studio) still start with no model loaded, as before. The MCPstart_servertool description no longer claims that a profile-less start works for every backend. - Closing a pop-up now restores the cursor on every exit path — and clears the pop-up box. 1.4.5 re-showed the cursor after the dismissing keypress, but when entering raw mode for that keypress failed the pop-up returned immediately with the cursor still hidden, and no exit path cleared the pop-up from the screen. Every pop-up exit path now clears the pop-up and restores the cursor, including the raw-mode failure path. (The progress pop-ups were already covered: their call sites restore the cursor after each operation.)
- A health-wait timeout no longer orphans the spawned server silently — and a retry no longer forks a duplicate onto the occupied port. llama-server answers
/healthwith 503 while it loads its model, and a large GGUF on a cold disk can legitimately exceed the launcher's wait window (15 s onstart, 30 s onload). On timeout the spawned process kept running, but the error never said so; an immediate retry then saw the address as "unhealthy", forked a secondllama-serveronto the same port, and reported a misleading "server exited immediately after start" when that duplicate died on the bind. The launcher now deliberately leaves the still-loading server running — killing a legitimately slow model load would be worse — and the timeout error names its PID and log path with recovery guidance (watchllama-launcher logs llamacpp, retry once healthy, orkill <PID>). A "still starting up" server (503) is now distinguished from an unreachable one: every managed start first probes the target address and refuses to spawn a duplicate while an earlier server there is still coming up, naming that server's PID and log path instead. This refusal also applies toload --restart— stop the reported PID first if you really want to replace a loading server. A retry after the server turns healthy behaves as before: the idempotent no-op (ADR-0007). This composes with 1.4.5's start-crash detection: a child that dies within the startup grace window is reaped and reported immediately, so the timeout error can no longer point at an already-dead PID. - The stop path no longer signals the same PID twice.
StopInstancecontradicted its own docstring (which claimed the backend hook ran first) and duplicatedEnsureStopped: it signalled the listening PID, then delegated toEnsureStopped, which re-derived and re-signalled the same PID. Both mechanisms now run exactly once, in the documented order (TDD §6.5) — PID signal with the SIGTERM → SIGKILL → port-release escalation, then the backend's native stop hook — inside a single routine; a hook failure surfaces only when the address is still serving afterwards, so it never masks or blocks a stop that already succeeded (ADR-0001: stop is unconditional). Ollama's stop behaviour is 1.4.5's:TryStopis a no-op and the address-scoped PID signal is its stop mechanism. - Failed MCP mutating calls are now flagged as tool errors. The adapter decided success by "non-zero exit with stdout ⇒ informational negative" — but every mutating subcommand prints progress to stdout (e.g. " Loading X") before it can fail, so a
load/stop/unloadthat exited 3 with "Error: …" on stderr still came back to the remote agent as a success-shaped result with the error buried in the text. The result mapping is now keyed off the CLI's exit code (TDD §3.3): exit 0 is success, exit 1 stays an informational negative returned as normal content (sostatus --json's exit-1-with-JSON-array case is unchanged), and exit ≥ 2 — as well as a signal or a failure to run the CLI at all — is a tool error carrying stderr with stdout appended for context. The 1 MiB per-stream output cap 1.4.5 added is unchanged and cannot affect the verdict, which no longer depends on output at all. - Two residual false-drift cases are gone from the idempotent-reload notice. 1.4.5 stopped comparing fields llama-server's
/propsdoes not report, but two false positives survived:/propsreportsn_ctxper slot, so profiles withparallel > 1still showed a boguscontext_sizedrift (the per-slot value is now scaled bytotal_slots); and sampling parameters (temperature,top_k, …) were still compared against the server's reported values — they only set the server's request defaults, so they are now excluded from the live diff entirely. The flip side is that editing only a sampling value in an already-running profile needsload --restartto take effect. A drift notice now always means real drift (ADR-0007). - LM Studio profiles now send and display
parallel. LM Studio's load endpoint (POST /api/v1/models/load, verified against both the official REST docs and the installed LM Studio 0.4.15) accepts aparallelfield, but the parameter table marked it unsupported for lmstudio and the load request never carried it. It is now sent, listed in the parameter table, and shown in the "Show model config" pop-up. The 1.4.5 mappings (batch_size→eval_batch_size,flash_attn→flash_attention, nogpu_layers— LM Studio's REST API has no GPU-offload field) are unchanged; the stalegpu_layers: 99lines in the commented example profiles are gone too. - ADR-0001 and CONTEXT.md now name Ollama's real stop mechanism. Both still cited arg-less
ollama stopas Ollama's stop path — a command that requires a MODEL argument and only unloads that model (verified against Ollama 0.32.1), so it never was a server-stop command. They now document the actual mechanism: the process listening at the instance's address is signalled, and there is deliberately no host-wide sweep ofollama serveprocesses — that would kill instances at other addresses the launcher was not asked to stop (ADR-0006). The decision itself (stop is unconditional) is unchanged; docs only, no code change. - A launcher-started
ollama serveis now reaped when it exits, so stopping it from the interactive menu no longer stalls.Ollama.TryStartforked the daemon without ever callingwait, so in a long-lived launcher process (the menu) a stoppedollama servelingered as a zombie — and a zombie still satisfieskill(pid, 0), so a later stop treated the already-dead process as alive and burned the full SIGTERM window, SIGKILL, and port-release poll (~21 s) against it. The child is now reaped by acmd.Waitgoroutine, matching how managed llama-server children are reaped. One-shot CLI invocations were unaffected (the process exits before the zombie matters). - Two more stale doc claims now match the code. TDD §8's llamacpp flag table drifted from what
BuildServerArgsemits (rows re-verified against llama-server b10068): the Model is passed as--model(not-m);flash_attnemits-fa on/-fa offwhenever set, not a bare-fawhen true; thejinja→--jinjarow was missing; and the bogusmodels_dir→--models-dirrow is gone —models_diris launcher-side only (it joins relative Model paths, TDD §4.4; llama-server's own--models-diris an unrelated router-server option and is never emitted). ADR-0007 no longer claims/propssampling settings are diffable: live drift detection compares onlycontext_sizeandparallel, the fields llama-server actually reports (see the false-drift fix above). Docs only, no code change.
Security
- The MCP adapter now caps request bodies at 1 MiB and bounds full-request reads at 30 s. The listener's timeouts (added in 1.4.5) covered headers, idle keep-alives, and writes, but not the request body: the MCP streamable handler buffers the whole POST body in memory, so an allowlisted but hostile (e.g. prompt-injected) client could exhaust the adapter's memory with one huge POST, or hold a connection open indefinitely with a slow-drip body. Every request body now passes through
http.MaxBytesReader(1 MiB — control-plane calls are small JSON-RPC payloads) and the server setsReadTimeout30 s alongside the existing timeouts. - Server-reported strings are now sanitised before display, and the response-read cap is unified at 512 KB. The launcher parses HTTP responses from whatever answers on its configured local ports, and that data is untrusted. Server-reported model names were printed to the terminal raw (status output, the auto-refreshing menu header, the "Show model config" pop-up), and the display width logic deliberately passes ESC sequences through — so a hostile server squatting a port could smuggle ANSI/OSC escapes into a model id and spoof the screen or window title, or write the clipboard via OSC 52. Server-reported strings now have all control characters stripped (the C0 range including ESC, DEL, and the C1 range) plus the Unicode directional-formatting characters (the Trojan-Source class: U+061C, U+200E/U+200F, U+202A–U+202E, U+2066–U+2069, which can visually reorder or mask displayed text without any control byte) at the points they enter the launcher — discovery's
RunningInstance, the load path's live-model probe, and LM Studio's server-supplied error messages — so every display site is covered at once. The bounded body reads 1.4.5 introduced are kept but unified at a single 512 KB cap (1.4.5 used 8 KiB/1 MiB tiers); an oversized body is truncated at the cap and surfaces as a parse or discrimination error instead of an unbounded allocation. - MCP tool-argument validation is strengthened from a keyword blocklist to a positive allowlist — and now covers all five argument-taking tools. 1.4.5 rejected values that start with
-or exactly match a CLI subcommand keyword, on three tools (tail_log,stop_server,unload_model) — which still forwarded compound or metacharacter-laden values ("clean --all","$(reboot)") and leftload_profileandstart_serverentirely unvalidated. The adapter now vets every forwarded value against a positive allowlist before shelling out: atargetmust be exactly a known backend name (llamacpp,lmstudio,ollama) or ahost:portwith a valid port number; a profile name (unload_model,load_profile,start_server) is user-defined and cannot be pinned to a list — the adapter deliberately does not parse the config (ADR-0008) — so it is vetted against a conservative character allowlist plus the no-leading-dash rule, which blocks flags, extra words, and shell metacharacters; resolving the name stays with the CLI, which fails cleanly on an unknown profile. Rejected values come back as tool errors without the CLI ever being invoked (ADR-0008: the CLI's own argument grammar is not a security boundary).