opencode-localhost 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dushyant30suthar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # opencode-localhost
2
+
3
+ Run local models in [opencode](https://opencode.ai). Your `.gguf` files show up
4
+ in the model picker as an ordinary provider, `llama-server` is started and
5
+ supervised for you, and a panel shows GPU, VRAM and CPU while you work.
6
+
7
+ ```
8
+ HARDWARE memory compute │ PROVIDER │ MODEL
9
+ GPU0 14.2/16.0G 87% │ llama.cpp │ Qwen3.6-35B-A3B
10
+ GPU1 13.8/16.0G 82% │ ● running │ 245760 ctx · q8_0 KV
11
+ CPU 18.1/31.0G 24% │ :9337 web off │ ngl 99 · 68.2 tok/s
12
+ ```
13
+
14
+ No fork of opencode, no patches. It is a plugin.
15
+
16
+ ## Requirements
17
+
18
+ - opencode
19
+ - a `llama-server` binary — [build it](https://github.com/ggml-org/llama.cpp) or
20
+ install it however you like, then make sure it is on `$PATH`
21
+ - some `.gguf` files
22
+
23
+ ## Install
24
+
25
+ opencode loads server-side and TUI plugins separately, so it goes in two files.
26
+
27
+ ```jsonc
28
+ // ~/.config/opencode/opencode.jsonc
29
+ { "plugin": ["opencode-localhost"] }
30
+ ```
31
+
32
+ ```jsonc
33
+ // ~/.config/opencode/tui.jsonc
34
+ { "plugin": ["opencode-localhost"] }
35
+ ```
36
+
37
+ Start opencode. The panel appears under the prompt and tells you what is still
38
+ missing.
39
+
40
+ ## Setup
41
+
42
+ On first run it writes `~/.config/opencode/providers/llamacpp/server.ini`.
43
+ Exactly one thing has no sensible default:
44
+
45
+ ```ini
46
+ [server]
47
+ bin = # empty = look on $PATH
48
+ models-dir = ~/models # ← set this
49
+ host = 127.0.0.1
50
+ port = 9337
51
+ models-max = 1
52
+ api-key =
53
+ ```
54
+
55
+ Set `models-dir` and save. No restart: the panel notices within a couple of
56
+ seconds, scans your models, generates `models.ini`, starts `llama-server`, and
57
+ asks opencode to re-read its config so the provider appears in the picker.
58
+
59
+ Nothing is guessed. If `llama-server` is not on `$PATH`, set `bin` — the panel
60
+ says so rather than failing silently.
61
+
62
+ ## Per-model settings
63
+
64
+ `~/.config/opencode/providers/llamacpp/models.ini` is written **for you**, the
65
+ first time your models are scanned — you never create it by hand. It is a
66
+ llama.cpp preset file, handed to `llama-server --models-preset` verbatim. A
67
+ section is appended when a new `.gguf` appears and **never modified
68
+ afterwards** — it is yours.
69
+
70
+ The section name is the model id, so renaming a section is safe: it keeps
71
+ governing its file, and its settings keep being used.
72
+
73
+ ```ini
74
+ [unsloth/Qwen3.6-35B-A3B-GGUF]
75
+ model = /path/to/Qwen3.6-35B-A3B-Q4_K_M.gguf
76
+ ctx-size = 245760
77
+ ubatch-size = 2048
78
+ gpu-layers = 99
79
+ flash-attn = on
80
+ cache-type-k = q8_0
81
+ cache-type-v = q8_0
82
+ temp = 0.6
83
+ top-p = 0.95
84
+ ```
85
+
86
+ Any `llama-server` flag works as a key, without the leading dashes.
87
+
88
+ Two different lifecycles:
89
+
90
+ | Change | Takes effect |
91
+ |---|---|
92
+ | launch flags — `ctx-size`, `gpu-layers`, `tensor-split`, `cache-type-*` | next model load |
93
+ | sampling — `temp`, `top-p`, `top-k`, `min-p` | next message, no reload |
94
+
95
+ Sampling is sent per request rather than baked into the server, so opencode's
96
+ own per-model defaults never override what you set here.
97
+
98
+ `ctx-size` is also what opencode compacts against — set it to what the model
99
+ really loads with, or long conversations will compact far too early.
100
+
101
+ ## Adding another backend
102
+
103
+ `src/server/llamacpp/` implements `src/server/backend.ts`. vLLM or anything else
104
+ is a sibling folder implementing the same three methods — discover models, start
105
+ the server, say where it is. Its own config file, in its own native format,
106
+ under `~/.config/opencode/providers/<backend>/`.
107
+
108
+ ## Security
109
+
110
+ `host` defaults to `127.0.0.1`, so the server is reachable only from this
111
+ machine. If you set `0.0.0.0` to use it from elsewhere on your network, set
112
+ `api-key` as well — `llama-server` then enforces bearer auth, and any
113
+ OpenAI-compatible client can connect with it.
114
+
115
+ ## Limitations
116
+
117
+ - **Provider refresh only happens from the home screen.** opencode's only
118
+ reload path disposes live instances, so it is not fired mid-session; the
119
+ panel shows **restart opencode** in that case instead.
120
+ - **No warm-on-select.** opencode's TUI does not expose the selected model to
121
+ plugins, so a model starts loading on your first message rather than the
122
+ moment you pick it.
123
+ - **Local only.** The panel reads `nvidia-smi` and the server directly, so it
124
+ goes blank if the TUI runs on a different machine from opencode.
125
+ - **NVIDIA only** for GPU stats. Everything else works without a GPU; the
126
+ hardware rows are simply omitted.
127
+
128
+ ## License
129
+
130
+ MIT
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "opencode-localhost",
3
+ "version": "0.1.0",
4
+ "description": "Run local models in opencode. llama.cpp as a provider, with live GPU, VRAM and CPU telemetry in the sidebar.",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "llama.cpp",
9
+ "llama-server",
10
+ "local-models",
11
+ "gguf",
12
+ "vllm",
13
+ "gpu",
14
+ "self-hosted"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "dushyant30suthar",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/dushyant30suthar/opencode-localhost.git"
21
+ },
22
+ "homepage": "https://github.com/dushyant30suthar/opencode-localhost#readme",
23
+ "bugs": "https://github.com/dushyant30suthar/opencode-localhost/issues",
24
+ "type": "module",
25
+ "exports": {
26
+ "./server": "./src/server/index.ts",
27
+ "./tui": "./src/tui/index.tsx"
28
+ },
29
+ "files": [
30
+ "src",
31
+ "tsconfig.json",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "peerDependencies": {
36
+ "@opentui/solid": ">=0.4.5",
37
+ "solid-js": ">=1.9.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@opentui/solid": {
41
+ "optional": true
42
+ },
43
+ "solid-js": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@opencode-ai/plugin": "^1.18.4",
49
+ "@opentui/solid": "^0.4.5",
50
+ "@types/node": "^26.1.1",
51
+ "solid-js": "^1.9.0",
52
+ "typescript": "^7.0.2"
53
+ },
54
+ "engines": {
55
+ "node": ">=22"
56
+ }
57
+ }
@@ -0,0 +1,38 @@
1
+ import type { ProviderStatus } from "../shared/types.ts"
2
+
3
+ /**
4
+ * What every backend implements. llama.cpp today; vLLM and OpenVINO are new
5
+ * folders implementing this, with no change to the plugin around them.
6
+ *
7
+ * A backend owns exactly three things: knowing what models exist, getting its
8
+ * server running, and saying where to reach it. Everything backend-specific —
9
+ * config file format, launch flags, discovery layout — stays inside.
10
+ */
11
+
12
+ export type DiscoveredModel = {
13
+ /** Stable id shown in opencode's picker and used as the API model id. */
14
+ id: string
15
+ name: string
16
+ /** Real context window this model loads with. opencode compacts against it. */
17
+ context: number
18
+ output: number
19
+ /** Sampling to send per request, so opencode's own defaults never override. */
20
+ sampling: Record<string, number>
21
+ }
22
+
23
+ export interface Backend {
24
+ readonly id: string
25
+ readonly name: string
26
+
27
+ /** Never throws. "unconfigured" is a normal answer on a fresh install. */
28
+ status(): Promise<ProviderStatus>
29
+
30
+ /** Empty when unconfigured or when the directory holds nothing usable. */
31
+ models(): Promise<DiscoveredModel[]>
32
+
33
+ /** Idempotent: starts the server only if it is not already answering. */
34
+ start(): Promise<ProviderStatus>
35
+
36
+ baseURL(): string
37
+ apiKey(): string | undefined
38
+ }
@@ -0,0 +1,103 @@
1
+ import type { Backend, DiscoveredModel } from "./backend.ts"
2
+ import { create as llamacpp } from "./llamacpp/index.ts"
3
+
4
+ /**
5
+ * The server half.
6
+ *
7
+ * opencode loads plugins before it reads `cfg.provider`, specifically so a
8
+ * plugin's config() hook can add to it. That is the whole integration: we
9
+ * discover models on disk, start the backend, and write a provider into the
10
+ * config opencode is about to read. From there it is an ordinary provider.
11
+ *
12
+ * Sampling is handled in chat.params rather than baked into the server launch,
13
+ * so changing `temp` in models.ini takes effect on the next message instead of
14
+ * requiring a model reload — and so opencode's own per-model heuristics, which
15
+ * know nothing about local models, never override it.
16
+ */
17
+
18
+ const BACKENDS: Backend[] = [llamacpp()]
19
+
20
+ /** Cached per process: discovery walks the filesystem and starts a server. */
21
+ const sampling = new Map<string, Record<string, number>>()
22
+
23
+ function providerEntry(backend: Backend, models: DiscoveredModel[]) {
24
+ return {
25
+ name: `${backend.name} (local)`,
26
+ api: backend.baseURL(),
27
+ npm: "@ai-sdk/openai-compatible",
28
+ options: {
29
+ baseURL: backend.baseURL(),
30
+ // the SDK requires some value; an unauthenticated server ignores it
31
+ apiKey: backend.apiKey() ?? "local",
32
+ },
33
+ models: Object.fromEntries(
34
+ models.map((model) => [
35
+ model.id,
36
+ {
37
+ name: model.name,
38
+ limit: { context: model.context, output: model.output },
39
+ cost: { input: 0, output: 0 },
40
+ options: {},
41
+ },
42
+ ]),
43
+ ),
44
+ }
45
+ }
46
+
47
+ async function register(input: any) {
48
+ for (const backend of BACKENDS) {
49
+ const status = await backend.status().catch(() => undefined)
50
+ // unconfigured or missing binary: contribute nothing. The panel explains why.
51
+ if (!status || status.state === "unconfigured" || status.state === "failed") continue
52
+
53
+ // Not up yet: start it in the background and register nothing this run.
54
+ //
55
+ // Awaiting here would stall opencode's startup for as long as the server
56
+ // takes to answer — once on a fresh setup, and on *every* launch if the
57
+ // server is failing to start. opencode reads its config once per instance
58
+ // and exposes no way to re-read it, so the provider genuinely cannot appear
59
+ // until the next launch; the panel says so rather than leaving it silent.
60
+ if (status.state !== "running") {
61
+ void backend.start().catch(() => {})
62
+ continue
63
+ }
64
+
65
+ const models = await backend.models().catch(() => [])
66
+ if (models.length === 0) continue
67
+
68
+ for (const model of models) {
69
+ if (Object.keys(model.sampling).length > 0) {
70
+ sampling.set(`${backend.id}/${model.id}`, model.sampling)
71
+ }
72
+ }
73
+
74
+ input.provider = input.provider ?? {}
75
+ input.provider[backend.id] = providerEntry(backend, models)
76
+ }
77
+ }
78
+
79
+ const server = async () => ({
80
+ config: async (input: any) => {
81
+ await register(input).catch(() => {})
82
+ },
83
+
84
+ "chat.params": async (input: any, output: any) => {
85
+ const providerID = input?.model?.providerID
86
+ if (!BACKENDS.some((backend) => backend.id === providerID)) return
87
+ const values = sampling.get(`${providerID}/${input?.model?.id}`)
88
+ if (!values) return
89
+ // opencode names three of these directly and passes the rest through options
90
+ if ("temperature" in values) output.temperature = values["temperature"]
91
+ if ("top_p" in values) output.topP = values["top_p"]
92
+ if ("top_k" in values) output.topK = values["top_k"]
93
+ const extra = { ...values }
94
+ delete extra["temperature"]
95
+ delete extra["top_p"]
96
+ delete extra["top_k"]
97
+ if (Object.keys(extra).length > 0) {
98
+ output.options = { ...(output.options ?? {}), ...extra }
99
+ }
100
+ },
101
+ })
102
+
103
+ export default { id: "opencode-localhost", server }
@@ -0,0 +1,92 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+
4
+ /**
5
+ * Find .gguf files under the models directory.
6
+ *
7
+ * llama-server's own --models-dir scan is one level deep, but the common
8
+ * layouts nest: LM Studio uses <root>/<publisher>/<repo>/*.gguf and a plain
9
+ * download folder is flat. We walk a bounded depth so both work without the
10
+ * user having to know which one they have.
11
+ */
12
+
13
+ const MAX_DEPTH = 3
14
+
15
+ export type LocalModel = {
16
+ /** Section name: "publisher/repo:Q6_K", or "publisher/repo" when unambiguous. */
17
+ id: string
18
+ file: string
19
+ /** Vision projector beside the weights, if present. */
20
+ mmproj?: string
21
+ }
22
+
23
+ type Found = {
24
+ dir: string
25
+ /** Path segments below the models root, for naming. */
26
+ relative: string[]
27
+ files: string[]
28
+ mmproj?: string
29
+ }
30
+
31
+ /** "Qwen3.6-27B-Q6_K.gguf" -> "Q6_K". Multi-shard suffixes stripped first. */
32
+ export function quantOf(filename: string): string | undefined {
33
+ const stem = filename.replace(/\.gguf$/i, "").replace(/-\d{5}-of-\d{5}$/, "")
34
+ return stem.match(/[-.]((?:I?Q\d[\w.]*)|F16|F32|BF16|MXFP4[\w]*|UD-[\w.]+)$/i)?.[1]
35
+ }
36
+
37
+ function nameFor(relative: string[], filename: string, multiple: boolean): string {
38
+ const base = relative.length > 0 ? relative.join("/").replace(/-GGUF$/i, "") : "models"
39
+ if (!multiple) return base
40
+ const quant = quantOf(filename)
41
+ return `${base}:${quant ?? filename.replace(/\.gguf$/i, "")}`
42
+ }
43
+
44
+ async function walk(dir: string, relative: string[], out: Found[]): Promise<void> {
45
+ if (relative.length > MAX_DEPTH) return
46
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
47
+ const files: string[] = []
48
+ let mmproj: string | undefined
49
+ for (const entry of entries) {
50
+ if (entry.isDirectory()) {
51
+ await walk(path.join(dir, entry.name), [...relative, entry.name], out)
52
+ continue
53
+ }
54
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".gguf")) continue
55
+ if (/mmproj/i.test(entry.name)) {
56
+ mmproj = entry.name
57
+ continue
58
+ }
59
+ // multi-shard models: only the first shard is passed to llama-server
60
+ if (/-\d{5}-of-\d{5}\.gguf$/i.test(entry.name)) {
61
+ if (entry.name.includes("-00001-of-")) files.push(entry.name)
62
+ continue
63
+ }
64
+ files.push(entry.name)
65
+ }
66
+ if (files.length > 0) out.push({ dir, relative, files: files.sort(), mmproj })
67
+ }
68
+
69
+ export async function scan(root: string): Promise<LocalModel[]> {
70
+ if (!root) return []
71
+ const found: Found[] = []
72
+ await walk(root, [], found)
73
+
74
+ const models: LocalModel[] = []
75
+ const taken = new Set<string>()
76
+ for (const group of found) {
77
+ // one entry per quant file, so every variant is separately selectable
78
+ const multiple = group.files.length > 1
79
+ for (const file of group.files) {
80
+ let id = nameFor(group.relative, file, multiple)
81
+ if (taken.has(id)) id = nameFor(group.relative, file, true)
82
+ if (taken.has(id)) continue
83
+ taken.add(id)
84
+ models.push({
85
+ id,
86
+ file: path.join(group.dir, file),
87
+ mmproj: group.mmproj ? path.join(group.dir, group.mmproj) : undefined,
88
+ })
89
+ }
90
+ }
91
+ return models.sort((a, b) => a.id.localeCompare(b.id))
92
+ }
@@ -0,0 +1,188 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+ import { spawn } from "child_process"
4
+ import { stateDir, collapseHome } from "../../shared/paths.ts"
5
+ import type { ProviderStatus } from "../../shared/types.ts"
6
+ import type { Backend, DiscoveredModel } from "../backend.ts"
7
+ import * as Server from "./server-ini.ts"
8
+ import * as Models from "./models-ini.ts"
9
+ import { scan } from "./discover.ts"
10
+
11
+ /**
12
+ * The llama.cpp backend: find llama-server, start it against models.ini, and
13
+ * report what it has. Everything llama.cpp-specific lives here and below.
14
+ */
15
+
16
+ const STATE = stateDir(Server.BACKEND)
17
+ const PID_FILE = path.join(STATE, "server.pid")
18
+ const LOG_FILE = path.join(STATE, "server.log")
19
+
20
+ const PROBE_TIMEOUT = 1_500
21
+ const START_TIMEOUT = 20_000
22
+ const POLL_INTERVAL = 400
23
+
24
+ /** Only $PATH. Guessing at build directories bakes one machine into everyone's. */
25
+ async function onPath(): Promise<string | undefined> {
26
+ const dirs = (process.env["PATH"] || "").split(path.delimiter).filter(Boolean)
27
+ for (const dir of dirs) {
28
+ const candidate = path.join(dir, "llama-server")
29
+ const stat = await fs.stat(candidate).catch(() => undefined)
30
+ if (stat?.isFile()) return candidate
31
+ }
32
+ return undefined
33
+ }
34
+
35
+ async function executable(file: string): Promise<boolean> {
36
+ const stat = await fs.stat(file).catch(() => undefined)
37
+ return !!stat?.isFile()
38
+ }
39
+
40
+ /** Reachable when it answers /v1/models; undefined means nothing is listening. */
41
+ async function reachable(baseURL: string, apiKey: string, timeout: number): Promise<boolean> {
42
+ try {
43
+ const res = await fetch(`${baseURL}/models`, {
44
+ signal: AbortSignal.timeout(timeout),
45
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
46
+ })
47
+ return res.ok
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ export function create(): Backend {
54
+ let settings: Server.ServerSettings | undefined
55
+ let starting: Promise<ProviderStatus> | undefined
56
+
57
+ // Re-read every time rather than caching: server.ini is small, and a cached
58
+ // copy meant editing it did nothing until the process restarted — including
59
+ // for the panel, which polls this several times a minute.
60
+ const config = async () => (settings = await Server.load())
61
+ const baseURL = () => {
62
+ const host = settings?.host === "0.0.0.0" ? "127.0.0.1" : (settings?.host ?? "127.0.0.1")
63
+ return `http://${host}:${settings?.port ?? 9337}/v1`
64
+ }
65
+
66
+ async function resolveBin(cfg: Server.ServerSettings): Promise<string | undefined> {
67
+ if (cfg.bin) return (await executable(cfg.bin)) ? cfg.bin : undefined
68
+ const found = await onPath()
69
+ // record it so the file always shows what is actually being used
70
+ if (found) await Server.update("bin", found)
71
+ return found
72
+ }
73
+
74
+ async function status(): Promise<ProviderStatus> {
75
+ const cfg = await config()
76
+ const bin = await resolveBin(cfg)
77
+ if (!bin) {
78
+ return {
79
+ state: "unconfigured",
80
+ missing: "binary",
81
+ message: "llama-server not found",
82
+ hint: `set bin in ${collapseHome(Server.FILE)}`,
83
+ }
84
+ }
85
+ if (!cfg.modelsDir) {
86
+ return {
87
+ state: "unconfigured",
88
+ missing: "models-dir",
89
+ message: "models-dir not set",
90
+ hint: `set it in ${collapseHome(Server.FILE)}`,
91
+ }
92
+ }
93
+ if (await reachable(baseURL(), cfg.apiKey, PROBE_TIMEOUT)) {
94
+ return { state: "running", endpoint: baseURL() }
95
+ }
96
+ return { state: "stopped" }
97
+ }
98
+
99
+ async function models(): Promise<DiscoveredModel[]> {
100
+ const cfg = await config()
101
+ if (!cfg.modelsDir) return []
102
+ const local = await scan(cfg.modelsDir)
103
+ if (local.length === 0) return []
104
+ const { names } = await Models.sync(local)
105
+ const perModel = await Models.settings()
106
+ return local.map((model) => {
107
+ // the section name governs, so a renamed section keeps its tuned values
108
+ const id = names.get(model.file) ?? model.id
109
+ const entry = perModel[id]
110
+ const context = entry?.context || 32_768
111
+ return {
112
+ id,
113
+ name: id.split("/").filter(Boolean).at(-1) ?? id,
114
+ context,
115
+ // thinking models spend the reasoning budget from the output allowance;
116
+ // half the window, capped, leaves room for a long plan without truncating
117
+ output: Math.min(32_768, Math.max(4_096, Math.floor(context / 2))),
118
+ sampling: entry?.sampling ?? {},
119
+ }
120
+ })
121
+ }
122
+
123
+ async function launch(cfg: Server.ServerSettings, bin: string): Promise<ProviderStatus> {
124
+ const local = await scan(cfg.modelsDir)
125
+ if (local.length === 0) {
126
+ return {
127
+ state: "unconfigured",
128
+ missing: "models-dir",
129
+ message: "no .gguf files found",
130
+ hint: collapseHome(cfg.modelsDir),
131
+ }
132
+ }
133
+ const { preset } = await Models.sync(local)
134
+ await fs.mkdir(STATE, { recursive: true }).catch(() => {})
135
+ const log = await fs.open(LOG_FILE, "a").catch(() => undefined)
136
+ try {
137
+ const child = spawn(bin, Server.argv(cfg, preset), {
138
+ detached: true,
139
+ stdio: ["ignore", log?.fd ?? "ignore", log?.fd ?? "ignore"],
140
+ })
141
+ child.on("error", () => {})
142
+ child.unref()
143
+ if (child.pid) await fs.writeFile(PID_FILE, `${child.pid}\n`).catch(() => {})
144
+ } catch (error) {
145
+ return { state: "failed", message: "could not start", hint: String(error).slice(0, 60) }
146
+ } finally {
147
+ await log?.close().catch(() => {})
148
+ }
149
+
150
+ const deadline = Date.now() + START_TIMEOUT
151
+ while (Date.now() < deadline) {
152
+ if (await reachable(baseURL(), cfg.apiKey, PROBE_TIMEOUT)) {
153
+ return { state: "running", endpoint: baseURL() }
154
+ }
155
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL))
156
+ }
157
+ return { state: "failed", message: "server did not start", hint: collapseHome(LOG_FILE) }
158
+ }
159
+
160
+ /** Idempotent and single-flight: concurrent callers share one attempt. */
161
+ async function start(): Promise<ProviderStatus> {
162
+ if (starting) return starting
163
+ starting = (async () => {
164
+ const cfg = await config()
165
+ const bin = await resolveBin(cfg)
166
+ if (!bin || !cfg.modelsDir) return status()
167
+ if (await reachable(baseURL(), cfg.apiKey, PROBE_TIMEOUT)) {
168
+ return { state: "running", endpoint: baseURL() } as ProviderStatus
169
+ }
170
+ return launch(cfg, bin)
171
+ })()
172
+ try {
173
+ return await starting
174
+ } finally {
175
+ starting = undefined
176
+ }
177
+ }
178
+
179
+ return {
180
+ id: Server.BACKEND,
181
+ name: "llama.cpp",
182
+ status,
183
+ models,
184
+ start,
185
+ baseURL,
186
+ apiKey: () => settings?.apiKey || undefined,
187
+ }
188
+ }