diff --git a/AGENTS.md b/AGENTS.md
index a87e18bc..21d53f48 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,7 +18,7 @@ This is the source-of-truth for automated contributors (LLM agents, codegen, etc
3. **One inference per step.** No reasoning loops inside a single LLM call — the runtime drives the loop. A single inference always emits a JSON **array** of `1..N` tool calls (`[{tool, args}, ...]`); a "solo" step is just a length-1 array (`[{...}]`). `N` is capped by `agent.maxParallelToolCalls` (default 8, hard ceiling 16 in the grammar). See §"Parallel tool calls per step" for the rationale (GBNF first-token bias) and the executor pipeline.
4. **Grammar-constrained tool calls.** The sidecar sends a GBNF grammar with every completion request that must produce a tool call. The root collapsed to **array-only** (`root ::= tool-call-array`) so the model cannot fall into the single-object form via first-token bias even when it only needs one call. Reasoning-prelude profiles (`qwen-think`, `gemma4-think`) prepend a `...` / `<|channel>thought...` block to the array; the seam between the close sentinel and the leading `[` of the array routes through a dedicated **bounded** `prelude-trail-ws ::= ( [ \t\n\r] ){0,8}` rule rather than the global unbounded `ws`. This is the structural anti-degenerate-loop guard — small reasoning-capable models (Gemma 4 26B-A4B in particular) used to slide into a whitespace-only tail after a long reasoning block because the sampler could keep emitting newlines indefinitely. Pinned by [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts) "bounds the whitespace between the reasoning-close sentinel and the tool-call array".
- **Reasoning-open ownership differs by profile (`reasoningOpenEmittedByModel` in [src/llm/model-profile.ts](src/llm/model-profile.ts)).** `qwen-think` **prefills** its open tag at the end of the prompt (`### respond` → ``); the grammar prelude starts after the open tag (`think-prelude ::= think-body "" …`) and `step-executor` prepends the open tag back onto the completion before parsing. `gemma4-think` is the opposite: Gemma 4's QAT template treats a prefilled `<|channel>thought\n` as the *thinking-disabled* marker and immediately closes the channel, dumping its reasoning into `reply.text`. The fix is **native turn-framing** — the profile carries `turnFraming { systemOpen: "<|turn>system\n", turnClose: "\n", assistantOpen: "<|turn>model\n" }` and `reasoningEmittedByModel: true`. `buildStablePrefix` opens the prompt with `<|turn>system\n<|think|>\n### system…` (the `<|think|>` token must sit at the very top of a real system turn to activate the channel — a one-time gemma-only stable-prefix byte change / KV invalidation), `buildPrompt` ends the tail with `…### respond\nRespond now.\n\n\n<|turn>model\n` instead of a channel prefill, and the grammar prelude **includes the open sentinel** (`channel-prelude ::= "<|channel>thought\n" channel-body "" prelude-trail-ws`) so the model emits its own `<|channel>thought` block. `step-executor.normalizeContent` does **not** prepend the open tag for `reasoningEmittedByModel` profiles (it is already in `completion.content`), the stream parser runs with `preOpenedThink: false` so it detects the open tag live, and the repair prompt strips/re-appends `\n<|turn>model` rather than the bare open tag. `checkProfilePromptAligned` asserts a turn-framed prompt ends with the model-turn opener (not the open tag). Pinned by [src/agent/profile-matrix.test.ts](src/agent/profile-matrix.test.ts), [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts), [src/prompt/build-prompt.test.ts](src/prompt/build-prompt.test.ts), [src/llm/profile-invariants.test.ts](src/llm/profile-invariants.test.ts), [src/llm/grammar/stream-parser.test.ts](src/llm/grammar/stream-parser.test.ts).
+ **Reasoning-open ownership differs by profile (`reasoningOpenEmittedByModel` in [src/llm/model-profile.ts](src/llm/model-profile.ts)).** `qwen-think` **prefills** its open tag at the end of the prompt (`### respond` → ``); the grammar prelude starts after the open tag (`think-prelude ::= think-body "" …`) and `step-executor` prepends the open tag back onto the completion before parsing. Nemotron 3.5 Lightning shares that ownership exactly: its template ends generation at `<|im_start|>assistant\n\n`, so `selectBaseProfile` detects it separately (the alias hint differs) but returns `qwen-think` itself rather than a duplicate profile. `gemma4-think` is the opposite: Gemma 4's QAT template treats a prefilled `<|channel>thought\n` as the *thinking-disabled* marker and immediately closes the channel, dumping its reasoning into `reply.text`. The fix is **native turn-framing** — the profile carries `turnFraming { systemOpen: "<|turn>system\n", turnClose: "\n", assistantOpen: "<|turn>model\n" }` and `reasoningEmittedByModel: true`. `buildStablePrefix` opens the prompt with `<|turn>system\n<|think|>\n### system…` (the `<|think|>` token must sit at the very top of a real system turn to activate the channel — a one-time gemma-only stable-prefix byte change / KV invalidation), `buildPrompt` ends the tail with `…### respond\nRespond now.\n\n\n<|turn>model\n` instead of a channel prefill, and the grammar prelude **includes the open sentinel** (`channel-prelude ::= "<|channel>thought\n" channel-body "" prelude-trail-ws`) so the model emits its own `<|channel>thought` block. `step-executor.normalizeContent` does **not** prepend the open tag for `reasoningEmittedByModel` profiles (it is already in `completion.content`), the stream parser runs with `preOpenedThink: false` so it detects the open tag live, and the repair prompt strips/re-appends `\n<|turn>model` rather than the bare open tag. `checkProfilePromptAligned` asserts a turn-framed prompt ends with the model-turn opener (not the open tag). Pinned by [src/agent/profile-matrix.test.ts](src/agent/profile-matrix.test.ts), [src/llm/grammar/build-grammar.test.ts](src/llm/grammar/build-grammar.test.ts), [src/prompt/build-prompt.test.ts](src/prompt/build-prompt.test.ts), [src/llm/profile-invariants.test.ts](src/llm/profile-invariants.test.ts), [src/llm/grammar/stream-parser.test.ts](src/llm/grammar/stream-parser.test.ts).
5. **No global singletons.** Dependencies are passed explicitly. `getConfig()` is the only exception.
6. **Session is multi-turn chat only.** A session is a long-lived chat: `user message → 0..N tool steps → reply` is a macro-turn, multiple turns share one `SessionState.turns[]`. Two terminals exist — `reply` ends the turn, `finish` ends the whole session. All three frontends (CLI `run`, TUI, sidecar) go through `runtime.runTurn` only; there is no one-shot goal mode.
@@ -276,7 +276,7 @@ Symmetric **`EmbeddingProviderRegistry`** ([src/memory/embeddings/embedding-prov
`atomic-agent` supports two modes for the llama-server backend (`config.llama.mode`):
- `external` (default) — user runs `llama-server` out-of-band; runtime reads the URL from `config.llama.url` (env fallback `ATOMIC_AGENT_LLAMA_URL`).
-- `managed` — `atomic-agent` downloads the llama.cpp binary from `AtomicBot-ai/atomic-llama-cpp-turboquant` GitHub Releases into `/llamacpp/backend/` and GGUF models into `/llamacpp/models//`. The server is **not** spawned by the runtime; operators control lifecycle via `atomic-agent llama start|stop|status|update`.
+- `managed` — `atomic-agent` downloads the llama.cpp binary from `AtomicBot-ai/atomic-llama-cpp-turboquant-nightly` GitHub Releases into `/llamacpp/backend/` and GGUF models into `/llamacpp/models//`. The server is **not** spawned by the runtime; operators control lifecycle via `atomic-agent llama start|stop|status|update`. Managed start auto-pulls a newer zip when `localModels.managed.autoUpdate` is true (default since config v41). A failed check or download never blocks start — the existing binary is used. The two entry points differ deliberately: **TUI auto-start** brings the daemon up first and runs the update afterwards, off the start path, so the user never faces a typeable prompt with no model behind it; that pass also refuses to stop the live daemon (`keepDaemonRunning`), so the swap lands on the next start. **CLI `models start`** is an explicit one-shot command, so it still updates before starting. Both bound the download with a timeout.
**Invariant (preserved):** the agent runtime never starts a `llama-server` process. It only connects. Managed-mode lifecycle lives entirely in the `atomic-agent llama` CLI so runtime code paths stay single-mode.
diff --git a/README.md b/README.md
index 9118304b..b2cb2cde 100644
--- a/README.md
+++ b/README.md
@@ -165,7 +165,7 @@ The model chooses actions. Atomic Agent owns the loop, the state, the approvals,
### Built to Make Local Models Work
-We run local models on our own TurboQuant `llama.cpp` ([`AtomicBot-ai/atomic-llama-cpp-turboquant`](https://github.com/AtomicBot-ai/atomic-llama-cpp-turboquant)):
+We run local models on our own TurboQuant `llama.cpp` ([`AtomicBot-ai/atomic-llama-cpp-turboquant-nightly`](https://github.com/AtomicBot-ai/atomic-llama-cpp-turboquant-nightly)):
- **TurboQuant KV-cache:** WHT-rotated low-bit quantization compresses the KV-cache up to ~6.4× versus F16, with a fused Metal decode kernel, so long-context sessions fit in far less memory.
- **TurboQuant weights:** Lloyd-Max weight quantization with WHT rotation and fused Metal/Vulkan kernels keeps quality usable while small models fit on consumer hardware.
diff --git a/src/agent/profile-matrix.test.ts b/src/agent/profile-matrix.test.ts
index 823e9590..f53cf338 100644
--- a/src/agent/profile-matrix.test.ts
+++ b/src/agent/profile-matrix.test.ts
@@ -6,6 +6,7 @@ import {
GEMMA4_PROPS,
GPT_OSS_PROPS,
LLAMA3_PROPS,
+ NEMOTRON_PROPS,
QWEN3_PROPS,
} from "../llm/model-profile.fixtures.js";
import { startTestHarness } from "../http/test-harness.js";
@@ -36,6 +37,21 @@ describe("profile matrix", () => {
});
});
+ it("streams nemotron inline reasoning through the full agent loop", async () => {
+ // Nemotron shares the qwen think-tags profile (its alias is what needs a
+ // dedicated detector, not its behaviour): the prompt prefills ``,
+ // the stream starts mid-body and closes with `` before the array.
+ // Without the nemotron detection branch this props payload falls through
+ // to plain-instruct and emits no reasoning at all.
+ await expectScenario({
+ props: NEMOTRON_PROPS,
+ chunks: [
+ 'inner thought[{"tool":"reply","args":{"text":"ok"}}]',
+ ],
+ expectReasoning: true,
+ });
+ });
+
it("streams gemma 4 channel reasoning through the full agent loop", async () => {
// Turn-framed gemma: the model emits its OWN `<|channel>thought\n` opener
// (the prompt no longer prefills it), reasons, then closes with
diff --git a/src/cli/models-command.test.ts b/src/cli/models-command.test.ts
index ad44a087..f05edea7 100644
--- a/src/cli/models-command.test.ts
+++ b/src/cli/models-command.test.ts
@@ -53,10 +53,18 @@ describe("modelsCommand", () => {
const code = await modelsCommand(["list"]);
expect(code).toBe(0);
const out = stdout();
- // One row per curated model in LOCAL_MODELS_CATALOG: 4 gemma + 6 qwen.
- expect(out.split("\n").filter((l) => l.includes("qwen-") || l.includes("gemma-"))).toHaveLength(
- 10,
- );
+ // One row per curated model in LOCAL_MODELS_CATALOG.
+ expect(
+ out
+ .split("\n")
+ .filter(
+ (l) =>
+ l.includes("qwen-") ||
+ l.includes("gemma-") ||
+ l.includes("nemotron-") ||
+ l.includes("muse-"),
+ ),
+ ).toHaveLength(12);
});
it("pull with bad id exits 1", async () => {
diff --git a/src/cli/models-handlers.ts b/src/cli/models-handlers.ts
index 80be86d0..ef0f14cf 100644
--- a/src/cli/models-handlers.ts
+++ b/src/cli/models-handlers.ts
@@ -20,6 +20,7 @@ import {
isModelDownloaded,
listVulkanDevices,
LOCAL_MODELS_CATALOG,
+ maybeAutoUpdateBackend,
readBackendVersion,
removeModel,
resolveChatTemplatePath,
@@ -62,14 +63,14 @@ export async function runLocalModelsList(): Promise {
const cfg = getConfig();
const dataDir = cfg.paths.localModelsDataDir;
process.stdout.write(
- "ID | FAMILY | SIZE | CONTEXT | DL | ACTIVE\n",
+ "ID | FAMILY | SIZE | CONTEXT | DL | ACTIVE\n",
);
for (const m of LOCAL_MODELS_CATALOG) {
const dl = isModelDownloaded(dataDir, m) ? "yes" : "no";
const active =
cfg.localModels.managed.modelId === m.id && cfg.localModels.mode === "managed" ? "*" : " ";
process.stdout.write(
- `${m.id.padEnd(19)} | ${m.family.padEnd(6)} | ${m.sizeLabel.padEnd(6)} | ${m.contextLabel.padEnd(7)} | ${dl.padEnd(3)} | ${active}\n`,
+ `${m.id.padEnd(20)} | ${m.family.padEnd(8)} | ${m.sizeLabel.padEnd(6)} | ${m.contextLabel.padEnd(7)} | ${dl.padEnd(3)} | ${active}\n`,
);
}
return 0;
@@ -218,6 +219,54 @@ export async function runLocalModelsStart(): Promise {
return 1;
}
const dataDir = cfg.paths.localModelsDataDir;
+ try {
+ const auto = await maybeAutoUpdateBackend(dataDir, {
+ enabled: cfg.localModels.managed.autoUpdate,
+ // Unlike the TUI, `models start` is an explicit one-shot command:
+ // updating before the daemon comes up is what the operator asked
+ // for. It still needs a deadline — a stalled-open connection would
+ // otherwise pin the command forever with a progress bar at 12%.
+ signal: AbortSignal.timeout(BACKEND_DOWNLOAD_TIMEOUT_MS),
+ onProgress: (p: number, t: number, tot: number) => {
+ const line = renderPullProgress("backend zip", p, t, tot);
+ if (process.stderr.isTTY) process.stderr.write(`\r${line.padEnd(79)}`);
+ else if (p % 5 === 0 || p === 100) process.stderr.write(`${line}\n`);
+ },
+ });
+ if (auto.action === "updated") {
+ if (process.stderr.isTTY) process.stderr.write("\n");
+ process.stdout.write(
+ `backend: updated ${auto.from ?? "none"} → ${auto.to}\n`,
+ );
+ } else if (auto.action === "check_failed") {
+ process.stderr.write(
+ `note: backend update check failed — starting current binary (${auto.error})\n`,
+ );
+ } else if (auto.action === "deferred") {
+ process.stderr.write(
+ "note: backend update deferred — another session is using the current binary\n",
+ );
+ } else if (auto.action === "update_failed") {
+ if (process.stderr.isTTY) process.stderr.write("\n");
+ if (!auto.backendUsable) {
+ // The daemon was stopped for the update and there is no binary
+ // left to fall back to — nothing can be started.
+ process.stderr.write(
+ `backend auto-update failed and no usable backend remains: ${auto.error}\n` +
+ `run 'atomic-agent models update' once connectivity is back.\n`,
+ );
+ return 1;
+ }
+ process.stderr.write(
+ `note: backend update failed — starting current binary (${auto.error})\n`,
+ );
+ }
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ process.stderr.write(`backend auto-update failed: ${msg}\n`);
+ return 1;
+ }
+
const m = getLocalModelDef(mid);
const tpl = resolveChatTemplatePath(m) ?? undefined;
const mmprojFile =
@@ -337,6 +386,13 @@ function describeDeviceChoice(
return resolved ?? configured;
}
+/**
+ * Deadline for the backend asset download. The zip is 27-39 MB, so this
+ * is generous for any working link; it exists because a stalled-but-open
+ * TCP connection never resolves on its own.
+ */
+const BACKEND_DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
+
const DEVICE_ID_RE = /^[A-Za-z]+\d+$/;
/**
@@ -580,7 +636,14 @@ export async function runLocalModelsUpdate(): Promise {
try {
const { updateAvailable, latestTag, currentTag } = await checkForBackendUpdate(dataDir);
if (!updateAvailable) {
- process.stdout.write(`backend up to date (${latestTag})\n`);
+ // `latestTag` is null when no scanned release ships this
+ // platform's asset — nothing to compare against, so the install
+ // on disk stands.
+ process.stdout.write(
+ latestTag === null
+ ? `backend unchanged (no published release for this platform)\n`
+ : `backend up to date (${latestTag})\n`,
+ );
return 0;
}
process.stdout.write(`current: ${currentTag ?? "none"} → latest: ${latestTag}\n`);
diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts
index 5855300b..5b7db11f 100644
--- a/src/config/config-schema.test.ts
+++ b/src/config/config-schema.test.ts
@@ -562,6 +562,38 @@ describe("parseUserConfigFile", () => {
expect(parsed.localModels.managed.autoUpdate).toBe(true);
});
+ it("defaults localModels.managed.autoUpdate to true", () => {
+ const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION });
+ expect(parsed.localModels.managed.autoUpdate).toBe(true);
+ });
+
+ it("migrates a pre-v41 autoUpdate:false (unused default) to true", () => {
+ const parsed = parseUserConfigFile({
+ version: 37,
+ localModels: { managed: { autoUpdate: false } },
+ });
+ expect(parsed.localModels.managed.autoUpdate).toBe(true);
+ });
+
+ // The migration gate is the LAST version that stored the dead default.
+ // v40 is the boundary: it must still migrate, v41 must be honoured.
+ // Without this pair the gate can drift off USER_CONFIG_VERSION unnoticed.
+ it("migrates a v40 autoUpdate:false (still the unused default) to true", () => {
+ const parsed = parseUserConfigFile({
+ version: 40,
+ localModels: { managed: { autoUpdate: false } },
+ });
+ expect(parsed.localModels.managed.autoUpdate).toBe(true);
+ });
+
+ it("preserves an explicit localModels.managed.autoUpdate=false on v41+", () => {
+ const parsed = parseUserConfigFile({
+ version: USER_CONFIG_VERSION,
+ localModels: { managed: { autoUpdate: false } },
+ });
+ expect(parsed.localModels.managed.autoUpdate).toBe(false);
+ });
+
it("preserves an explicit localModels.managed.device override", () => {
const parsed = parseUserConfigFile({
version: USER_CONFIG_VERSION,
diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts
index 1a2d602f..cdf51614 100644
--- a/src/config/config-schema.ts
+++ b/src/config/config-schema.ts
@@ -894,6 +894,14 @@ export interface UserManagedLocalLlmConfig {
modelId: string | null;
port: number;
dataDirOverride: string | null;
+ /**
+ * When true, managed-mode start (TUI auto-start / `s`, CLI
+ * `models start`) checks GitHub Releases and replaces the llama.cpp
+ * zip if a newer tag (or a stale Windows variant) is available.
+ * Default `true` since config v41. Older files stored an unused
+ * `false` default — those migrate to `true`. Set `false` to pin the
+ * installed backend.
+ */
autoUpdate: boolean;
/**
* Compute-device preference for the managed llama.cpp daemon:
@@ -1495,7 +1503,12 @@ export interface UserConfigFile {
// v40: new `tui.mouse` flag gating the mouse layer. Defaults to true, so an
// older file inherits mouse support on upgrade; `--no-mouse` and `/mouse off`
// override it without rewriting the file.
-export const USER_CONFIG_VERSION = 40 as const;
+// v41: `localModels.managed.autoUpdate` is wired to managed start
+// (TUI auto-start / CLI `models start`) and defaults to `true`. Pre-v41
+// files stored an unused `false` default — those migrate to `true` so
+// existing installs pick up newer llama.cpp zips. Explicit `false` on
+// a v41+ file is honoured.
+export const USER_CONFIG_VERSION = 41 as const;
/**
* Config v21+ flips the full memory-v2 fabric on by default. Upgrades
@@ -1505,6 +1518,9 @@ export const USER_CONFIG_VERSION = 40 as const;
*/
const MEMORY_V2_OPT_IN_DEFAULTS_VERSION = 22;
+/** Pre-v41 `autoUpdate: false` was a dead default; force the live default. */
+const MANAGED_AUTO_UPDATE_DEFAULTS_VERSION = 41;
+
export type RewriterGateMode = "heuristic" | "embedding" | "always";
/**
@@ -1574,6 +1590,12 @@ export type RewriterGateMode = "heuristic" | "embedding" | "always";
* v32→v33 added the optional `analytics.*` block (anonymous PostHog
* product analytics — opt-out via `analytics.enabled: false`). Older
* files inherit `analytics: { enabled: true }` transparently.
+ * v40→v41 wired `localModels.managed.autoUpdate` (default `true`):
+ * managed start pulls a newer llama.cpp zip from GitHub Releases when
+ * one exists. Pre-v41 the field was stored but never read, so a `false`
+ * there carried no meaning and is migrated to `true` — including one a
+ * user set deliberately, since the two are indistinguishable on disk.
+ * An explicit `false` on a v41+ file is honoured.
* Older files are transparently upgraded by filling missing
* blocks/fields from `USER_CONFIG_DEFAULTS`. Anything older than v5
* is not migrated: this is active development, callers delete their
@@ -1615,6 +1637,7 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [
37,
38,
39,
+ 40,
USER_CONFIG_VERSION,
];
@@ -1628,7 +1651,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = {
modelId: null,
port: 19091,
dataDirOverride: null,
- autoUpdate: false,
+ autoUpdate: true,
stopOnExit: true,
device: "auto",
contextSize: 0,
@@ -2176,6 +2199,16 @@ function parseMemoryV2FeatureEnabled(
return parseBool(raw ?? defaultEnabled, field);
}
+function resolveManagedAutoUpdate(inputVersion: number, raw: unknown): boolean {
+ if (inputVersion < MANAGED_AUTO_UPDATE_DEFAULTS_VERSION) {
+ return true;
+ }
+ return parseBool(
+ raw ?? USER_CONFIG_DEFAULTS.localModels.managed.autoUpdate,
+ "localModels.managed.autoUpdate",
+ );
+}
+
function resolveEmbeddingModelId(
_inputVersion: number,
raw: unknown,
@@ -2849,10 +2882,7 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile {
rawManaged.dataDirOverride,
"localModels.managed.dataDirOverride",
),
- autoUpdate: parseBool(
- rawManaged.autoUpdate ?? USER_CONFIG_DEFAULTS.localModels.managed.autoUpdate,
- "localModels.managed.autoUpdate",
- ),
+ autoUpdate: resolveManagedAutoUpdate(version, rawManaged.autoUpdate),
stopOnExit: parseBool(
rawManaged.stopOnExit ??
USER_CONFIG_DEFAULTS.localModels.managed.stopOnExit,
diff --git a/src/llm/model-profile.fixtures.ts b/src/llm/model-profile.fixtures.ts
index d1403a09..f12c5e08 100644
--- a/src/llm/model-profile.fixtures.ts
+++ b/src/llm/model-profile.fixtures.ts
@@ -444,3 +444,270 @@ export const GEMMA4_PROPS = {
supports_preserve_reasoning: true,
},
};
+
+export const NEMOTRON_PROPS = {
+ model_alias: "nvidia-nemotron-3.5-lightning-30b-a3b",
+ chat_template: `{% macro render_extra_keys(json_dict, handled_keys) %}
+ {%- if json_dict is mapping %}
+ {%- for json_key in json_dict if json_key not in handled_keys %}
+ {%- if json_dict[json_key] is mapping or (json_dict[json_key] is sequence and json_dict[json_key] is not string) %}
+ {{- '\\n<' ~ json_key ~ '>' ~ (json_dict[json_key] | tojson | safe) ~ '' ~ json_key ~ '>' }}
+ {%- else %}
+ {{-'\\n<' ~ json_key ~ '>' ~ (json_dict[json_key] | string) ~ '' ~ json_key ~ '>' }}
+ {%- endif %}
+ {%- endfor %}
+ {%- endif %}
+{% endmacro %}
+{%- set enable_thinking = enable_thinking if enable_thinking is defined else True %}
+{%- set truncate_history_thinking = truncate_history_thinking if truncate_history_thinking is defined else True %}
+{%- set ns = namespace(last_user_idx = -1) %}
+{%- set loop_messages = messages %}
+{%- for m in loop_messages %}
+ {%- if m["role"] == "user" %}
+ {%- set ns.last_user_idx = loop.index0 %}
+ {%- endif %}
+{%- endfor %}
+{%- if messages[0]["role"] == "system" %}
+ {%- set system_message = messages[0]["content"] %}
+ {%- set loop_messages = messages[1:] %}
+{%- else %}
+ {%- set system_message = "" %}
+ {%- set loop_messages = messages %}
+{%- endif %}
+{%- if not tools is defined %}
+ {%- set tools = [] %}
+{%- endif %}
+{%- set ns = namespace(last_user_idx = -1) %}
+{%- for m in loop_messages %}
+ {%- if m["role"] == "user" %}
+ {%- set ns.last_user_idx = loop.index0 %}
+ {%- endif %}
+{%- endfor %}
+{%- if system_message is defined %}
+ {{- "<|im_start|>system\\n" + system_message }}
+{%- else %}
+ {%- if tools is iterable and tools | length > 0 %}
+ {{- "<|im_start|>system\\n" }}
+ {%- endif %}
+{%- endif %}
+{%- if tools is iterable and tools | length > 0 %}
+ {%- if system_message is defined and system_message | length > 0 %}
+ {{- "\\n\\n" }}
+ {%- endif %}
+ {{- "# Tools\\n\\nYou have access to the following functions:\\n\\n" }}
+ {{- "" }}
+ {%- for tool in tools %}
+ {%- if tool.function is defined %}
+ {%- set tool = tool.function %}
+ {%- endif %}
+ {{- "\\n\\n" ~ tool.name ~ "" }}
+ {%- if tool.description is defined %}
+ {{- '\\n' ~ (tool.description | trim) ~ '' }}
+ {%- endif %}
+ {{- '\\n' }}
+ {%- if tool.parameters is defined and tool.parameters is mapping and tool.parameters.properties is defined and tool.parameters.properties is mapping %}
+ {%- for param_name, param_fields in tool.parameters.properties|items %}
+ {{- '\\n' }}
+ {{- '\\n' ~ param_name ~ '' }}
+ {%- if param_fields.type is defined %}
+ {{- '\\n' ~ (param_fields.type | string) ~ '' }}
+ {%- endif %}
+ {%- if param_fields.description is defined %}
+ {{- '\\n' ~ (param_fields.description | trim) ~ '' }}
+ {%- endif %}
+ {%- if param_fields.enum is defined %}
+ {{- '\\n' ~ (param_fields.enum | tojson | safe) ~ '' }}
+ {%- endif %}
+ {%- set handled_keys = ['name', 'type', 'description', 'enum'] %}
+ {{- render_extra_keys(param_fields, handled_keys) }}
+ {{- '\\n' }}
+ {%- endfor %}
+ {%- endif %}
+ {% set handled_keys = ['type', 'properties', 'required'] %}
+ {{- render_extra_keys(tool.parameters, handled_keys) }}
+ {%- if tool.parameters is defined and tool.parameters.required is defined %}
+ {{- '\\n' ~ (tool.parameters.required | tojson | safe) ~ '' }}
+ {%- endif %}
+ {{- '\\n' }}
+ {%- set handled_keys = ['type', 'name', 'description', 'parameters'] %}
+ {{- render_extra_keys(tool, handled_keys) }}
+ {{- '\\n' }}
+ {%- endfor %}
+ {{- "\\n" }}
+ {{- '\\n\\nIf you choose to call a function ONLY reply in the following format with NO suffix:\\n\\n\\n\\n\\nvalue_1\\n\\n\\nThis is the value for the second parameter\\nthat can span\\nmultiple lines\\n\\n\\n\\n\\n\\nReminder:\\n- Function calls MUST follow the specified format: an inner block must be nested within XML tags\\n- Required parameters MUST be specified\\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\\n' }}
+{%- endif %}
+{%- if system_message is defined %}
+ {{- '<|im_end|>\\n' }}
+{%- else %}
+ {%- if tools is iterable and tools | length > 0 %}
+ {{- '<|im_end|>\\n' }}
+ {%- endif %}
+{%- endif %}
+{%- for message in loop_messages %}
+ {%- if message.role == "assistant" %}
+ {%- if message.reasoning_content is defined and message.reasoning_content is string and message.reasoning_content | trim | length > 0 %}
+ {%- set content = "\\n" ~ message.reasoning_content ~ "" ~ (message.content | default('', true)) %}
+ {%- else %}
+ {%- set content = message.content | default('', true) %}
+ {%- if content is string -%}
+ {%- if '' not in content and '' not in content -%}
+ {%- set content = "" ~ content -%}
+ {%- endif -%}
+ {%- else -%}
+ {%- set content = content -%}
+ {%- endif -%}
+ {%- endif %}
+ {%- if message.tool_calls is defined and message.tool_calls is iterable and message.tool_calls | length > 0 %}
+ {{- '<|im_start|>assistant\\n' }}
+ {%- set include_content = not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %}
+ {%- if content is string and content | trim | length > 0 %}
+ {%- if include_content %}
+ {{- (content | trim) ~ '\\n' -}}
+ {%- else %}
+ {%- set c = (content | string) %}
+ {%- if '' in c %}
+ {%- set c = c.split('')[-1] %}
+ {%- elif '' in c %}
+ {%- set c = c.split('')[0] %}
+ {%- endif %}
+ {%- set c = "" ~ c %}
+ {%- if c | length > 0 %}
+ {{- c ~ '\\n' -}}
+ {%- endif %}
+ {%- endif %}
+ {%- else %}
+ {{- "" -}}
+ {%- endif %}
+ {%- for tool_call in message.tool_calls %}
+ {%- if tool_call.function is defined %}
+ {%- set tool_call = tool_call.function %}
+ {%- endif %}
+ {{- '\\n\\n' -}}
+ {%- if tool_call.arguments is defined %}
+ {%- for args_name, args_value in tool_call.arguments|items %}
+ {{- '\\n' -}}
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
+ {{- args_value ~ '\\n\\n' -}}
+ {%- endfor %}
+ {%- endif %}
+ {{- '\\n\\n' -}}
+ {%- endfor %}
+ {{- '<|im_end|>\\n' }}
+ {%- else %}
+ {%- if not (truncate_history_thinking and loop.index0 < ns.last_user_idx) %}
+ {{- '<|im_start|>assistant\\n' ~ (content | default('', true) | string | trim) ~ '<|im_end|>\\n' }}
+ {%- else %}
+ {%- set c = (content | default('', true) | string) %}
+ {%- if '' in c and '' in c %}
+ {%- set c = "" ~ c.split('')[-1] %}
+ {%- endif %}
+ {%- set c = c | trim %}
+ {%- if c | length > 0 %}
+ {{- '<|im_start|>assistant\\n' ~ c ~ '<|im_end|>\\n' }}
+ {%- else %}
+ {{- '<|im_start|>assistant\\n<|im_end|>\\n' }}
+ {%- endif %}
+ {%- endif %}
+ {%- endif %}
+ {%- elif message.role == "user" or message.role == "system" %}
+ {{- '<|im_start|>' + message.role + '\\n' }}
+ {%- set content = message.content | string %}
+ {{- content }}
+ {{- '<|im_end|>\\n' }}
+ {%- elif message.role == "tool" %}
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
+ {{- '<|im_start|>user\\n' }}
+ {%- endif %}
+ {{- '\\n' }}
+ {{- message.content }}
+ {{- '\\n\\n' }}
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
+ {{- '<|im_end|>\\n' }}
+ {%- elif loop.last %}
+ {{- '<|im_end|>\\n' }}
+ {%- endif %}
+ {%- else %}
+ {{- '<|im_start|>' + message.role + '\\n' + message.content + '<|im_end|>\\n' }}
+ {%- endif %}
+{%- endfor %}
+{%- if add_generation_prompt %}
+ {%- if enable_thinking %}
+ {{- '<|im_start|>assistant\\n\\n' }}
+ {%- else %}
+ {{- '<|im_start|>assistant\\n' }}
+ {%- endif %}
+{%- endif %}`,
+ chat_template_caps: {
+ supports_preserve_reasoning: true,
+ },
+};
+
+/**
+ * Meta Muse Glimmer 30B as llama-server reports it. The alias is the
+ * catalog id verbatim — `daemon-lifecycle.ts` passes `model.id` to `-a`.
+ *
+ * The template is Harmony/ATEM channel framing, deliberately kept rich
+ * rather than stubbed: it carries `<|channel|>analysis` reasoning markers
+ * and native `<|start|>`/`<|end|>` tool framing. That is the point of the
+ * fixture — detection still falls through to `plain-instruct`, and it does
+ * so because the alias `muse-glimmer-30b` matches no hint in
+ * `selectBaseProfile` (not because the template is empty). A stub template
+ * would pass the same assertion for the wrong reason.
+ */
+export const MUSE_PROPS = {
+ model_alias: "muse-glimmer-30b",
+ chat_template: `{%- if messages[0].role == 'system' %}
+ {{- '<|start|>system<|message|>' + messages[0].content + '<|end|>' }}
+ {%- set loop_messages = messages[1:] %}
+{%- else %}
+ {%- set loop_messages = messages %}
+{%- endif %}
+{%- if tools is defined and tools | length > 0 %}
+ {{- '<|start|>developer<|message|># Tools\\n\\n' }}
+ {{- '## functions\\n\\nnamespace functions {\\n\\n' }}
+ {%- for tool in tools %}
+ {%- if tool.function is defined %}
+ {%- set tool = tool.function %}
+ {%- endif %}
+ {%- if tool.description is defined %}
+ {{- '// ' ~ (tool.description | trim) ~ '\\n' }}
+ {%- endif %}
+ {{- 'type ' ~ tool.name ~ ' = (_: ' }}
+ {{- (tool.parameters | tojson | safe) ~ ') => any;\\n\\n' }}
+ {%- endfor %}
+ {{- '} // namespace functions<|end|>' }}
+{%- endif %}
+{%- for message in loop_messages %}
+ {%- if message.role == 'assistant' %}
+ {%- if message.tool_calls is defined and message.tool_calls %}
+ {%- for tool_call in message.tool_calls %}
+ {%- if tool_call.function is defined %}
+ {%- set tool_call = tool_call.function %}
+ {%- endif %}
+ {{- '<|start|>assistant to=functions.' ~ tool_call.name }}
+ {{- '<|channel|>commentary json<|message|>' }}
+ {{- (tool_call.arguments | tojson | safe) ~ '<|call|>' }}
+ {%- endfor %}
+ {%- else %}
+ {%- set content = message.content | default('', true) | string %}
+ {%- if '<|channel|>analysis<|message|>' in content %}
+ {%- set content = content.split('<|end|>')[-1] %}
+ {%- endif %}
+ {{- '<|start|>assistant<|channel|>final<|message|>' }}
+ {{- content | trim ~ '<|end|>' }}
+ {%- endif %}
+ {%- elif message.role == 'tool' %}
+ {{- '<|start|>functions.' ~ message.name ~ ' to=assistant' }}
+ {{- '<|channel|>commentary<|message|>' ~ message.content ~ '<|end|>' }}
+ {%- else %}
+ {{- '<|start|>' ~ message.role ~ '<|message|>' }}
+ {{- (message.content | string) ~ '<|end|>' }}
+ {%- endif %}
+{%- endfor %}
+{%- if add_generation_prompt %}
+ {{- '<|start|>assistant<|channel|>analysis<|message|>' }}
+{%- endif %}`,
+ chat_template_caps: {
+ supports_preserve_reasoning: true,
+ },
+};
diff --git a/src/llm/model-profile.test.ts b/src/llm/model-profile.test.ts
index 172306d2..04210571 100644
--- a/src/llm/model-profile.test.ts
+++ b/src/llm/model-profile.test.ts
@@ -12,6 +12,7 @@ import {
GEMMA4_PROPS,
GPT_OSS_PROPS,
LLAMA3_PROPS,
+ NEMOTRON_PROPS,
QWEN3_PROPS,
} from "./model-profile.fixtures.js";
@@ -49,6 +50,47 @@ describe("detectModelProfile", () => {
expect(detectModelProfile(GEMMA4_PROPS)).toEqual(GEMMA4_THINK_PROFILE);
});
+ // Nemotron has no profile of its own: its ChatML template is qwen-shaped,
+ // so the dedicated detector deliberately maps onto QWEN_THINK_PROFILE. The
+ // contract under test is that the Nemotron template yields the think-tags
+ // reasoning profile at all — deleting the branch drops it to plain-instruct
+ // and silently kills the reasoning channel.
+ it("maps the nemotron ChatML + enable_thinking template onto the think-tags profile", () => {
+ const profile = detectModelProfile(NEMOTRON_PROPS);
+ expect(profile).toEqual(QWEN_THINK_PROFILE);
+ expect(profile.reasoningStyle).toBe("think-tags");
+ });
+
+ // Pins the alias gate: the Nemotron detector must require a `nemotron`
+ // alias, not fire on the template markers alone. An alias carrying none of
+ // the qwen/qwq/deepseek-r1/nemotron hints must fall through to plain even
+ // though the template is a full ChatML + + enable_thinking match.
+ it("requires a nemotron alias — template markers alone do not classify", () => {
+ expect(
+ detectModelProfile({
+ ...NEMOTRON_PROPS,
+ model_alias: "some-other-chatml-think-model",
+ }),
+ ).toEqual(PLAIN_INSTRUCT_PROFILE);
+ });
+
+ // Pins branch ordering. This alias satisfies BOTH gates (it contains
+ // "qwen" and "nemotron"), which is the only input where the order of the
+ // two branches is observable: whichever runs first decides. The qwen
+ // branch runs first, so the qwen gate must win. Both branches currently
+ // yield QWEN_THINK_PROFILE, so this is pinned on the gate that fired
+ // rather than on the returned object.
+ it("lets the qwen gate win when an alias matches both the qwen and nemotron hints", () => {
+ const alias = "qwen-nemotron-hybrid-think";
+ // Guard: the alias really does trip both gates, so the assertion below
+ // is about ordering and not about one gate quietly failing to match.
+ expect(alias).toContain("qwen");
+ expect(alias).toContain("nemotron");
+ expect(detectModelProfile({ ...NEMOTRON_PROPS, model_alias: alias })).toEqual(
+ QWEN_THINK_PROFILE,
+ );
+ });
+
it("falls back to plain profile for gpt-oss style templates", () => {
expect(detectModelProfile(GPT_OSS_PROPS)).toEqual(PLAIN_INSTRUCT_PROFILE);
});
diff --git a/src/llm/model-profile.ts b/src/llm/model-profile.ts
index 1829f6ac..aa3059a7 100644
--- a/src/llm/model-profile.ts
+++ b/src/llm/model-profile.ts
@@ -225,6 +225,16 @@ function selectBaseProfile(
if (looksLikeQwenThinkModel(modelAlias, templateLower, supportsPreserveReasoning)) {
return QWEN_THINK_PROFILE;
}
+ // Nemotron needs its own detector but not its own profile. Its ChatML
+ // template is qwen-shaped — ``/`` prefilled at the
+ // generation point, same open/close ownership, no turn framing — so the
+ // runtime behaviour is byte-for-byte `QWEN_THINK_PROFILE`. Only the alias
+ // gate differs: `looksLikeQwenThinkModel` requires a qwen/qwq/deepseek-r1
+ // alias, which Nemotron's does not match, so without this branch it would
+ // fall through to `plain-instruct` and lose its reasoning channel.
+ if (looksLikeNemotronThinkModel(modelAlias, templateLower)) {
+ return QWEN_THINK_PROFILE;
+ }
if (looksLikeGemma4ThinkModel(modelAlias, templateLower)) {
return GEMMA4_THINK_PROFILE;
}
@@ -290,6 +300,17 @@ function looksLikeQwenThinkModel(
return aliasHint && templateHint;
}
+function looksLikeNemotronThinkModel(
+ modelAlias: string,
+ templateLower: string,
+): boolean {
+ const aliasHint = modelAlias.includes("nemotron");
+ const templateHint =
+ templateLower.includes("") &&
+ templateLower.includes("enable_thinking");
+ return aliasHint && templateHint;
+}
+
function looksLikeGemma4ThinkModel(modelAlias: string, templateLower: string): boolean {
const aliasHint = modelAlias.includes("gemma");
const templateHint =
diff --git a/src/local-llm/backend-installer.test.ts b/src/local-llm/backend-installer.test.ts
index 0f63e357..6a6822c7 100644
--- a/src/local-llm/backend-installer.test.ts
+++ b/src/local-llm/backend-installer.test.ts
@@ -1,13 +1,51 @@
-import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import JSZip from "jszip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { downloadBackend, isBackendDownloaded } from "./backend-installer.js";
+import {
+ checkForBackendUpdate,
+ downloadBackend,
+ isBackendDownloaded,
+ resetLatestReleaseCache,
+} from "./backend-installer.js";
import { resolveServerBinPath } from "./backend-paths.js";
-import { readBackendVersion } from "./backend-version.js";
+import { readBackendVersion, writeBackendVersion } from "./backend-version.js";
+
+/** Minimal GitHub releases-list payload for the macOS arm64 asset. */
+function releasesResponse(
+ releases: Array<{
+ tag: string;
+ url?: string;
+ publishedAt?: string | null;
+ assetName?: string;
+ }>,
+): Response {
+ return new Response(
+ JSON.stringify(
+ releases.map((r) => ({
+ tag_name: r.tag,
+ published_at: r.publishedAt === undefined ? null : r.publishedAt,
+ assets: [
+ {
+ name: r.assetName ?? "llama-turboquant-macos-arm64.zip",
+ browser_download_url: r.url ?? "https://example.com/asset.zip",
+ },
+ ],
+ })),
+ ),
+ { status: 200, headers: { "content-type": "application/json" } },
+ );
+}
describe("backend-installer", () => {
let dir: string;
@@ -16,10 +54,12 @@ describe("backend-installer", () => {
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "local-llm-be-"));
prevFetch = globalThis.fetch;
+ resetLatestReleaseCache();
});
afterEach(() => {
globalThis.fetch = prevFetch;
+ resetLatestReleaseCache();
rmSync(dir, { recursive: true, force: true });
});
@@ -180,6 +220,323 @@ describe("backend-installer", () => {
}
});
+ it("keeps the working install when the download fails mid-flight", async () => {
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64");
+ // Pre-existing, working install.
+ const backendDir = join(dir, "backend");
+ mkdirSync(backendDir, { recursive: true });
+ writeFileSync(join(backendDir, "llama-server"), "#!/bin/sh\necho old\n", {
+ mode: 0o755,
+ });
+ writeBackendVersion(dir, {
+ tag: "turboquant-old",
+ downloadedAt: "2026-01-01T00:00:00.000Z",
+ asset: "llama-turboquant-macos-arm64.zip",
+ });
+
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
+ const u = String(url);
+ if (u.includes("/releases")) {
+ return releasesResponse([
+ { tag: "turboquant-new", publishedAt: "2026-02-01T00:00:00Z" },
+ ]);
+ }
+ // Asset download dies part-way through, as a dropped connection does.
+ throw new Error("socket hang up");
+ }) as typeof fetch;
+
+ try {
+ await expect(downloadBackend(dir)).rejects.toThrow(/socket hang up/);
+
+ const binPath = resolveServerBinPath(dir, "llama-server");
+ expect(existsSync(binPath)).toBe(true);
+ expect(readFileSync(binPath, "utf-8").includes("echo old")).toBe(true);
+ expect(isBackendDownloaded(dir)).toBe(true);
+ // The version record must still describe the install that is live.
+ expect(readBackendVersion(dir)?.tag).toBe("turboquant-old");
+ // No staging leftovers.
+ expect(existsSync(`${join(dir, "backend")}.next`)).toBe(false);
+ expect(existsSync(`${join(dir, "backend")}.old`)).toBe(false);
+ } finally {
+ platformSpy.mockRestore();
+ archSpy.mockRestore();
+ }
+ });
+
+ it("keeps the working install when the archive has no server binary", async () => {
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64");
+ const backendDir = join(dir, "backend");
+ mkdirSync(backendDir, { recursive: true });
+ writeFileSync(join(backendDir, "llama-server"), "#!/bin/sh\necho old\n", {
+ mode: 0o755,
+ });
+
+ // Well-formed zip, but it ships the wrong payload — the corrupt /
+ // mis-built release case.
+ const zip = new JSZip();
+ zip.file("release-root/README.md", Buffer.from("no binary here"));
+ const zipBuf = await zip.generateAsync({ type: "nodebuffer" });
+
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
+ const u = String(url);
+ if (u.includes("/releases")) {
+ return releasesResponse([
+ { tag: "turboquant-broken", publishedAt: "2026-02-01T00:00:00Z" },
+ ]);
+ }
+ return new Response(zipBuf, {
+ status: 200,
+ headers: { "content-length": String(zipBuf.length) },
+ });
+ }) as typeof fetch;
+
+ try {
+ await expect(downloadBackend(dir)).rejects.toThrow(/not found after extract/);
+ const binPath = resolveServerBinPath(dir, "llama-server");
+ expect(readFileSync(binPath, "utf-8").includes("echo old")).toBe(true);
+ expect(existsSync(`${join(dir, "backend")}.next`)).toBe(false);
+ } finally {
+ platformSpy.mockRestore();
+ archSpy.mockRestore();
+ }
+ });
+
+ it("replaces a stale staging dir left by a previous crash", async () => {
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64");
+ // Crash leftovers: a half-extracted `.next` carrying a foreign
+ // wrapper dir that would poison the flatten step, and a `.old`.
+ const stagingDir = join(dir, "backend.next");
+ mkdirSync(join(stagingDir, "build", "bin"), { recursive: true });
+ writeFileSync(join(stagingDir, "build", "bin", "llama-server"), "stale");
+ mkdirSync(join(dir, "backend.old"), { recursive: true });
+
+ const zip = new JSZip();
+ zip.file("llama-server", Buffer.from("#!/bin/sh\necho fresh\n"));
+ const zipBuf = await zip.generateAsync({ type: "nodebuffer" });
+
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
+ const u = String(url);
+ if (u.includes("/releases")) {
+ return releasesResponse([
+ { tag: "turboquant-fresh", publishedAt: "2026-02-01T00:00:00Z" },
+ ]);
+ }
+ return new Response(zipBuf, {
+ status: 200,
+ headers: { "content-length": String(zipBuf.length) },
+ });
+ }) as typeof fetch;
+
+ try {
+ await downloadBackend(dir);
+ const binPath = resolveServerBinPath(dir, "llama-server");
+ expect(readFileSync(binPath, "utf-8").includes("echo fresh")).toBe(true);
+ expect(existsSync(join(dir, "backend", "build"))).toBe(false);
+ expect(existsSync(stagingDir)).toBe(false);
+ expect(existsSync(join(dir, "backend.old"))).toBe(false);
+ } finally {
+ platformSpy.mockRestore();
+ archSpy.mockRestore();
+ }
+ });
+
+ it("records the release timestamp so later checks can order against it", async () => {
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64");
+ const zip = new JSZip();
+ zip.file("llama-server", Buffer.from("#!/bin/sh\necho ok\n"));
+ const zipBuf = await zip.generateAsync({ type: "nodebuffer" });
+
+ globalThis.fetch = vi.fn(async (url: string | URL) => {
+ const u = String(url);
+ if (u.includes("/releases")) {
+ return releasesResponse([
+ { tag: "turboquant-dated", publishedAt: "2026-02-03T04:05:06Z" },
+ ]);
+ }
+ return new Response(zipBuf, {
+ status: 200,
+ headers: { "content-length": String(zipBuf.length) },
+ });
+ }) as typeof fetch;
+
+ try {
+ await downloadBackend(dir);
+ expect(readBackendVersion(dir)?.releasedAt).toBe("2026-02-03T04:05:06Z");
+ } finally {
+ platformSpy.mockRestore();
+ archSpy.mockRestore();
+ }
+ });
+
+ it("does not downgrade when a re-published older tag heads the list", async () => {
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64");
+ writeBackendVersion(dir, {
+ tag: "turboquant-june",
+ downloadedAt: "2026-06-02T00:00:00.000Z",
+ asset: "llama-turboquant-macos-arm64.zip",
+ releasedAt: "2026-06-01T00:00:00Z",
+ });
+ // A maintainer re-published the old January release, so GitHub's
+ // created_at ordering puts it first. Its own timestamp is still older.
+ globalThis.fetch = vi.fn(async () =>
+ releasesResponse([
+ { tag: "turboquant-january", publishedAt: "2026-01-01T00:00:00Z" },
+ { tag: "turboquant-june", publishedAt: "2026-06-01T00:00:00Z" },
+ ]),
+ ) as typeof fetch;
+
+ try {
+ const check = await checkForBackendUpdate(dir);
+ expect(check.updateAvailable).toBe(false);
+ expect(check.latestTag).toBe("turboquant-june");
+ expect(check.currentTag).toBe("turboquant-june");
+ } finally {
+ platformSpy.mockRestore();
+ archSpy.mockRestore();
+ }
+ });
+
+ it("does not downgrade when the newest available release predates the install", async () => {
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64");
+ writeBackendVersion(dir, {
+ tag: "turboquant-june",
+ downloadedAt: "2026-06-02T00:00:00.000Z",
+ asset: "llama-turboquant-macos-arm64.zip",
+ releasedAt: "2026-06-01T00:00:00Z",
+ });
+ // The June release was deleted from the repo; the newest one still
+ // listed is older than what this machine already runs.
+ globalThis.fetch = vi.fn(async () =>
+ releasesResponse([
+ { tag: "turboquant-may", publishedAt: "2026-05-01T00:00:00Z" },
+ { tag: "turboquant-april", publishedAt: "2026-04-01T00:00:00Z" },
+ ]),
+ ) as typeof fetch;
+
+ try {
+ const check = await checkForBackendUpdate(dir);
+ expect(check.updateAvailable).toBe(false);
+ } finally {
+ platformSpy.mockRestore();
+ archSpy.mockRestore();
+ }
+ });
+
+ it("still updates when the resolved release is genuinely newer", async () => {
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64");
+ writeBackendVersion(dir, {
+ tag: "turboquant-june",
+ downloadedAt: "2026-06-02T00:00:00.000Z",
+ asset: "llama-turboquant-macos-arm64.zip",
+ releasedAt: "2026-06-01T00:00:00Z",
+ });
+ globalThis.fetch = vi.fn(async () =>
+ releasesResponse([
+ { tag: "turboquant-july", publishedAt: "2026-07-01T00:00:00Z" },
+ { tag: "turboquant-june", publishedAt: "2026-06-01T00:00:00Z" },
+ ]),
+ ) as typeof fetch;
+
+ try {
+ const check = await checkForBackendUpdate(dir);
+ expect(check.updateAvailable).toBe(true);
+ expect(check.latestTag).toBe("turboquant-july");
+ } finally {
+ platformSpy.mockRestore();
+ archSpy.mockRestore();
+ }
+ });
+
+ it("updates on a variant change even though the tag is unchanged", async () => {
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
+ const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("x64");
+ // Installed the Vulkan build; the machine now warrants CUDA. Same
+ // tag, same timestamp — recency must not veto the variant re-pull.
+ writeBackendVersion(dir, {
+ tag: "turboquant-win",
+ downloadedAt: "2026-06-02T00:00:00.000Z",
+ asset: "llama-turboquant-windows-x64-cuda-13.3.zip",
+ releasedAt: "2026-06-01T00:00:00Z",
+ });
+ globalThis.fetch = vi.fn(async () =>
+ releasesResponse([
+ {
+ tag: "turboquant-win",
+ publishedAt: "2026-06-01T00:00:00Z",
+ assetName: "llama-turboquant-windows-x64-vulkan.zip",
+ },
+ ]),
+ ) as typeof fetch;
+
+ try {
+ const check = await checkForBackendUpdate(dir);
+ expect(check.updateAvailable).toBe(true);
+ } finally {
+ platformSpy.mockRestore();
+ archSpy.mockRestore();
+ }
+ });
+
+ it("treats a page-1 miss for this platform as 'no update', not an error", async () => {
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64");
+ writeBackendVersion(dir, {
+ tag: "turboquant-installed",
+ downloadedAt: "2026-06-02T00:00:00.000Z",
+ asset: "llama-turboquant-macos-arm64.zip",
+ });
+ // Page 1 is all Windows releases — the macOS asset fell off the end.
+ globalThis.fetch = vi.fn(async () =>
+ releasesResponse([
+ {
+ tag: "turboquant-windows-9",
+ publishedAt: "2026-07-01T00:00:00Z",
+ assetName: "llama-turboquant-windows-x64-vulkan.zip",
+ },
+ ]),
+ ) as typeof fetch;
+
+ try {
+ const check = await checkForBackendUpdate(dir);
+ expect(check.updateAvailable).toBe(false);
+ expect(check.latestTag).toBeNull();
+ expect(check.currentTag).toBe("turboquant-installed");
+ } finally {
+ platformSpy.mockRestore();
+ archSpy.mockRestore();
+ }
+ });
+
+ it("bounds the releases request with a timeout signal", async () => {
+ const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
+ const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64");
+ let seenSignal: AbortSignal | undefined;
+ globalThis.fetch = vi.fn(async (_url: string | URL, init?: RequestInit) => {
+ seenSignal = init?.signal ?? undefined;
+ return releasesResponse([
+ { tag: "turboquant-x", publishedAt: "2026-07-01T00:00:00Z" },
+ ]);
+ }) as typeof fetch;
+
+ try {
+ await checkForBackendUpdate(dir);
+ // A black-holed connection must not hang the start path until the
+ // OS TCP timeout, so the request has to carry an abort signal.
+ expect(seenSignal).toBeInstanceOf(AbortSignal);
+ expect(seenSignal?.aborted).toBe(false);
+ } finally {
+ platformSpy.mockRestore();
+ archSpy.mockRestore();
+ }
+ });
+
it("isBackendDownloaded is false on unsupported platform (darwin x64)", () => {
const platformSpy = vi
.spyOn(process, "platform", "get")
diff --git a/src/local-llm/backend-installer.ts b/src/local-llm/backend-installer.ts
index 56e303e1..76271de8 100644
--- a/src/local-llm/backend-installer.ts
+++ b/src/local-llm/backend-installer.ts
@@ -1,26 +1,18 @@
-import { execSync } from "node:child_process";
-import {
- chmodSync,
- existsSync,
- mkdirSync,
- readdirSync,
- readFileSync,
- renameSync,
- rmSync,
- statSync,
- writeFileSync,
-} from "node:fs";
-import { dirname, join, relative } from "node:path";
-
-import JSZip from "jszip";
+import { existsSync, mkdirSync } from "node:fs";
+import { join } from "node:path";
import { resolveBackendDir, resolveServerBinPath } from "./backend-paths.js";
+import {
+ extractBackendArchive,
+ rmDirQuiet,
+ swapInStagedBackend,
+} from "./backend-staging.js";
import { downloadFile } from "./download-file.js";
-import { readBackendVersion, writeBackendVersion } from "./backend-version.js";
+import { readBackendVersion, writeBackendVersionAt } from "./backend-version.js";
import { resolvePlatformAsset, UnsupportedPlatformError } from "./platform-assets.js";
import { resolveDownloadAsset } from "./windows-backend-variant.js";
-const GITHUB_REPO = "AtomicBot-ai/atomic-llama-cpp-turboquant";
+const GITHUB_REPO = "AtomicBot-ai/atomic-llama-cpp-turboquant-nightly";
/**
* Anonymous GitHub API allows ~60 req/h per IP. The Models tab polls
@@ -41,16 +33,36 @@ const RELEASE_CACHE_TTL_MS = 10 * 60_000;
*/
const RELEASES_PER_PAGE = 30;
+/**
+ * Timeout for the small releases-list JSON request. With auto-update on
+ * this call sits on the critical path of every managed start, and a
+ * black-holed connection (captive portal, DNS sinkhole) would otherwise
+ * hang until the OS TCP timeout — ~130s on Linux — before the daemon
+ * even begins to boot. Aborting at 5s turns that into a normal check
+ * failure and the caller starts the binary already on disk. Only the
+ * JSON request is bounded; the multi-hundred-MB asset download keeps
+ * the caller's own `opts.signal`.
+ */
+const RELEASES_FETCH_TIMEOUT_MS = 5_000;
+
export type ReleaseAsset = { name: string; browser_download_url: string };
export interface LatestReleaseInfo {
tag: string;
assets: ReleaseAsset[];
+ /**
+ * `published_at` (or `created_at` when a release was never published)
+ * as ISO-8601, or null when GitHub omitted both. Used to order this
+ * release against the installed one — the repo is a nightly, and its
+ * tags are not semver-sortable.
+ */
+ releasedAt: string | null;
}
interface ReleaseCacheEntry {
fetchedAt: number;
- release: LatestReleaseInfo;
+ /** `null` = scanned successfully, no release carries our asset. */
+ release: LatestReleaseInfo | null;
}
/**
@@ -66,9 +78,13 @@ export function resetLatestReleaseCache(): void {
releaseCache.clear();
}
+/**
+ * Resolve the newest release that ships this platform's asset, or
+ * `null` when none of the scanned releases carries it.
+ */
export async function fetchLatestRelease(opts?: {
force?: boolean;
-}): Promise {
+}): Promise {
const { assetName } = resolveDownloadAsset();
const cached = releaseCache.get(assetName);
if (
@@ -86,7 +102,10 @@ export async function fetchLatestRelease(opts?: {
const token = (process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "").trim();
if (token) headers.Authorization = `Bearer ${token}`;
- const res = await fetch(url, { headers });
+ const res = await fetch(url, {
+ headers,
+ signal: AbortSignal.timeout(RELEASES_FETCH_TIMEOUT_MS),
+ });
if (!res.ok) {
if (res.status === 403 || res.status === 429) {
throw new GithubRateLimitedError(res.status);
@@ -96,26 +115,52 @@ export async function fetchLatestRelease(opts?: {
const data = (await res.json()) as Array<{
tag_name: string;
draft?: boolean;
+ published_at?: string | null;
+ created_at?: string | null;
assets: Array<{ name: string; browser_download_url: string }>;
}>;
- // GitHub lists releases newest-first; pick the first (non-draft) whose
- // assets include the asset for the current platform.
- const match = (Array.isArray(data) ? data : []).find(
+ // GitHub lists releases newest-first *by creation*, which is not the
+ // same as newest-published: re-publishing or backfilling an old tag
+ // moves it. Collect every non-draft release carrying our platform
+ // asset and pick the one with the newest release timestamp, falling
+ // back to GitHub's own order when timestamps are missing.
+ const candidates = (Array.isArray(data) ? data : []).filter(
(r) => !r.draft && (r.assets ?? []).some((a) => a.name === assetName),
);
- if (!match) {
- throw new Error(
- `No release found containing asset ${assetName} (scanned ${RELEASES_PER_PAGE} releases)`,
- );
+ if (candidates.length === 0) {
+ // A rarely-built platform's newest asset can fall off page 1 of the
+ // list. That is "nothing to update to", not a hard error: throwing
+ // here would fail the check on every single start and, with the
+ // pre-staging installer, was the only thing standing between the
+ // user and a working binary already on disk.
+ releaseCache.set(assetName, { fetchedAt: Date.now(), release: null });
+ return null;
}
+ const match = candidates.reduce((best, cur) =>
+ releaseTime(cur) > releaseTime(best) ? cur : best,
+ );
const release: LatestReleaseInfo = {
tag: match.tag_name,
assets: match.assets ?? [],
+ releasedAt: match.published_at ?? match.created_at ?? null,
};
releaseCache.set(assetName, { fetchedAt: Date.now(), release });
return release;
}
+/**
+ * Sort key for release recency. `-Infinity` for a release with no
+ * usable timestamp so it never displaces a dated one; ties keep the
+ * earlier (GitHub-ordered) candidate because `reduce` only swaps on a
+ * strict improvement.
+ */
+function releaseTime(r: { published_at?: string | null; created_at?: string | null }): number {
+ const raw = r.published_at ?? r.created_at;
+ if (!raw) return -Infinity;
+ const t = Date.parse(raw);
+ return Number.isNaN(t) ? -Infinity : t;
+}
+
export class GithubRateLimitedError extends Error {
constructor(public readonly status: number) {
super(
@@ -127,110 +172,62 @@ export class GithubRateLimitedError extends Error {
export async function checkForBackendUpdate(
dataDir: string,
-): Promise<{ updateAvailable: boolean; latestTag: string; currentTag: string | null }> {
+): Promise<{
+ updateAvailable: boolean;
+ latestTag: string | null;
+ currentTag: string | null;
+}> {
const current = readBackendVersion(dataDir);
const release = await fetchLatestRelease();
// A variant mismatch counts as an update even at the same tag: a
// Windows box that installed the Vulkan build before its NVIDIA driver
// was present would otherwise keep running Vulkan (and offloading to
- // whatever device Vulkan enumerates) forever.
+ // whatever device Vulkan enumerates) forever. This is a property of
+ // the local machine, not of release ordering, so it is checked before
+ // (and independently of) the recency comparison.
const variantStale =
current?.asset !== undefined && current.asset !== resolveDownloadAsset().assetName;
+ if (release === null) {
+ // Nothing resolvable to update *to* — keep whatever is installed.
+ return {
+ updateAvailable: false,
+ latestTag: null,
+ currentTag: current?.tag ?? null,
+ };
+ }
return {
- updateAvailable: current?.tag !== release.tag || variantStale,
+ updateAvailable: variantStale || isNewerRelease(current, release),
latestTag: release.tag,
currentTag: current?.tag ?? null,
};
}
-function normalizeZipPath(entryName: string): string {
- return entryName.replace(/\\/g, "/");
-}
-
-/**
- * Recursively walk `root`, return the absolute path to the first file
- * whose basename equals `name`. Used after zip extraction to find the
- * `llama-server` binary regardless of the archive's internal nesting
- * (some releases wrap it under `build/bin/`, others under a single
- * top-level folder, others drop it at the root).
- */
-function findFileByName(root: string, name: string): string | null {
- const stack = [root];
- while (stack.length) {
- const cur = stack.pop()!;
- let entries: string[];
- try {
- entries = readdirSync(cur);
- } catch {
- continue;
- }
- for (const entry of entries) {
- const full = join(cur, entry);
- let st;
- try {
- st = statSync(full);
- } catch {
- continue;
- }
- if (st.isDirectory()) {
- stack.push(full);
- } else if (entry === name) {
- return full;
- }
- }
- }
- return null;
-}
-
-/**
- * Move every file in `from` (recursively) into `to`, flattening into
- * siblings at `to`'s root. Used to promote `backend/build/bin/*` or
- * `backend/release-root/*` up to `backend/` after extraction so the
- * `llama-server` binary lives at the path `resolveServerBinPath`
- * expects. Existing files at the destination are overwritten.
- */
-function moveContentsFlat(from: string, to: string): void {
- const walk = (dir: string): void => {
- const entries = readdirSync(dir);
- for (const entry of entries) {
- const src = join(dir, entry);
- const st = statSync(src);
- if (st.isDirectory()) {
- walk(src);
- continue;
- }
- const dst = join(to, entry);
- mkdirSync(dirname(dst), { recursive: true });
- try {
- renameSync(src, dst);
- } catch {
- // Cross-device or other rename failure — fall back to copy+unlink.
- writeFileSync(dst, readFileSync(src));
- try {
- rmSync(src, { force: true });
- } catch {
- /* ignore */
- }
- }
- }
- };
- walk(from);
-}
-
/**
- * Return the first path segment under `backendRoot` leading to
- * `fileInside` — e.g. for `backendRoot=/.../backend` and
- * `fileInside=/.../backend/build/bin/llama-server` this returns
- * `/.../backend/build`. Used after the flatten step to delete the
- * now-empty wrapper tree.
+ * Is `release` genuinely newer than what is installed?
+ *
+ * A bare `current.tag !== release.tag` also fires when the resolved
+ * release is *older* — which happens for real on a nightly repo whose
+ * tags are not semver-sortable: re-publishing or backfilling a tag
+ * moves it, and every client would silently downgrade on next start.
+ * Two contending releases would additionally re-download hundreds of MB
+ * and bounce the daemon on *every* start.
+ *
+ * Release timestamps are the only defensible ordering available here,
+ * so when both sides carry one we require a strict increase. When
+ * either is missing — no install yet, or a version file written before
+ * `releasedAt` existed — we fall back to tag inequality so those users
+ * still converge onto the current build once.
*/
-function topLevelWrapper(backendRoot: string, fileInside: string): string | null {
- const rel = relative(backendRoot, fileInside);
- // `relative` yields platform-native separators: `/` on POSIX, `\` on
- // Windows. Match either so the wrapper dir is cleaned up on both.
- const firstSep = rel.search(/[/\\]/);
- if (firstSep < 0) return null;
- return join(backendRoot, rel.slice(0, firstSep));
+function isNewerRelease(
+ current: { tag: string; releasedAt?: string } | null,
+ release: LatestReleaseInfo,
+): boolean {
+ if (current === null) return true;
+ if (current.tag === release.tag) return false;
+ const currentAt = current.releasedAt ? Date.parse(current.releasedAt) : NaN;
+ const releaseAt = release.releasedAt ? Date.parse(release.releasedAt) : NaN;
+ if (Number.isNaN(currentAt) || Number.isNaN(releaseAt)) return true;
+ return releaseAt > currentAt;
}
export function isBackendDownloaded(dataDir: string): boolean {
@@ -253,6 +250,11 @@ export async function downloadBackend(
// Always hit GitHub for an actual install so we don't grab a stale
// tag from the snapshot cache.
const release = await fetchLatestRelease({ force: true });
+ if (release === null) {
+ throw new Error(
+ `No release found containing asset ${assetName} (scanned ${RELEASES_PER_PAGE} releases)`,
+ );
+ }
const asset = release.assets.find((a) => a.name === assetName);
if (!asset) {
const known = release.assets.map((a) => a.name).join(", ");
@@ -262,94 +264,49 @@ export async function downloadBackend(
}
const backendDir = resolveBackendDir(dataDir);
- // Wipe any leftovers from a previous failed download so the flatten
- // step can't pick up stale `bin/` or `build/` wrappers. Done before
- // mkdir so a missing dir is fine.
- try {
- rmSync(backendDir, { recursive: true, force: true });
- } catch {
- /* ignore */
- }
- mkdirSync(backendDir, { recursive: true });
- const archivePath = join(backendDir, assetName);
-
- await downloadFile(asset.browser_download_url, archivePath, {
- onProgress: opts?.onProgress,
- userAgent: "atomic-agent/local-llm-backend-download",
- signal: opts?.signal,
- });
-
- const zip = await JSZip.loadAsync(readFileSync(archivePath));
- // Extract preserving the archive's internal layout. Flattening happens
- // in a second pass so we can support any of:
- // * `llama-server` (flat)
- // * `release-root/llama-server` (single top folder)
- // * `build/bin/llama-server` (nested)
- for (const entry of Object.values(zip.files)) {
- if (entry.dir) continue;
- const rel = normalizeZipPath(entry.name);
- if (!rel || rel.endsWith("/")) continue;
- const out = join(backendDir, rel);
- mkdirSync(dirname(out), { recursive: true });
- const buf = await entry.async("nodebuffer");
- writeFileSync(out, buf);
- try {
- chmodSync(out, 0o755);
- } catch {
- /* Windows may ignore chmod */
- }
- }
+ // Download and extract into a sibling staging dir, never into the
+ // live one. The old code wiped `backend/` first and only then pulled
+ // several hundred MB, so a network drop, a Ctrl-C, a corrupt zip or a
+ // full disk left the user with no backend at all — and with
+ // auto-update this path now runs on every managed start, not just an
+ // explicit `models update`. Siblings (not tmpdir) so the final swap
+ // stays on one filesystem and can be a rename.
+ const stagingDir = `${backendDir}.next`;
+ const retiredDir = `${backendDir}.old`;
+ // A previous crash can leave either behind; both are scratch, and a
+ // stale `.next` would otherwise poison the flatten step with foreign
+ // `bin/` or `build/` wrappers.
+ rmDirQuiet(stagingDir);
+ rmDirQuiet(retiredDir);
+ mkdirSync(stagingDir, { recursive: true });
- const foundBin = findFileByName(backendDir, binaryName);
- if (!foundBin) {
- throw new Error(
- `llama-server binary not found after extract (searched for ${binaryName} under ${backendDir})`,
- );
- }
- const expectedBin = resolveServerBinPath(dataDir, binaryName);
- if (foundBin !== expectedBin) {
- // Promote the binary's parent directory contents into `backendDir`
- // so `llama-server` (and any sibling shared libs) sit at the path
- // the daemon lifecycle expects, then remove the now-orphaned
- // wrapper directories (e.g. `build/`, `release-root/`).
- moveContentsFlat(dirname(foundBin), backendDir);
- const topWrapper = topLevelWrapper(backendDir, foundBin);
- if (topWrapper !== null) {
- try {
- rmSync(topWrapper, { recursive: true, force: true });
- } catch {
- /* ignore — stray files, not fatal */
- }
- }
- }
+ try {
+ const archivePath = join(stagingDir, assetName);
+ await downloadFile(asset.browser_download_url, archivePath, {
+ onProgress: opts?.onProgress,
+ userAgent: "atomic-agent/local-llm-backend-download",
+ signal: opts?.signal,
+ });
- if (process.platform === "darwin") {
- try {
- execSync(`xattr -cr "${backendDir}"`, { timeout: 10_000 });
- } catch {
- /* xattr may fail */
- }
- }
+ await extractBackendArchive(archivePath, stagingDir, binaryName);
- rmSync(archivePath, { force: true });
+ // The version record lives inside `backend/`, so it is staged with
+ // the rest of the tree and rides in on the swap. It therefore never
+ // describes anything other than what is actually live, and a
+ // failure before this point leaves the old record untouched.
+ writeBackendVersionAt(stagingDir, {
+ tag: release.tag,
+ downloadedAt: new Date().toISOString(),
+ asset: assetName,
+ ...(release.releasedAt ? { releasedAt: release.releasedAt } : {}),
+ });
- if (!existsSync(expectedBin)) {
- throw new Error(
- `llama-server binary not found after extract + flatten (expected ${binaryName} at ${expectedBin}; ` +
- `original location was ${relative(backendDir, foundBin)})`,
- );
+ swapInStagedBackend(backendDir, stagingDir, retiredDir);
+ } catch (err) {
+ // Leave the existing install exactly as it was.
+ rmDirQuiet(stagingDir);
+ throw err;
}
- try {
- chmodSync(expectedBin, 0o755);
- } catch {
- /* Windows */
- }
-
- writeBackendVersion(dataDir, {
- tag: release.tag,
- downloadedAt: new Date().toISOString(),
- asset: assetName,
- });
return { ok: true, tag: release.tag };
}
diff --git a/src/local-llm/backend-staging.ts b/src/local-llm/backend-staging.ts
new file mode 100644
index 00000000..f8550eac
--- /dev/null
+++ b/src/local-llm/backend-staging.ts
@@ -0,0 +1,267 @@
+import { execSync } from "node:child_process";
+import {
+ accessSync,
+ chmodSync,
+ constants,
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ renameSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
+import { dirname, join, relative } from "node:path";
+
+import JSZip from "jszip";
+
+/**
+ * Filesystem side of a backend install: unpack an archive into a
+ * staging directory, normalise the layout, and swap it over the live
+ * one only once it is complete and usable.
+ *
+ * Kept separate from `backend-installer.ts` so the release-resolution
+ * logic there is not interleaved with extraction mechanics.
+ */
+
+function normalizeZipPath(entryName: string): string {
+ return entryName.replace(/\\/g, "/");
+}
+
+/**
+ * Recursively walk `root`, return the absolute path to the first file
+ * whose basename equals `name`. Used after zip extraction to find the
+ * `llama-server` binary regardless of the archive's internal nesting
+ * (some releases wrap it under `build/bin/`, others under a single
+ * top-level folder, others drop it at the root).
+ */
+function findFileByName(root: string, name: string): string | null {
+ const stack = [root];
+ while (stack.length) {
+ const cur = stack.pop()!;
+ let entries: string[];
+ try {
+ entries = readdirSync(cur);
+ } catch {
+ continue;
+ }
+ for (const entry of entries) {
+ const full = join(cur, entry);
+ let st;
+ try {
+ st = statSync(full);
+ } catch {
+ continue;
+ }
+ if (st.isDirectory()) {
+ stack.push(full);
+ } else if (entry === name) {
+ return full;
+ }
+ }
+ }
+ return null;
+}
+
+/**
+ * Move every file in `from` (recursively) into `to`, flattening into
+ * siblings at `to`'s root. Used to promote `backend/build/bin/*` or
+ * `backend/release-root/*` up to `backend/` after extraction so the
+ * `llama-server` binary lives at the path `resolveServerBinPath`
+ * expects. Existing files at the destination are overwritten.
+ */
+function moveContentsFlat(from: string, to: string): void {
+ const walk = (dir: string): void => {
+ const entries = readdirSync(dir);
+ for (const entry of entries) {
+ const src = join(dir, entry);
+ const st = statSync(src);
+ if (st.isDirectory()) {
+ walk(src);
+ continue;
+ }
+ const dst = join(to, entry);
+ mkdirSync(dirname(dst), { recursive: true });
+ try {
+ renameSync(src, dst);
+ } catch {
+ // Cross-device or other rename failure — fall back to copy+unlink.
+ writeFileSync(dst, readFileSync(src));
+ try {
+ rmSync(src, { force: true });
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+ };
+ walk(from);
+}
+
+/**
+ * Return the first path segment under `backendRoot` leading to
+ * `fileInside` — e.g. for `backendRoot=/.../backend` and
+ * `fileInside=/.../backend/build/bin/llama-server` this returns
+ * `/.../backend/build`. Used after the flatten step to delete the
+ * now-empty wrapper tree.
+ */
+function topLevelWrapper(backendRoot: string, fileInside: string): string | null {
+ const rel = relative(backendRoot, fileInside);
+ // `relative` yields platform-native separators: `/` on POSIX, `\` on
+ // Windows. Match either so the wrapper dir is cleaned up on both.
+ const firstSep = rel.search(/[/\\]/);
+ if (firstSep < 0) return null;
+ return join(backendRoot, rel.slice(0, firstSep));
+}
+
+/**
+ * Remove `dir` if it exists, ignoring failures. Used for staging /
+ * rollback scratch dirs where a leftover is a nuisance, not a fault.
+ */
+export function rmDirQuiet(dir: string): void {
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ /* ignore */
+ }
+}
+
+/**
+ * Is the staged tree a usable install? Guards the swap: only an
+ * extraction that actually produced an executable server binary at the
+ * path the daemon launches is allowed to replace a working one.
+ */
+function stagedBinaryUsable(binPath: string): boolean {
+ try {
+ const st = statSync(binPath);
+ if (!st.isFile() || st.size === 0) return false;
+ } catch {
+ return false;
+ }
+ if (process.platform === "win32") return true;
+ try {
+ accessSync(binPath, constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Extract `archivePath` into `stagingDir` and normalise the layout so
+ * `binaryName` ends up at the staging root, executable. Throws when the
+ * archive does not yield a usable server binary — the caller is
+ * expected to discard the staging dir and keep the previous install.
+ */
+export async function extractBackendArchive(
+ archivePath: string,
+ stagingDir: string,
+ binaryName: string,
+): Promise {
+ const zip = await JSZip.loadAsync(readFileSync(archivePath));
+ // Extract preserving the archive's internal layout. Flattening
+ // happens in a second pass so we can support any of:
+ // * `llama-server` (flat)
+ // * `release-root/llama-server` (single top folder)
+ // * `build/bin/llama-server` (nested)
+ for (const entry of Object.values(zip.files)) {
+ if (entry.dir) continue;
+ const rel = normalizeZipPath(entry.name);
+ if (!rel || rel.endsWith("/")) continue;
+ const out = join(stagingDir, rel);
+ mkdirSync(dirname(out), { recursive: true });
+ const buf = await entry.async("nodebuffer");
+ writeFileSync(out, buf);
+ try {
+ chmodSync(out, 0o755);
+ } catch {
+ /* Windows may ignore chmod */
+ }
+ }
+
+ const foundBin = findFileByName(stagingDir, binaryName);
+ if (!foundBin) {
+ throw new Error(
+ `llama-server binary not found after extract (searched for ${binaryName} under ${stagingDir})`,
+ );
+ }
+ const stagedBin = join(stagingDir, binaryName);
+ if (foundBin !== stagedBin) {
+ // Promote the binary's parent directory contents into the staging
+ // root so `llama-server` (and any sibling shared libs) sit at the
+ // path the daemon lifecycle expects once swapped in, then remove
+ // the now-orphaned wrapper dirs (e.g. `build/`, `release-root/`).
+ moveContentsFlat(dirname(foundBin), stagingDir);
+ const topWrapper = topLevelWrapper(stagingDir, foundBin);
+ if (topWrapper !== null) {
+ try {
+ rmSync(topWrapper, { recursive: true, force: true });
+ } catch {
+ /* ignore — stray files, not fatal */
+ }
+ }
+ }
+
+ if (process.platform === "darwin") {
+ try {
+ execSync(`xattr -cr "${stagingDir}"`, { timeout: 10_000 });
+ } catch {
+ /* xattr may fail */
+ }
+ }
+
+ rmSync(archivePath, { force: true });
+
+ if (!existsSync(stagedBin)) {
+ throw new Error(
+ `llama-server binary not found after extract + flatten (expected ${binaryName} at ${stagedBin}; ` +
+ `original location was ${relative(stagingDir, foundBin)})`,
+ );
+ }
+ try {
+ chmodSync(stagedBin, 0o755);
+ } catch {
+ /* Windows */
+ }
+ if (!stagedBinaryUsable(stagedBin)) {
+ throw new Error(
+ `staged llama-server at ${stagedBin} is not a usable executable — keeping the existing install`,
+ );
+ }
+}
+
+/**
+ * Replace `backendDir` with `stagingDir`. Two renames, which is the
+ * closest to atomic a directory swap gets on POSIX and Windows alike:
+ * the live dir is moved aside first so the second rename lands on a
+ * free name (`rename` onto a non-empty dir fails on both platforms).
+ *
+ * The window where neither dir is at the live path spans one rename of
+ * an already-materialised sibling — microseconds, and no I/O that can
+ * block on the network or the disk filling up. If the *second* rename
+ * still fails, the old install is rolled back so the caller is left
+ * with a working backend rather than none.
+ */
+export function swapInStagedBackend(
+ backendDir: string,
+ stagingDir: string,
+ retiredDir: string,
+): void {
+ const hadLive = existsSync(backendDir);
+ if (hadLive) renameSync(backendDir, retiredDir);
+ try {
+ renameSync(stagingDir, backendDir);
+ } catch (err) {
+ if (hadLive) {
+ try {
+ renameSync(retiredDir, backendDir);
+ } catch {
+ /* rollback failed too — surface the original error */
+ }
+ }
+ throw err;
+ }
+ rmDirQuiet(retiredDir);
+}
+
diff --git a/src/local-llm/backend-version.ts b/src/local-llm/backend-version.ts
index a06c624f..ac4d2ebb 100644
--- a/src/local-llm/backend-version.ts
+++ b/src/local-llm/backend-version.ts
@@ -1,5 +1,5 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
-import { dirname } from "node:path";
+import { dirname, join } from "node:path";
import { resolveVersionFilePath } from "./backend-paths.js";
@@ -16,6 +16,16 @@ export interface BackendVersionInfo {
* installs predating this field.
*/
asset?: string;
+ /**
+ * `published_at` (falling back to `created_at`) of the GitHub release
+ * this install came from, ISO-8601. `checkForBackendUpdate` compares
+ * it against the resolved release so a re-published or backfilled
+ * older tag cannot present itself as an upgrade. Absent on installs
+ * predating this field, which is treated as "unknown, allow the
+ * tag-difference verdict to stand" so those users still get one more
+ * update.
+ */
+ releasedAt?: string;
}
export function readBackendVersion(dataDir: string): BackendVersionInfo | null {
@@ -28,7 +38,25 @@ export function readBackendVersion(dataDir: string): BackendVersionInfo | null {
}
export function writeBackendVersion(dataDir: string, info: BackendVersionInfo): void {
- const p = resolveVersionFilePath(dataDir);
+ writeVersionFile(resolveVersionFilePath(dataDir), info);
+}
+
+/**
+ * Write the version record into an arbitrary backend directory rather
+ * than the live one. The version file lives *inside* `backend/`, so a
+ * staged install must carry its own copy — writing it to the live path
+ * before the swap would describe a build that is not on disk yet, and
+ * writing it after would leave a window where the swapped-in binary is
+ * described by the previous tag.
+ */
+export function writeBackendVersionAt(
+ backendDir: string,
+ info: BackendVersionInfo,
+): void {
+ writeVersionFile(join(backendDir, "backend-version.json"), info);
+}
+
+function writeVersionFile(p: string, info: BackendVersionInfo): void {
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, JSON.stringify(info, null, 2) + "\n");
}
diff --git a/src/local-llm/ensure-latest-backend.test.ts b/src/local-llm/ensure-latest-backend.test.ts
new file mode 100644
index 00000000..41c67b8f
--- /dev/null
+++ b/src/local-llm/ensure-latest-backend.test.ts
@@ -0,0 +1,214 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("./backend-installer.js", async () => {
+ const actual =
+ await vi.importActual(
+ "./backend-installer.js",
+ );
+ return {
+ ...actual,
+ checkForBackendUpdate: vi.fn(),
+ downloadBackend: vi.fn(),
+ isBackendDownloaded: vi.fn(),
+ };
+});
+
+vi.mock("./daemon-lifecycle.js", async () => {
+ const actual =
+ await vi.importActual(
+ "./daemon-lifecycle.js",
+ );
+ return {
+ ...actual,
+ readRunningPid: vi.fn(),
+ stopChatAndEmbeddingDaemons: vi.fn(),
+ };
+});
+
+vi.mock("./session-registry.js", async () => {
+ const actual =
+ await vi.importActual(
+ "./session-registry.js",
+ );
+ return {
+ ...actual,
+ hasOtherLiveSessions: vi.fn(),
+ };
+});
+
+import {
+ checkForBackendUpdate,
+ downloadBackend,
+ isBackendDownloaded,
+} from "./backend-installer.js";
+import {
+ readRunningPid,
+ stopChatAndEmbeddingDaemons,
+} from "./daemon-lifecycle.js";
+import { maybeAutoUpdateBackend } from "./ensure-latest-backend.js";
+import { hasOtherLiveSessions } from "./session-registry.js";
+
+describe("maybeAutoUpdateBackend", () => {
+ afterEach(() => {
+ vi.mocked(checkForBackendUpdate).mockReset();
+ vi.mocked(downloadBackend).mockReset();
+ vi.mocked(readRunningPid).mockReset();
+ vi.mocked(stopChatAndEmbeddingDaemons).mockReset();
+ vi.mocked(hasOtherLiveSessions).mockReset();
+ vi.mocked(hasOtherLiveSessions).mockReturnValue(false);
+ vi.mocked(isBackendDownloaded).mockReset();
+ vi.mocked(isBackendDownloaded).mockReturnValue(true);
+ });
+
+ it("is a no-op when autoUpdate is off", async () => {
+ const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: false });
+ expect(result).toEqual({ action: "skipped" });
+ expect(checkForBackendUpdate).not.toHaveBeenCalled();
+ expect(downloadBackend).not.toHaveBeenCalled();
+ });
+
+ it("does not download when the installed tag already matches latest", async () => {
+ vi.mocked(checkForBackendUpdate).mockResolvedValue({
+ updateAvailable: false,
+ latestTag: "turboquant-07b9908",
+ currentTag: "turboquant-07b9908",
+ });
+
+ const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true });
+ expect(result).toEqual({
+ action: "current",
+ tag: "turboquant-07b9908",
+ });
+ expect(downloadBackend).not.toHaveBeenCalled();
+ expect(stopChatAndEmbeddingDaemons).not.toHaveBeenCalled();
+ });
+
+ it("stops a running daemon then downloads when a newer tag exists", async () => {
+ vi.mocked(checkForBackendUpdate).mockResolvedValue({
+ updateAvailable: true,
+ latestTag: "turboquant-07b9908",
+ currentTag: "b10269-1.5.1",
+ });
+ vi.mocked(readRunningPid).mockReturnValue(4242);
+ vi.mocked(stopChatAndEmbeddingDaemons).mockResolvedValue();
+ vi.mocked(downloadBackend).mockResolvedValue({
+ ok: true,
+ tag: "turboquant-07b9908",
+ });
+
+ const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true });
+ expect(result).toEqual({
+ action: "updated",
+ from: "b10269-1.5.1",
+ to: "turboquant-07b9908",
+ });
+ expect(stopChatAndEmbeddingDaemons).toHaveBeenCalledWith("/tmp/data");
+ expect(downloadBackend).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not stop when nothing is running, then still downloads", async () => {
+ vi.mocked(checkForBackendUpdate).mockResolvedValue({
+ updateAvailable: true,
+ latestTag: "turboquant-new",
+ currentTag: null,
+ });
+ vi.mocked(readRunningPid).mockReturnValue(null);
+ vi.mocked(downloadBackend).mockResolvedValue({
+ ok: true,
+ tag: "turboquant-new",
+ });
+
+ const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true });
+ expect(result.action).toBe("updated");
+ expect(stopChatAndEmbeddingDaemons).not.toHaveBeenCalled();
+ });
+
+ it("defers the download when another live session owns the running daemon", async () => {
+ vi.mocked(checkForBackendUpdate).mockResolvedValue({
+ updateAvailable: true,
+ latestTag: "turboquant-new",
+ currentTag: "old",
+ });
+ vi.mocked(readRunningPid).mockReturnValue(99);
+ vi.mocked(hasOtherLiveSessions).mockReturnValue(true);
+
+ const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true });
+ expect(result).toEqual({ action: "deferred", reason: "other_session" });
+ expect(stopChatAndEmbeddingDaemons).not.toHaveBeenCalled();
+ expect(downloadBackend).not.toHaveBeenCalled();
+ });
+
+ it("folds a download failure into update_failed so start can continue", async () => {
+ vi.mocked(checkForBackendUpdate).mockResolvedValue({
+ updateAvailable: true,
+ latestTag: "turboquant-new",
+ currentTag: "turboquant-old",
+ });
+ vi.mocked(readRunningPid).mockReturnValue(4242);
+ vi.mocked(stopChatAndEmbeddingDaemons).mockResolvedValue();
+ vi.mocked(downloadBackend).mockRejectedValue(new Error("socket hang up"));
+ // Staged install: the previous binary survives a failed download.
+ vi.mocked(isBackendDownloaded).mockReturnValue(true);
+
+ // The daemon has already been stopped at this point, so throwing
+ // would leave the user with nothing running at all.
+ const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true });
+ expect(result).toEqual({
+ action: "update_failed",
+ error: "socket hang up",
+ backendUsable: true,
+ });
+ expect(stopChatAndEmbeddingDaemons).toHaveBeenCalledWith("/tmp/data");
+ });
+
+ it("reports backendUsable false when nothing is left to start", async () => {
+ vi.mocked(checkForBackendUpdate).mockResolvedValue({
+ updateAvailable: true,
+ latestTag: "turboquant-new",
+ currentTag: null,
+ });
+ vi.mocked(readRunningPid).mockReturnValue(null);
+ vi.mocked(downloadBackend).mockRejectedValue(new Error("disk full"));
+ vi.mocked(isBackendDownloaded).mockReturnValue(false);
+
+ const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true });
+ expect(result).toEqual({
+ action: "update_failed",
+ error: "disk full",
+ backendUsable: false,
+ });
+ });
+
+ it("folds a daemon-stop failure into update_failed rather than throwing", async () => {
+ vi.mocked(checkForBackendUpdate).mockResolvedValue({
+ updateAvailable: true,
+ latestTag: "turboquant-new",
+ currentTag: "turboquant-old",
+ });
+ vi.mocked(readRunningPid).mockReturnValue(4242);
+ vi.mocked(stopChatAndEmbeddingDaemons).mockRejectedValue(
+ new Error("kill EPERM"),
+ );
+
+ const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true });
+ expect(result).toEqual({
+ action: "update_failed",
+ error: "kill EPERM",
+ backendUsable: true,
+ });
+ expect(downloadBackend).not.toHaveBeenCalled();
+ });
+
+ it("folds a GitHub check failure into check_failed so start can continue", async () => {
+ vi.mocked(checkForBackendUpdate).mockRejectedValue(
+ new Error("GitHub API rate-limited (HTTP 403)"),
+ );
+
+ const result = await maybeAutoUpdateBackend("/tmp/data", { enabled: true });
+ expect(result).toEqual({
+ action: "check_failed",
+ error: "GitHub API rate-limited (HTTP 403)",
+ });
+ expect(downloadBackend).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/local-llm/ensure-latest-backend.ts b/src/local-llm/ensure-latest-backend.ts
new file mode 100644
index 00000000..cbdd7aea
--- /dev/null
+++ b/src/local-llm/ensure-latest-backend.ts
@@ -0,0 +1,110 @@
+import {
+ checkForBackendUpdate,
+ downloadBackend,
+ isBackendDownloaded,
+} from "./backend-installer.js";
+import type { DownloadProgressFn } from "./download-file.js";
+import {
+ readRunningPid,
+ stopChatAndEmbeddingDaemons,
+} from "./daemon-lifecycle.js";
+import { hasOtherLiveSessions } from "./session-registry.js";
+
+export type AutoUpdateBackendResult =
+ | { action: "skipped" }
+ | { action: "current"; tag: string | null }
+ | { action: "updated"; from: string | null; to: string }
+ | { action: "deferred"; reason: "other_session" | "daemon_live" }
+ | { action: "check_failed"; error: string }
+ /**
+ * The version check said "update", but stopping the daemon or
+ * downloading the replacement failed. `backendUsable` reports whether
+ * a server binary is still on disk: the staged installer keeps the
+ * previous install intact, so this is almost always true and the
+ * caller should start it. False means there is genuinely nothing to
+ * run and the caller must fail.
+ */
+ | { action: "update_failed"; error: string; backendUsable: boolean };
+
+/**
+ * When `enabled`, pull a newer llama.cpp backend from GitHub Releases
+ * before the managed daemon starts. Missing-backend first install is
+ * still owned by the TUI/CLI start paths; this only upgrades an already
+ * installed zip. Failures anywhere in the update are fire-safe: this
+ * never throws, and every non-fatal outcome leaves the caller free to
+ * start the binary already on disk instead of aborting the turn. That
+ * matters most *after* the daemon was stopped for the update — an
+ * exception there used to leave the user with nothing running, which is
+ * strictly worse than never having attempted the update.
+ */
+export async function maybeAutoUpdateBackend(
+ dataDir: string,
+ opts: {
+ enabled: boolean;
+ onProgress?: DownloadProgressFn;
+ onWillDownload?: () => void;
+ /**
+ * Abort the (27-39 MB) asset download. Without one a stalled but
+ * open connection never resolves and the update hangs for the
+ * lifetime of the process.
+ */
+ signal?: AbortSignal;
+ /**
+ * Never stop a running daemon to install the update. Set by the
+ * deferred pass that runs *after* start: there the live daemon is
+ * the one serving the user, and `hasOtherLiveSessions` cannot see
+ * it — it skips our own pid by design — so without this the
+ * background update would kill the model mid-turn.
+ */
+ keepDaemonRunning?: boolean;
+ },
+): Promise {
+ if (!opts.enabled) return { action: "skipped" };
+
+ let check: Awaited>;
+ try {
+ check = await checkForBackendUpdate(dataDir);
+ } catch (err) {
+ return {
+ action: "check_failed",
+ error: err instanceof Error ? err.message : String(err),
+ };
+ }
+ if (!check.updateAvailable) {
+ return { action: "current", tag: check.latestTag };
+ }
+
+ // Replacing the zip while llama-server still holds the old binary
+ // fails on Windows (file lock) and leaves POSIX starts racing the
+ // live pid. Stop both daemons first; the caller starts them after.
+ // Skip the stop when another TUI/CLI session is live — killing their
+ // model mid-chat is worse than sitting on an old tag until next solo start.
+ try {
+ if (readRunningPid(dataDir) !== null) {
+ if (opts.keepDaemonRunning) {
+ return { action: "deferred", reason: "daemon_live" };
+ }
+ if (hasOtherLiveSessions(dataDir)) {
+ return { action: "deferred", reason: "other_session" };
+ }
+ await stopChatAndEmbeddingDaemons(dataDir);
+ }
+
+ opts.onWillDownload?.();
+ const downloaded = await downloadBackend(dataDir, {
+ onProgress: opts.onProgress,
+ signal: opts.signal,
+ });
+ return {
+ action: "updated",
+ from: check.currentTag,
+ to: downloaded.tag,
+ };
+ } catch (err) {
+ return {
+ action: "update_failed",
+ error: err instanceof Error ? err.message : String(err),
+ backendUsable: isBackendDownloaded(dataDir),
+ };
+ }
+}
diff --git a/src/local-llm/index.ts b/src/local-llm/index.ts
index cccdd389..20309223 100644
--- a/src/local-llm/index.ts
+++ b/src/local-llm/index.ts
@@ -50,6 +50,10 @@ export {
GithubRateLimitedError,
type LatestReleaseInfo,
} from "./backend-installer.js";
+export {
+ maybeAutoUpdateBackend,
+ type AutoUpdateBackendResult,
+} from "./ensure-latest-backend.js";
export {
isModelDownloaded,
isMmprojDownloaded,
diff --git a/src/local-llm/models-catalog.test.ts b/src/local-llm/models-catalog.test.ts
index 1ea7f7b3..9afc93c6 100644
--- a/src/local-llm/models-catalog.test.ts
+++ b/src/local-llm/models-catalog.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
+import {
+ detectModelProfile,
+ PLAIN_INSTRUCT_PROFILE,
+} from "../llm/model-profile.js";
+import { MUSE_PROPS } from "../llm/model-profile.fixtures.js";
import {
DEFAULT_EMBEDDING_MODEL_ID,
DEFAULT_LLAMACPP_MODEL_ID,
@@ -11,10 +16,10 @@ import {
} from "./models-catalog.js";
describe("models-catalog", () => {
- it("has exactly 10 Qwen+Gemma models with unique ids", () => {
- expect(LOCAL_MODELS_CATALOG.length).toBe(10);
+ it("has exactly 12 Qwen+Gemma+Nemotron+Muse models with unique ids", () => {
+ expect(LOCAL_MODELS_CATALOG.length).toBe(12);
const ids = new Set(LOCAL_MODELS_CATALOG.map((m) => m.id));
- expect(ids.size).toBe(10);
+ expect(ids.size).toBe(12);
});
it("defaults to qwen-3.5-4b", () => {
@@ -25,9 +30,16 @@ describe("models-catalog", () => {
// no `` / `enable_thinking` markers. Because `--chat-template-file`
// is what `/props.chat_template` reports back, it demoted the profile to
// `plain-instruct` and deadlocked the grammar. See chat-templates.test.ts.
- it("does not override the Qwen 3.5 chat template", () => {
- expect(getLocalModelDef("qwen-3.5-4b").chatTemplateAsset).toBeUndefined();
- expect(getLocalModelDef("qwen-3.5-35b").chatTemplateAsset).toBeUndefined();
+ //
+ // Catalog-wide rather than per-id: any entry that grows an override is
+ // exposed to the same failure mode, so adding one has to be a deliberate
+ // act that edits this test (and states why the override keeps every
+ // reasoning marker `detectModelProfile` keys on) — not a silent field.
+ it("ships no chat template override on any catalog entry", () => {
+ for (const def of LOCAL_MODELS_CATALOG) {
+ expect(def.chatTemplateAsset, `${def.id} must not override its chat template`)
+ .toBeUndefined();
+ }
});
it("throws on unknown id", () => {
@@ -36,16 +48,43 @@ describe("models-catalog", () => {
).toThrow(/unknown local model id/);
});
- it("marks every catalog entry as vision-capable with mmproj URL", () => {
- expect(LOCAL_MODELS_CATALOG.length).toBeGreaterThan(0);
- for (const def of LOCAL_MODELS_CATALOG) {
- expect(def.supportsVision).toBe(true);
+ it("ships mmproj URL for every vision-capable catalog entry", () => {
+ const visionModels = LOCAL_MODELS_CATALOG.filter((m) => m.supportsVision);
+ expect(visionModels.length).toBeGreaterThan(0);
+ for (const def of visionModels) {
expect(def.mmprojUrl).toMatch(/^https:\/\//);
expect(def.mmprojFilename).toMatch(/\.gguf$/);
expect(typeof def.mmprojFileSizeGb).toBe("number");
}
});
+ it("omits mmproj fields on text-only catalog entries", () => {
+ const textOnly = LOCAL_MODELS_CATALOG.filter((m) => !m.supportsVision);
+ expect(textOnly.map((m) => m.id)).toEqual(["nemotron-3.5-30b-a3b"]);
+ for (const def of textOnly) {
+ expect(def.mmprojUrl).toBeUndefined();
+ expect(def.mmprojFilename).toBeUndefined();
+ expect(def.mmprojFileSizeGb).toBeUndefined();
+ }
+ });
+
+ // Interim contract for Muse Glimmer. The catalog advertises multimodal
+ // (real: mmproj ships below) but NOT a native tool format, because there
+ // is none wired: the daemon passes `model.id` as the llama-server alias,
+ // and `muse-glimmer-30b` matches no alias hint in `selectBaseProfile`, so
+ // `/props` resolves to `plain-instruct` and tool calls run on the generic
+ // GBNF array grammar. This test is the tripwire: the day someone wires a
+ // native ATEM/Harmony profile, it fails and forces the description to be
+ // updated in the same commit instead of drifting into an over-promise.
+ it("resolves Muse Glimmer to plain-instruct, and says so in its description", () => {
+ expect(detectModelProfile(MUSE_PROPS)).toEqual(PLAIN_INSTRUCT_PROFILE);
+
+ const muse = getLocalModelDef("muse-glimmer-30b");
+ expect(muse.supportsVision).toBe(true);
+ expect(muse.description).not.toMatch(/atem|harmony/i);
+ expect(muse.description).toMatch(/generic tool calling/i);
+ });
+
it("ensures mmproj URL points at the same HF repo as the GGUF weights", () => {
for (const def of LOCAL_MODELS_CATALOG) {
if (!def.mmprojUrl) continue;
diff --git a/src/local-llm/models-catalog.ts b/src/local-llm/models-catalog.ts
index 6cc284f3..02a31a84 100644
--- a/src/local-llm/models-catalog.ts
+++ b/src/local-llm/models-catalog.ts
@@ -1,6 +1,7 @@
/**
- * Curated GGUF catalog (Qwen + Gemma only). URLs mirror atomic-hermes
- * desktop local LLM models; `family` replaces UI-only icon fields.
+ * Curated GGUF catalog (Qwen + Gemma + Nemotron + Muse). URLs mirror
+ * atomic-hermes desktop local LLM models; `family` replaces UI-only
+ * icon fields.
*/
export type LocalModelId =
@@ -13,7 +14,9 @@ export type LocalModelId =
| "gemma-4-e4b"
| "gemma-4-12b"
| "gemma-4-26b-a4b"
- | "gemma-4-31b";
+ | "gemma-4-31b"
+ | "nemotron-3.5-30b-a3b"
+ | "muse-glimmer-30b";
/**
* Memory-v2 phase 1B. Embedding model identifiers. A separate union
@@ -43,7 +46,7 @@ export interface LocalModelDef {
contextLabel: string;
minRamGb: number;
recommendedRamGb: number;
- family: "qwen" | "gemma";
+ family: "qwen" | "gemma" | "nemotron" | "muse";
/**
* Jinja file under `assets/ai-models/` passed to llama-server as
* `--chat-template-file`, overriding the template baked into the GGUF.
@@ -280,6 +283,51 @@ export const LOCAL_MODELS_CATALOG: readonly LocalModelDef[] = [
mmprojFilename: "mmproj-F16.gguf",
mmprojFileSizeGb: 0.90,
},
+ {
+ id: "nemotron-3.5-30b-a3b",
+ name: "NVIDIA Nemotron 3.5 Lightning 30B-A3B GGUF",
+ filename: "NVIDIA-Nemotron-3.5-Lightning-30B-A3B-AD-IQ4_NL.gguf",
+ huggingFaceUrl:
+ "https://huggingface.co/AtomicChat/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF/resolve/main/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-AD-IQ4_NL.gguf",
+ fileSizeGb: 19.65,
+ sizeLabel: "19.7 GB",
+ description: "Hybrid Mamba2 MoE reasoning, imatrix-calibrated",
+ maxContextLength: 262_144,
+ contextLabel: "256K",
+ minRamGb: 24,
+ recommendedRamGb: 32,
+ family: "nemotron",
+ tag: "New",
+ supportsVision: false,
+ },
+ {
+ id: "muse-glimmer-30b",
+ name: "Meta Muse Glimmer 30B GGUF",
+ filename: "Muse-Glimmer-30B-UD-Q4_K_XL.gguf",
+ huggingFaceUrl:
+ "https://huggingface.co/unsloth/Muse-Glimmer-30B-GGUF/resolve/main/Muse-Glimmer-30B-UD-Q4_K_XL.gguf",
+ fileSizeGb: 15.9,
+ sizeLabel: "15.9 GB",
+ // Vision is fully wired (mmproj below). The native ATEM/Harmony tool
+ // format is NOT: the daemon passes `model.id` as the llama-server
+ // alias (`daemon-lifecycle.ts`), and `muse-glimmer-30b` matches no
+ // alias hint in `selectBaseProfile`, so this resolves to
+ // `plain-instruct` and tool calls go through the generic GBNF array
+ // grammar. Pinned by `models-catalog.test.ts`; reword only when a
+ // native profile actually lands.
+ description: "Multimodal 30B MoE, generic tool calling",
+ maxContextLength: 131_072,
+ contextLabel: "128K",
+ minRamGb: 20,
+ recommendedRamGb: 32,
+ family: "muse",
+ tag: "New",
+ supportsVision: true,
+ mmprojUrl:
+ "https://huggingface.co/unsloth/Muse-Glimmer-30B-GGUF/resolve/main/mmproj-Muse-Glimmer-30B-Q8_0.gguf",
+ mmprojFilename: "mmproj-Muse-Glimmer-30B-Q8_0.gguf",
+ mmprojFileSizeGb: 2.05,
+ },
];
export const DEFAULT_LLAMACPP_MODEL_ID: LocalModelId = "qwen-3.5-4b";
diff --git a/src/tui/components/local-models-panel.tsx b/src/tui/components/local-models-panel.tsx
index 358237ba..a6a71393 100644
--- a/src/tui/components/local-models-panel.tsx
+++ b/src/tui/components/local-models-panel.tsx
@@ -441,10 +441,11 @@ export function LocalModelsPanel({
data dir: {panel.dataDir} · backend{" "}
{panel.backend.currentTag ?? "—"}
{panel.backend.updateAvailable === true ? " (update available)" : ""}
+ {panel.backend.autoUpdate ? "" : " · auto-update off"}
) : null}
- j/k move · Enter pull/activate (embedding: *row + Enter starts server) · g gguf · i info · d remove · s chat+embedding · E embeddings on/off · G gpu · B · r · L
+ j/k move · Enter pull/activate (embedding: *row + Enter starts server) · g gguf · i info · d remove · s chat+embedding · E embeddings on/off · G gpu · U auto-update · B · r · L
) : (
diff --git a/src/tui/local-models/local-models-key-bindings.test.ts b/src/tui/local-models/local-models-key-bindings.test.ts
index 7f373d0f..726c7718 100644
--- a/src/tui/local-models/local-models-key-bindings.test.ts
+++ b/src/tui/local-models/local-models-key-bindings.test.ts
@@ -137,6 +137,53 @@ describe("handleLocalModelsTabKey — vision-aware Enter / g hotkey", () => {
expect(onCycle).toHaveBeenCalledTimes(1);
});
+ // The flag is on by default and drives a background download, so it
+ // needs an in-TUI way out: the CLI equivalent rewrites the whole
+ // config file. Like `G`, it ignores the cursor row.
+ it("'U' toggles backend auto-update regardless of the cursor row type", () => {
+ const onToggle = vi.fn();
+ const callbacks: TuiAppCallbacks = {
+ onApprovalDecision: vi.fn(),
+ onAbort: vi.fn(),
+ onQuit: vi.fn(),
+ onMessageSubmitted: vi.fn(),
+ onLocalModelsAutoUpdateToggleRequested: onToggle,
+ };
+ const state = stateWithRow(
+ makeRow("gemma-4-e4b", { downloaded: true, mmprojStatus: "downloaded" }),
+ );
+ const handled = handleLocalModelsTabKey("U", emptyKey({ shift: true }), {
+ state,
+ dispatch: vi.fn(),
+ callbacks,
+ });
+ expect(handled).toBe(true);
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+
+ // Lowercase must not trigger it — `u` is unbound here, and silently
+ // flipping a background-download setting on a stray keypress is the
+ // kind of surprise the uppercase convention exists to prevent.
+ it("lowercase 'u' does not toggle backend auto-update", () => {
+ const onToggle = vi.fn();
+ const callbacks: TuiAppCallbacks = {
+ onApprovalDecision: vi.fn(),
+ onAbort: vi.fn(),
+ onQuit: vi.fn(),
+ onMessageSubmitted: vi.fn(),
+ onLocalModelsAutoUpdateToggleRequested: onToggle,
+ };
+ const state = stateWithRow(
+ makeRow("gemma-4-e4b", { downloaded: true, mmprojStatus: "downloaded" }),
+ );
+ handleLocalModelsTabKey("u", emptyKey(), {
+ state,
+ dispatch: vi.fn(),
+ callbacks,
+ });
+ expect(onToggle).not.toHaveBeenCalled();
+ });
+
it("Enter on a downloaded GGUF + missing mmproj row triggers mmproj-only pull", () => {
const onPull = vi.fn();
const callbacks: TuiAppCallbacks = {
diff --git a/src/tui/local-models/local-models-key-bindings.ts b/src/tui/local-models/local-models-key-bindings.ts
index 8ea88eb8..52be5893 100644
--- a/src/tui/local-models/local-models-key-bindings.ts
+++ b/src/tui/local-models/local-models-key-bindings.ts
@@ -157,6 +157,12 @@ export function handleLocalModelsTabKey(
callbacks.onLocalModelsDeviceCycleRequested?.();
return true;
}
+ // `U` (uppercase) toggles backend auto-update, matching the `B`/`G`
+ // convention for panel-wide actions that ignore the cursor row.
+ if (input === "U") {
+ callbacks.onLocalModelsAutoUpdateToggleRequested?.();
+ return true;
+ }
if (input === "r") {
callbacks.onLocalModelsRefreshRequested?.();
return true;
diff --git a/src/tui/local-models/local-models-orchestrator-auto-update.test.ts b/src/tui/local-models/local-models-orchestrator-auto-update.test.ts
new file mode 100644
index 00000000..c19c318c
--- /dev/null
+++ b/src/tui/local-models/local-models-orchestrator-auto-update.test.ts
@@ -0,0 +1,297 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("../../local-llm/index.js", async () => {
+ const actual =
+ await vi.importActual(
+ "../../local-llm/index.js",
+ );
+ return {
+ ...actual,
+ getDaemonStatus: vi.fn(),
+ getEmbeddingDaemonStatus: vi.fn(),
+ startEmbeddingDaemon: vi.fn(),
+ stopEmbeddingDaemon: vi.fn(),
+ maybeAutoUpdateBackend: vi.fn(),
+ };
+});
+
+import { getConfig, resetConfigCache } from "../../config/index.js";
+import * as localLlm from "../../local-llm/index.js";
+import {
+ resolveBackendDir,
+ resolveModelFilePath,
+ resolveServerBinPath,
+} from "../../local-llm/index.js";
+import { resolvePlatformAsset } from "../../local-llm/platform-assets.js";
+import { persistUserLocalModelsConfig } from "../persist-user-local-models-config.js";
+import { LocalModelsOrchestrator } from "./local-models-orchestrator.js";
+
+type Emitted = { type: string; line?: string };
+
+/**
+ * The backend auto-update runs on every managed start, and it stops the
+ * daemon before downloading. If a post-check failure aborted the start,
+ * the user would be left with nothing running — strictly worse than not
+ * having the feature. These tests pin that a failed update still lets
+ * the existing binary start, and that the one genuinely fatal case
+ * (nothing usable left on disk) still stops.
+ */
+describe("LocalModelsOrchestrator backend auto-update", () => {
+ let stateDir: string;
+
+ beforeEach(() => {
+ stateDir = mkdtempSync(join(tmpdir(), "local-models-au-"));
+ process.env.ATOMIC_AGENT_STATE_DIR = stateDir;
+ resetConfigCache();
+ vi.mocked(localLlm.getDaemonStatus).mockReset();
+ vi.mocked(localLlm.getEmbeddingDaemonStatus).mockReset();
+ vi.mocked(localLlm.startEmbeddingDaemon).mockReset();
+ vi.mocked(localLlm.stopEmbeddingDaemon).mockReset();
+ vi.mocked(localLlm.maybeAutoUpdateBackend).mockReset();
+ });
+
+ afterEach(() => {
+ delete process.env.ATOMIC_AGENT_STATE_DIR;
+ resetConfigCache();
+ rmSync(stateDir, { recursive: true, force: true });
+ });
+
+ it("starts the existing binary when the update failed but a backend remains", async () => {
+ const dataDir = prepareManagedInstall();
+ vi.mocked(localLlm.maybeAutoUpdateBackend).mockResolvedValue({
+ action: "update_failed",
+ error: "socket hang up",
+ backendUsable: true,
+ });
+
+ const actions: Emitted[] = [];
+ const orchestrator = new LocalModelsOrchestrator({
+ emit(a: unknown) {
+ actions.push(a as Emitted);
+ },
+ });
+ vi.spyOn(orchestrator, "startDaemon").mockResolvedValue(true);
+ vi.spyOn(orchestrator, "refresh").mockResolvedValue();
+ // No daemon adopted, so autoStartIfReady must reach startDaemon.
+ vi.mocked(localLlm.getDaemonStatus).mockResolvedValue({
+ running: false,
+ healthy: false,
+ loading: false,
+ pid: null,
+ port: 19091,
+ });
+
+ await orchestrator.autoStartIfReady();
+
+ expect(orchestrator.startDaemon).toHaveBeenCalledTimes(1);
+ expect(actions.map((a) => a.line).filter(Boolean)).toContain(
+ "local-llm: backend update failed — starting current binary (socket hang up)",
+ );
+ });
+
+ // `autoStartIfReady` starts the daemon and then runs one deferred
+ // update pass. A TUI launch used to hit GitHub twice (against a ~60
+ // req/h anonymous budget) and race two passes on the same
+ // `backend.next` staging dir; the flag below is what keeps it to one.
+ it("checks for a backend update exactly once per start", async () => {
+ prepareManagedInstall();
+ vi.mocked(localLlm.maybeAutoUpdateBackend).mockResolvedValue({
+ action: "current",
+ tag: "turboquant-07b9908",
+ });
+
+ const orchestrator = new LocalModelsOrchestrator({ emit() {} });
+ const startDaemon = vi
+ .spyOn(orchestrator, "startDaemon")
+ .mockResolvedValue(true);
+ vi.spyOn(orchestrator, "refresh").mockResolvedValue();
+ vi.mocked(localLlm.getDaemonStatus).mockResolvedValue({
+ running: false,
+ healthy: false,
+ loading: false,
+ pid: null,
+ port: 19091,
+ });
+
+ await orchestrator.autoStartIfReady();
+
+ expect(localLlm.maybeAutoUpdateBackend).toHaveBeenCalledTimes(1);
+ expect(startDaemon).toHaveBeenCalledWith({ backendAlreadyChecked: true });
+ });
+
+ // The whole point of the deferral: a rendered TUI the user can type
+ // into, with no model behind it, reads as a broken agent. The daemon
+ // must be up before the (27-39 MB, possibly stalled) download starts.
+ it("starts the daemon before checking for a backend update", async () => {
+ prepareManagedInstall();
+ const order: string[] = [];
+ vi.mocked(localLlm.maybeAutoUpdateBackend).mockImplementation(async () => {
+ order.push("update");
+ return { action: "current", tag: "turboquant-07b9908" };
+ });
+
+ const orchestrator = new LocalModelsOrchestrator({ emit() {} });
+ vi.spyOn(orchestrator, "startDaemon").mockImplementation(async () => {
+ order.push("start");
+ return true;
+ });
+ vi.spyOn(orchestrator, "refresh").mockResolvedValue();
+ vi.mocked(localLlm.getDaemonStatus).mockResolvedValue({
+ running: false,
+ healthy: false,
+ loading: false,
+ pid: null,
+ port: 19091,
+ });
+
+ await orchestrator.autoStartIfReady();
+ await vi.waitFor(() => expect(order).toHaveLength(2));
+
+ expect(order).toEqual(["start", "update"]);
+ });
+
+ // `hasOtherLiveSessions` skips our own pid by design, so on the
+ // deferred pass it reports "no other sessions" for the very daemon we
+ // just started. Without `keepDaemonRunning` the background update
+ // would stop the model the user is talking to.
+ it("never stops the running daemon on the deferred pass", async () => {
+ prepareManagedInstall();
+ vi.mocked(localLlm.maybeAutoUpdateBackend).mockResolvedValue({
+ action: "deferred",
+ reason: "daemon_live",
+ });
+
+ const orchestrator = new LocalModelsOrchestrator({ emit() {} });
+ vi.spyOn(orchestrator, "startDaemon").mockResolvedValue(true);
+ vi.spyOn(orchestrator, "refresh").mockResolvedValue();
+ vi.mocked(localLlm.getDaemonStatus).mockResolvedValue({
+ running: false,
+ healthy: false,
+ loading: false,
+ pid: null,
+ port: 19091,
+ });
+
+ await orchestrator.autoStartIfReady();
+ await vi.waitFor(() =>
+ expect(localLlm.maybeAutoUpdateBackend).toHaveBeenCalled(),
+ );
+
+ expect(localLlm.maybeAutoUpdateBackend).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ keepDaemonRunning: true }),
+ );
+ });
+
+ // The flag must be opt-in: a bare `startDaemon()` (the `s` key, or a
+ // restart after a model switch) is the only update check on that path,
+ // so defaulting it to "already checked" would silently disable
+ // auto-update everywhere except TUI launch.
+ // Complements the flag test above: that one mocks `startDaemon`, so it
+ // only proves the flag is PASSED. This one calls the real method with
+ // the flag set and proves it is HONOURED — without it, the guard clause
+ // could be deleted and the pair would still look green.
+ it("skips the check when startDaemon is told the backend was checked", async () => {
+ prepareManagedInstall();
+ vi.mocked(localLlm.maybeAutoUpdateBackend).mockResolvedValue({
+ action: "update_failed",
+ error: "disk full",
+ backendUsable: false,
+ });
+
+ const orchestrator = new LocalModelsOrchestrator({ emit() {} });
+ vi.spyOn(orchestrator, "refresh").mockResolvedValue();
+
+ // With the check skipped there is nothing to bail on, so the start
+ // proceeds past the point where `backendUsable: false` would stop it.
+ await orchestrator.startDaemon({ backendAlreadyChecked: true });
+
+ expect(localLlm.maybeAutoUpdateBackend).not.toHaveBeenCalled();
+ });
+
+ it("still checks when startDaemon is invoked without the flag", async () => {
+ prepareManagedInstall();
+ vi.mocked(localLlm.maybeAutoUpdateBackend).mockResolvedValue({
+ action: "update_failed",
+ error: "disk full",
+ backendUsable: false,
+ });
+
+ const orchestrator = new LocalModelsOrchestrator({ emit() {} });
+ vi.spyOn(orchestrator, "refresh").mockResolvedValue();
+
+ // `backendUsable: false` makes startDaemon bail before spawning, so
+ // this exercises the check without launching a real llama-server.
+ await expect(orchestrator.startDaemon()).resolves.toBe(false);
+
+ expect(localLlm.maybeAutoUpdateBackend).toHaveBeenCalledTimes(1);
+ });
+
+ // The key binding only proves an event fires; this proves the flag is
+ // actually written and read back, which is what `U` is for.
+ it("toggleBackendAutoUpdate flips the persisted flag both ways", async () => {
+ prepareManagedInstall();
+ const orchestrator = new LocalModelsOrchestrator({ emit() {} });
+ vi.spyOn(orchestrator, "refresh").mockResolvedValue();
+
+ expect(getConfig().localModels.managed.autoUpdate).toBe(true);
+
+ await orchestrator.toggleBackendAutoUpdate();
+ expect(getConfig().localModels.managed.autoUpdate).toBe(false);
+
+ await orchestrator.toggleBackendAutoUpdate();
+ expect(getConfig().localModels.managed.autoUpdate).toBe(true);
+ });
+
+ // Turning it off must actually stop the update, not just relabel it.
+ it("skips the update entirely once auto-update is toggled off", async () => {
+ prepareManagedInstall();
+ const orchestrator = new LocalModelsOrchestrator({ emit() {} });
+ vi.spyOn(orchestrator, "refresh").mockResolvedValue();
+ await orchestrator.toggleBackendAutoUpdate();
+
+ vi.mocked(localLlm.maybeAutoUpdateBackend).mockResolvedValue({
+ action: "skipped",
+ });
+ vi.spyOn(orchestrator, "startDaemon").mockResolvedValue(true);
+ vi.mocked(localLlm.getDaemonStatus).mockResolvedValue({
+ running: false,
+ healthy: false,
+ loading: false,
+ pid: null,
+ port: 19091,
+ });
+
+ await orchestrator.autoStartIfReady();
+ await vi.waitFor(() =>
+ expect(localLlm.maybeAutoUpdateBackend).toHaveBeenCalled(),
+ );
+
+ expect(localLlm.maybeAutoUpdateBackend).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ enabled: false }),
+ );
+ });
+
+ /** Managed mode with backend + chat model already on disk. */
+ function prepareManagedInstall(): string {
+ const dataDir = getConfig().paths.localModelsDataDir;
+ const backendDir = resolveBackendDir(dataDir);
+ mkdirSync(backendDir, { recursive: true });
+ const { binaryName } = resolvePlatformAsset();
+ writeFileSync(resolveServerBinPath(dataDir, binaryName), "");
+ const def = localLlm.getLocalModelDef("qwen-3.5-4b");
+ mkdirSync(join(dataDir, "models", def.id), { recursive: true });
+ writeFileSync(resolveModelFilePath(dataDir, def.id, def.filename), "stub");
+ persistUserLocalModelsConfig({
+ mode: "managed",
+ managed: { modelId: "qwen-3.5-4b" },
+ });
+ resetConfigCache();
+ return dataDir;
+ }
+});
diff --git a/src/tui/local-models/local-models-orchestrator-pairing.test.ts b/src/tui/local-models/local-models-orchestrator-pairing.test.ts
index 797b25e8..f414a8e3 100644
--- a/src/tui/local-models/local-models-orchestrator-pairing.test.ts
+++ b/src/tui/local-models/local-models-orchestrator-pairing.test.ts
@@ -15,6 +15,7 @@ vi.mock("../../local-llm/index.js", async () => {
getEmbeddingDaemonStatus: vi.fn(),
startEmbeddingDaemon: vi.fn(),
stopEmbeddingDaemon: vi.fn(),
+ maybeAutoUpdateBackend: vi.fn(),
};
});
@@ -56,6 +57,10 @@ describe("LocalModelsOrchestrator embedding pairing", () => {
vi.mocked(localLlm.getEmbeddingDaemonStatus).mockReset();
vi.mocked(localLlm.startEmbeddingDaemon).mockReset();
vi.mocked(localLlm.stopEmbeddingDaemon).mockReset();
+ vi.mocked(localLlm.maybeAutoUpdateBackend).mockReset();
+ vi.mocked(localLlm.maybeAutoUpdateBackend).mockResolvedValue({
+ action: "skipped",
+ });
});
afterEach(() => {
diff --git a/src/tui/local-models/local-models-orchestrator.ts b/src/tui/local-models/local-models-orchestrator.ts
index 3c266915..87ac14c0 100644
--- a/src/tui/local-models/local-models-orchestrator.ts
+++ b/src/tui/local-models/local-models-orchestrator.ts
@@ -24,6 +24,7 @@ import {
isModelDownloaded,
listVulkanDevices,
LOCAL_MODELS_CATALOG,
+ maybeAutoUpdateBackend,
probeNvidiaVramMiB,
readBackendVersion,
readLogTail,
@@ -81,6 +82,15 @@ function describeDeviceChoice(
}
/** Log-tail poll cadence while the LLM logs tab is active. */
+/**
+ * Deadline for the backend asset download on the auto-update path. The
+ * zip is 27-39 MB, so this is generous for any working link; it exists
+ * because a stalled-but-open TCP connection otherwise never resolves
+ * and the update hangs for the life of the process. A timeout is
+ * reported like any other update failure and retried on the next start.
+ */
+const BACKEND_DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
+
const LOGS_POLL_MS = 1000;
/** Snapshot refresh cadence while the Models tab is idle. */
@@ -117,8 +127,10 @@ export class LocalModelsOrchestrator {
* `pullEmbeddingModel` `await this.pullBackend()` when the backend is
* missing; without this a chat pull and an embedding pull started at the
* same time would launch two concurrent `downloadBackend()` calls that
- * wipe + extract into the same `/backend/` directory, corrupting
- * the install. Concurrent callers share the same in-flight promise.
+ * race on the same `/backend.next` staging dir — each clears it
+ * before extracting, so the loser's tree is deleted mid-write and the
+ * winner can swap in a partial install. Concurrent callers share the
+ * same in-flight promise.
*/
private backendPullInFlight: Promise | null = null;
/**
@@ -248,6 +260,7 @@ export class LocalModelsOrchestrator {
currentTag: ver?.tag ?? null,
latestTag,
updateAvailable,
+ autoUpdate: cfg.localModels.managed.autoUpdate,
},
daemon: {
running: daemon.running,
@@ -715,6 +728,25 @@ export class LocalModelsOrchestrator {
* `auto → cpu → auto`. Does not restart the daemon — the operator
* presses `s` to apply.
*/
+ /**
+ * Flip `localModels.managed.autoUpdate`. The flag is on by default and
+ * governs a background download, so it needs a way out that is not
+ * "hand-edit config.json" — the CLI equivalent takes the whole file.
+ * Takes effect on the next start; nothing in flight is cancelled.
+ */
+ async toggleBackendAutoUpdate(): Promise {
+ const next = !getConfig().localModels.managed.autoUpdate;
+ persistUserLocalModelsConfig({ managed: { autoUpdate: next } });
+ resetConfigCache();
+ this.bus.emit({
+ type: "runtime_info",
+ line: next
+ ? "local-llm: backend auto-update on — a newer llama.cpp is fetched after start"
+ : "local-llm: backend auto-update off — update manually with 'B'",
+ });
+ await this.refresh();
+ }
+
async cycleManagedDevice(): Promise {
const cfg = getConfig();
const dataDir = cfg.paths.localModelsDataDir;
@@ -801,7 +833,16 @@ export class LocalModelsOrchestrator {
});
}
- async startDaemon(): Promise {
+ /**
+ * @param opts.backendAlreadyChecked set by callers that ran
+ * `applyBackendAutoUpdate` themselves. Without it a TUI launch
+ * checks GitHub twice per start — two hits against the ~60 req/h
+ * anonymous budget, and two passes racing on the same
+ * `backend.next` staging dir.
+ */
+ async startDaemon(opts?: {
+ backendAlreadyChecked?: boolean;
+ }): Promise {
const cfg = getConfig();
if (cfg.localModels.mode !== "managed") {
this.bus.emit({
@@ -820,6 +861,7 @@ export class LocalModelsOrchestrator {
}
const dataDir = cfg.paths.localModelsDataDir;
const def = getLocalModelDef(mid);
+ let justPulledBackend = false;
if (!isBackendDownloaded(dataDir)) {
this.bus.emit({
type: "runtime_info",
@@ -827,6 +869,7 @@ export class LocalModelsOrchestrator {
});
await this.pullBackend();
if (!isBackendDownloaded(dataDir)) return false;
+ justPulledBackend = true;
}
if (!isModelDownloaded(dataDir, def)) {
this.bus.emit({
@@ -835,6 +878,10 @@ export class LocalModelsOrchestrator {
});
return false;
}
+ if (!justPulledBackend && !opts?.backendAlreadyChecked) {
+ const updated = await this.applyBackendAutoUpdate(dataDir);
+ if (!updated) return false;
+ }
this.bus.emit({ type: "local_models_daemon_phase_set", phase: "starting" });
this.bus.emit({
type: "runtime_info",
@@ -1499,11 +1546,120 @@ export class LocalModelsOrchestrator {
};
}
+ /**
+ * Check GitHub Releases and replace the llama.cpp zip when a newer
+ * tag exists. Returns whether the caller may proceed to start.
+ *
+ * Every update failure is fire-safe: the installer stages the new
+ * build and only swaps it in once it is complete, so a failed check,
+ * stop or download leaves the previous binary on disk and start
+ * continues on it. `false` is reserved for the one case that cannot
+ * be papered over — the daemon was stopped for an update and no
+ * usable backend remains.
+ */
+ private async applyBackendAutoUpdate(
+ dataDir: string,
+ opts?: { keepDaemonRunning?: boolean },
+ ): Promise {
+ try {
+ const result = await maybeAutoUpdateBackend(dataDir, {
+ enabled: getConfig().localModels.managed.autoUpdate,
+ keepDaemonRunning: opts?.keepDaemonRunning,
+ // The zip is small (27-39 MB) but the link may not be. Without a
+ // deadline a stalled-open connection pins the download for the
+ // life of the process; the next start retries from scratch.
+ signal: AbortSignal.timeout(BACKEND_DOWNLOAD_TIMEOUT_MS),
+ onWillDownload: () => {
+ this.bus.emit({
+ type: "local_models_pull_started",
+ pull: {
+ kind: "backend",
+ modelId: "_backend",
+ label: "llama.cpp backend",
+ percent: 0,
+ transferredBytes: 0,
+ totalBytes: 0,
+ error: null,
+ },
+ });
+ },
+ onProgress: (percent: number, transferred: number, total: number) => {
+ this.bus.emit({
+ type: "local_models_pull_progress",
+ kind: "backend",
+ percent,
+ transferredBytes: transferred,
+ totalBytes: total,
+ });
+ },
+ });
+ if (result.action === "updated") {
+ this.bus.emit({ type: "local_models_pull_finished", kind: "backend" });
+ this.bus.emit({
+ type: "runtime_info",
+ line: `local-llm: updated llama.cpp ${result.from ?? "none"} → ${result.to}`,
+ });
+ } else if (result.action === "check_failed") {
+ this.bus.emit({
+ type: "runtime_info",
+ line: `local-llm: backend update check failed — starting current binary (${result.error})`,
+ });
+ } else if (result.action === "deferred") {
+ this.bus.emit({
+ type: "runtime_info",
+ line:
+ result.reason === "daemon_live"
+ ? "local-llm: backend update deferred — will install on next start"
+ : "local-llm: backend update deferred — another session is using the current binary",
+ });
+ } else if (result.action === "update_failed") {
+ this.bus.emit({
+ type: "local_models_pull_failed",
+ kind: "backend",
+ error: result.error,
+ });
+ if (!result.backendUsable) {
+ this.bus.emit({
+ type: "runtime_info",
+ line: `local-llm: backend update failed and no usable backend remains — ${result.error}`,
+ });
+ return false;
+ }
+ this.bus.emit({
+ type: "runtime_info",
+ line: `local-llm: backend update failed — starting current binary (${result.error})`,
+ });
+ }
+ return true;
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ this.bus.emit({
+ type: "local_models_pull_failed",
+ kind: "backend",
+ error: msg,
+ });
+ this.bus.emit({
+ type: "runtime_info",
+ line: `local-llm: backend auto-update failed — ${msg}`,
+ });
+ return false;
+ }
+ }
+
/**
* Called once at TUI startup. If the user is in managed mode AND the
* backend + model are already on disk AND no daemon is currently
* running, start the daemon so the user lands in a ready state
- * without needing an extra keypress. No-op otherwise.
+ * without needing an extra keypress.
+ *
+ * The backend auto-update runs **after** the daemon is up, never
+ * before. Checking first meant the user got a rendered TUI they
+ * could type into while no model was loaded — a prompt that silently
+ * does nothing reads as a broken agent, and the download (27-39 MB,
+ * unbounded on a stalled link) sat on that path. Starting first
+ * costs one session on the previous binary; the swap is picked up on
+ * the next start. A daemon already serving a model is left alone for
+ * the same reason.
*/
async autoStartIfReady(): Promise {
const cfg = getConfig();
@@ -1527,9 +1683,30 @@ export class LocalModelsOrchestrator {
// (see `ensureEmbeddingPaired`).
await this.ensureEmbeddingPaired();
await this.refresh();
+ this.scheduleBackendAutoUpdate(dataDir);
return;
}
- await this.startDaemon();
+ // `startDaemon` owns the pre-start check on the path where the
+ // backend has to be replaced before a daemon exists; here it must
+ // not run one, because the deferred pass below owns it.
+ await this.startDaemon({ backendAlreadyChecked: true });
+ this.scheduleBackendAutoUpdate(dataDir);
+ }
+
+ /**
+ * Run the backend auto-update once the daemon is serving, off the
+ * start path. Deliberately not awaited: the result only matters for
+ * the *next* start, so nothing the user is waiting on depends on it.
+ * `maybeAutoUpdateBackend` already defers when any daemon is live
+ * (`hasOtherLiveSessions` / `readRunningPid`), so the model we just
+ * started is not pulled out from under the user mid-turn.
+ */
+ private scheduleBackendAutoUpdate(dataDir: string): void {
+ void this.applyBackendAutoUpdate(dataDir, {
+ keepDaemonRunning: true,
+ }).catch(() => {
+ /* applyBackendAutoUpdate already reports failures on the bus */
+ });
}
/**
diff --git a/src/tui/local-models/local-models-panel-state.ts b/src/tui/local-models/local-models-panel-state.ts
index d6329dd7..6e7fd13e 100644
--- a/src/tui/local-models/local-models-panel-state.ts
+++ b/src/tui/local-models/local-models-panel-state.ts
@@ -90,6 +90,8 @@ export interface LocalModelsBackendInfo {
currentTag: string | null;
latestTag: string | null;
updateAvailable: boolean | null;
+ /** `localModels.managed.autoUpdate`; surfaced so `U` has visible state. */
+ autoUpdate: boolean;
}
export interface LocalModelsDaemonInfo {
@@ -207,7 +209,12 @@ export function createInitialLocalModelsPanelState(): LocalModelsPanelState {
mode: "list",
rows: [],
cursor: 0,
- backend: { currentTag: null, latestTag: null, updateAvailable: null },
+ backend: {
+ currentTag: null,
+ latestTag: null,
+ updateAvailable: null,
+ autoUpdate: true,
+ },
daemon: {
running: false,
healthy: false,
diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx
index 15e7fa35..b13c0291 100644
--- a/src/tui/tui-app.tsx
+++ b/src/tui/tui-app.tsx
@@ -170,6 +170,7 @@ export interface TuiAppCallbacks {
onLocalModelsRefreshRequested?(): void;
/** Cycle the managed daemon's GPU preference (auto → devices → cpu). */
onLocalModelsDeviceCycleRequested?(): void | Promise;
+ onLocalModelsAutoUpdateToggleRequested?(): void | Promise;
onLocalModelsRemoveConfirmed?(modelId: import("../local-llm/index.js").LocalModelId): void;
onLocalModelsStatusRequested?(): void | Promise;
/** Ask the orchestrator to (re)start the llama-server daemon. */
diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts
index 3fc51a7e..35a20713 100644
--- a/src/tui/tui-command.ts
+++ b/src/tui/tui-command.ts
@@ -405,6 +405,8 @@ export async function tuiCommand(args: string[]): Promise {
onLocalModelsRefreshRequested: () => void orchestrator.localModels.refresh(),
onLocalModelsDeviceCycleRequested: () =>
void orchestrator.localModels.cycleManagedDevice(),
+ onLocalModelsAutoUpdateToggleRequested: () =>
+ void orchestrator.localModels.toggleBackendAutoUpdate(),
onLocalModelsRemoveConfirmed: (id) =>
void orchestrator.localModels.removeLocalModel(id),
onLocalModelsStatusRequested: () => orchestrator.localModels.emitStatusLine(),