pi-mtplx 0.1.9 → 0.2.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/README.md CHANGED
@@ -18,14 +18,14 @@ Restart Pi after installation.
18
18
  2. Download the model(s) you want, e.g. `mtplx install Youssofal/Qwen3.8-27B-MTPLX-Optimized-Quality`.
19
19
  3. Register a downloaded model with Pi via `/mtplx` → **Models** (or add it manually to `~/.pi/agent/mtplx-models.json`), then open `/model` to activate it.
20
20
 
21
- If you pick an MTPLX model that isn't installed, the server won't start — Pi will warn you. Use `mtplx list` to see what you've downloaded.
21
+ `/mtplx` → **Models** only scans models installed through the local MTPLX CLI. It never downloads model weights and does not discover models available only on a configured remote endpoint. If you pick an MTPLX model that isn't installed locally, Pi cannot auto-start it. Use `mtplx list` to see what you've downloaded.
22
22
 
23
23
  ## What happens on first run
24
24
 
25
25
  Once installed, Pi automatically manages your MTPLX workflow:
26
26
 
27
27
  - **Model discovery** — `/mtplx` → **Models** scans what you've downloaded (`mtplx list`) and registers any of them with Pi. Each Pi model ID comes from MTPLX's own `quickstart --dry-run` plan, while capabilities (context window, vision, reasoning) are read from the installed artifact. Models beyond the MTPLX stock set work too.
28
- - **Auto-start** — The MTPLX server starts when you switch to an `mtplx` model. Auto shutdown is on by default and can be changed in `/mtplx`.
28
+ - **Auto-start** — The MTPLX server starts when you switch to an `mtplx` model. Auto-start and auto-shutdown are on by default and can be changed in `/mtplx`.
29
29
  - **Token speed** — A `⚡N.N tk/s` indicator appears in the footer showing the generation speed of the last assistant turn.
30
30
 
31
31
  ## Commands
@@ -34,12 +34,15 @@ Run `/mtplx` to open an interactive menu:
34
34
 
35
35
  | Option | What it does |
36
36
  | -------- | ------------- |
37
- | **Toggle (on/off)** | Start or stop the MTPLX server |
37
+ | **Server** | Read-only status showing the model currently served at the configured endpoint, or that the endpoint is unavailable |
38
+ | **Toggle (on/off)** | Start or stop an MTPLX server launched by this Pi session; separately managed servers are left running |
38
39
  | **API Key** | View a masked identifier for, or replace, the API key Pi uses for the local MTPLX server |
40
+ | **Endpoint** | Set the OpenAI-compatible MTPLX base URL; useful for a separately started server |
39
41
  | **Fan Curves** | Set the thermal profile (`default`, `smart`, `max`) |
40
- | **Auto Shutdown** | Choose whether Pi stops MTPLX on `/quit` or a normal terminal-close shutdown (on by default) |
42
+ | **Auto Shutdown Pi-Owned Server** | Choose whether Pi stops an MTPLX server it launched on `/quit` or a normal terminal-close shutdown (on by default); separately managed and remote servers are always left running |
43
+ | **Auto Start** | Choose whether Pi starts or switches MTPLX when loading an MTPLX model (on by default) |
41
44
  | **SSD Session Cache** | Enable or disable MTPLX's SSD-backed session cache for subsequent server starts (on by default) |
42
- | **Models** | Register or unregister models — ✓ means registered (click to unregister), ✗ means available (click to register) |
45
+ | **Models** | Register or unregister locally installed models — ✓ means registered (click to unregister), ✗ means available (click to register); it does not fetch or discover remote models |
43
46
  | **Uninstall** | Remove the `mtplx` provider from Pi's config |
44
47
 
45
48
  > **To activate a model** after registering or unregistering it, open **`/model`** (or `/scoped-models`). pi-mtplx writes Pi's config files immediately, but Pi loads them into memory on startup — so a **newly registered** MTPLX model only appears in `/model` after you restart Pi with **`/quit`** and relaunch it (`pi`). Unregistering/re-registering an existing model is picked up by opening `/model`, but a brand-new model id requires the restart.
@@ -60,9 +63,11 @@ Models are registered in `~/.pi/agent/mtplx-models.json`. Each entry maps MTPLX'
60
63
 
61
64
  Register new models via the `/mtplx` → **Models** menu; it asks MTPLX for the exact served ID. Opening this menu also migrates older ref-derived IDs to their canonical MTPLX names. Restart Pi after a migration or registration, then open `/model` (or `/scoped-models`) to activate the model.
62
65
 
66
+ Local Forge outputs use their installed paths as artifact refs; downloaded repositories retain their `owner/model` refs. If an artifact cannot produce a serving plan, the menu shows MTPLX's error and skips that artifact while keeping the other models available.
67
+
63
68
  ### Fan mode
64
69
 
65
- Controls the thermal profile of your MTPLX server. Saved to `~/.pi/agent/mtplx-fanmode.json` and persists across restarts.
70
+ Controls the thermal profile used when Pi starts an MTPLX server. Saved to `~/.pi/agent/mtplx-fanmode.json` and persists across restarts. When Pi discovers a server that is already running—whether it is local, remote, or Pi-owned—it does not automatically change the fan curve; if the server reports a supported curve that differs from Pi's saved default, Pi adopts that curve as its new default for future Pi-started servers. Choosing `/mtplx` → **Fan Curves** is an explicit override and changes the fan curve of the active healthy server.
66
71
 
67
72
  | Mode | Behavior |
68
73
  | ------ | ---------- |
@@ -74,9 +79,27 @@ Controls the thermal profile of your MTPLX server. Saved to `~/.pi/agent/mtplx-f
74
79
 
75
80
  pi-mtplx always uses an API key. It uses `providers.mtplx.apiKey` from `~/.pi/agent/models.json` when configured; otherwise it uses the local default `mtplx-local`. The resolved key is passed to Pi-managed MTPLX startup and sent with health, fan-control, and inference requests. If Pi already has a stored MTPLX API-key credential, pi-mtplx synchronizes it with this key at startup and whenever `/mtplx` → **API Key** saves a change. Restart Pi after changing the key so its in-memory inference provider reloads the configuration. The menu only shows a masked suffix for custom keys, never the full secret.
76
81
 
77
- ### Auto shutdown
82
+ ### Standalone MTPLX
83
+
84
+ To use an MTPLX server that you start outside Pi, set `/mtplx` → **Auto Start** to off, then set `/mtplx` → **Endpoint** to that server's OpenAI base URL (for example, `http://127.0.0.1:8001/v1`). Restart Pi after changing it. The endpoint, health checks, and fan controls all use `providers.mtplx.baseUrl`; its default is `http://127.0.0.1:8000/v1`. pi-mtplx never stops a server it did not launch, including on normal Pi shutdown.
85
+
86
+ The served model ID must be the same ID selected in Pi. Before every MTPLX request, pi-mtplx verifies this through `/health` and reports both IDs if they differ. A separately managed server is never switched; start it with `--model-id <Pi model ID>` if its artifact's default identity differs. `/mtplx` → **Models** cannot discover a remote-only model, so add its exact ID to Pi's `mtplx` provider configuration manually before selecting it. Use the same API key in both the MTPLX launch command and `/mtplx` → **API Key**.
87
+
88
+ ### Auto start
89
+
90
+ Auto Start is on by default. Pi first checks the selected endpoint before starting anything. If a healthy MTPLX server is already there and serves the selected model, Pi uses it without replacing it. If it serves a different model, Pi switches it only when that server was started by the current Pi session; separately managed and remote servers are left alone, and Pi blocks the request with both model IDs. Pi starts MTPLX only when no MTPLX server is available at a loopback endpoint. With Auto Start off, Pi performs the same health and model-match check but never starts or switches a server; it instead tells you to start MTPLX at the configured endpoint.
91
+
92
+ ### Auto shutdown Pi-owned server
93
+
94
+ Auto shutdown is on by default. When enabled, pi-mtplx stops an MTPLX server it launched during the current Pi session when Pi exits via `/quit` or a normal terminal-close shutdown. It never stops a separately managed or remote server. Turn it off in `/mtplx` → **Auto Shutdown Pi-Owned Server** to leave the Pi-launched server running after Pi exits. It cannot handle abrupt termination such as `SIGKILL` or a power loss.
95
+
96
+ ## Remote endpoints
97
+
98
+ I do not have a remote server, so I was unable to personally test how pi-mtplx interacts with remote endpoints.
99
+
100
+ ## Reporting bugs
78
101
 
79
- Auto shutdown is on by default. When enabled, pi-mtplx stops the managed MTPLX server when Pi exits via `/quit` or a normal terminal-close shutdown. Turn it off in `/mtplx` → **Auto Shutdown** to leave the loaded server running after Pi exits. It cannot handle abrupt termination such as `SIGKILL` or a power loss.
102
+ If you encounter a bug, please open a [GitHub Issue](../../issues) and let me know.
80
103
 
81
104
  ## Troubleshooting
82
105
 
@@ -86,6 +109,7 @@ Auto shutdown is on by default. When enabled, pi-mtplx stops the managed MTPLX s
86
109
  | **"No MTPLX models registered"** | Run `/mtplx` → **Models** to discover and register one |
87
110
  | **"MTPLX is already running ... but Pi requested ..."** | A manually started MTPLX server is serving a different, unmanaged model. Stop it in the MTPLX app, or find it with `lsof -nP -iTCP:8000 -sTCP:LISTEN` and run `kill -TERM <PID>`, then retry so Pi can start and manage the selected model. |
88
111
  | **"MTPLX rejected the API key"** | The running server requires a different key. Use `/mtplx` → **API Key** to enter its current key, then retry. |
112
+ | **"Connection error"** with standalone MTPLX | Pi's configured endpoint has no listener. Set `/mtplx` → **Endpoint** to the standalone server's exact URL (including `/v1`), then restart Pi. |
89
113
  | **MTPLX startup timed out after 180s** | Run `mtplx status --deep` for MTPLX-side diagnostics (model validation, memory, thermal) |
90
114
 
91
115
  ## License
@@ -6,41 +6,69 @@
6
6
  * and shared helpers (src/utils.ts) into Pi via before_agent_start,
7
7
  * agent_end and session_shutdown, plus the /mtplx command.
8
8
  */
9
- import { acquire, release, stopServer } from "../src/mtplx-process.ts";
9
+ import { acquire, isOwnedByThisSession, release, stopServer, validateServer } from "../src/mtplx-process.ts";
10
10
  import { authenticationFailureMessage, getFanMode, health, healthProbe, setFanMode, setFanModeValue } from "../src/mtplx-client.ts";
11
11
  import { MTPLX_PROVIDER, manageModels, removeModel, removePiMtplxProvider } from "../src/model-discovery.ts";
12
- import { FAN_MODES, isMtplxModel, loadAutoShutdown, loadMtplxApiKey, loadSsdSessionCache, maskMtplxApiKey, saveAutoShutdown, saveFanMode, saveMtplxApiKey, saveSsdSessionCache, syncMtplxStoredCredential, type FanMode } from "../src/utils.ts";
12
+ import { FAN_MODES, isMtplxModel, loadAutoShutdown, loadAutoStart, loadMtplxApiKey, loadMtplxEndpoint, loadSsdSessionCache, maskMtplxApiKey, mtplxEndpointFromBaseUrl, saveAutoShutdown, saveAutoStart, saveFanMode, saveMtplxApiKey, saveMtplxEndpoint, saveSsdSessionCache, syncMtplxStoredCredential, type FanMode } from "../src/utils.ts";
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
14
 
15
+ function syncFanModeFromRunningServer(fanMode: string | undefined): { previous: FanMode; current: FanMode } | undefined {
16
+ if (typeof fanMode !== "string" || !(FAN_MODES as readonly string[]).includes(fanMode)) return;
17
+ const current = fanMode as FanMode;
18
+ const previous = getFanMode();
19
+ if (current === previous) return;
20
+ setFanModeValue(current);
21
+ saveFanMode(current);
22
+ return { previous, current };
23
+ }
24
+
15
25
  export default function mtplxAutostart(pi: ExtensionAPI): void {
26
+ let lastServerNotice: string | undefined;
27
+
16
28
  // Pi's runtime may already have loaded auth.json for this process, but
17
29
  // syncing here ensures its next launch uses the same key as models.json.
18
30
  syncMtplxStoredCredential();
19
31
 
20
32
  pi.registerCommand("mtplx", {
21
- description: "MTPLX — toggle the server, configure API key, fan, shutdown, and SSD cache, manage models, or uninstall",
33
+ description: "MTPLX — view server status, configure its endpoint and lifecycle, manage models, or uninstall",
22
34
  handler: async (_args, ctx) => {
23
35
  const current = await health();
24
36
  const status = current ? "on" : "off";
37
+ const fanSync = current ? syncFanModeFromRunningServer(current.fan_mode) : undefined;
38
+ const server = current
39
+ ? `Server (serving: ${current.model})`
40
+ : "Server (unavailable — check endpoint or API key)";
25
41
  await ctx.ui.setStatus("mtplx", `MTPLX: ${status}`);
42
+ if (fanSync) {
43
+ ctx.ui.notify(
44
+ `MTPLX was already running with fan curve ${fanSync.current}, while Pi's saved default was ${fanSync.previous}. Pi left the server unchanged and updated its saved default to ${fanSync.current}.`,
45
+ "info",
46
+ );
47
+ }
26
48
  const ssdSessionCache = loadSsdSessionCache();
27
49
  const autoShutdown = loadAutoShutdown();
50
+ const autoStart = loadAutoStart();
28
51
  const apiKeyLabel = maskMtplxApiKey(loadMtplxApiKey());
52
+ const endpoint = loadMtplxEndpoint();
29
53
  const topChoices = [
54
+ server,
30
55
  `Toggle (${status})`,
31
56
  `API Key (current: ${apiKeyLabel})`,
57
+ `Endpoint (current: ${endpoint.baseUrl})`,
32
58
  `Fan Curves (current: ${getFanMode()})`,
33
- `Auto Shutdown (current: ${autoShutdown ? "on" : "off"})`,
59
+ `Auto Shutdown Pi-Owned Server (current: ${autoShutdown ? "on" : "off"})`,
60
+ `Auto Start (current: ${autoStart ? "on" : "off"})`,
34
61
  `SSD Session Cache (current: ${ssdSessionCache ? "on" : "off"})`,
35
62
  "Models",
36
63
  "Uninstall (remove provider)",
37
64
  ];
38
65
  const top = await ctx.ui.select("MTPLX", topChoices, undefined);
39
66
  if (!top) return;
67
+ if (top === server) return;
40
68
  if (top.startsWith("Toggle")) {
41
69
  if (current) {
42
- await stopServer();
43
- ctx.ui.notify("MTPLX server stopped", "info");
70
+ const stopped = await stopServer();
71
+ ctx.ui.notify(stopped ? "MTPLX server stopped" : "MTPLX is managed separately; pi-mtplx left it running.", stopped ? "info" : "warning");
44
72
  } else if (ctx.model && isMtplxModel(ctx.model)) {
45
73
  await acquire(ctx.model.id);
46
74
  ctx.ui.notify(`MTPLX server started (${ctx.model.id})`, "info");
@@ -49,6 +77,40 @@ export default function mtplxAutostart(pi: ExtensionAPI): void {
49
77
  }
50
78
  return;
51
79
  }
80
+ if (top.startsWith("Endpoint")) {
81
+ const baseUrl = await ctx.ui.input("MTPLX endpoint", `Current: ${endpoint.baseUrl} — e.g. http://127.0.0.1:8001/v1`);
82
+ if (baseUrl === undefined) return;
83
+ try {
84
+ const url = new URL(baseUrl.trim());
85
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("unsupported protocol");
86
+ } catch {
87
+ ctx.ui.notify("MTPLX endpoint was not updated. Enter an http(s) URL.", "error");
88
+ return;
89
+ }
90
+ if (mtplxEndpointFromBaseUrl(baseUrl).baseUrl === endpoint.baseUrl) {
91
+ ctx.ui.notify("MTPLX endpoint is unchanged.", "info");
92
+ return;
93
+ }
94
+ if (isOwnedByThisSession()) {
95
+ try {
96
+ await stopServer();
97
+ } catch (error) {
98
+ ctx.ui.notify(
99
+ `MTPLX endpoint was not changed because Pi could not stop its running server: ${error instanceof Error ? error.message : String(error)}`,
100
+ "error",
101
+ );
102
+ return;
103
+ }
104
+ ctx.ui.notify("Stopped Pi-owned MTPLX server before changing the endpoint.", "info");
105
+ }
106
+ if (!saveMtplxEndpoint(baseUrl)) {
107
+ ctx.ui.notify("MTPLX endpoint was not updated. Enter an http(s) URL.", "error");
108
+ return;
109
+ }
110
+ const probe = await healthProbe();
111
+ ctx.ui.notify(probe.health ? "MTPLX endpoint saved and verified. Restart Pi before inference uses it." : "MTPLX endpoint saved, but health verification failed. Check its host, port, and API key; then restart Pi.", probe.health ? "info" : "warning");
112
+ return;
113
+ }
52
114
  if (top.startsWith("API Key")) {
53
115
  const apiKey = await ctx.ui.input(`MTPLX API Key — current: ${apiKeyLabel}`, "Paste a custom key, or leave blank for the default (visible while typing)");
54
116
  if (apiKey === undefined) return;
@@ -74,21 +136,35 @@ export default function mtplxAutostart(pi: ExtensionAPI): void {
74
136
  if (!(FAN_MODES as readonly string[]).includes(mode)) return;
75
137
  setFanModeValue(mode);
76
138
  saveFanMode(mode);
77
- if (current) {
78
- await setFanMode();
79
- }
80
- ctx.ui.notify(`MTPLX fan mode set to ${getFanMode()}`, "info");
139
+ if (current) await setFanMode();
140
+ ctx.ui.notify(
141
+ current
142
+ ? `MTPLX fan curve set to ${getFanMode()} and saved as Pi's default for future Pi-started servers.`
143
+ : `MTPLX fan default saved as ${getFanMode()}. It applies the next time Pi starts MTPLX.`,
144
+ "info",
145
+ );
81
146
  }
82
147
  if (top.startsWith("Auto Shutdown")) {
83
148
  const choices = [
84
149
  autoShutdown ? "Off" : "Off (current)",
85
150
  autoShutdown ? "On (current)" : "On",
86
151
  ];
87
- const picked = await ctx.ui.select("MTPLX — stop the server when Pi exits", choices, undefined);
152
+ const picked = await ctx.ui.select("MTPLX — stop the Pi-owned server when Pi exits", choices, undefined);
88
153
  if (!picked) return;
89
154
  const enabled = picked.startsWith("On");
90
155
  saveAutoShutdown(enabled);
91
- ctx.ui.notify(`MTPLX will ${enabled ? "stop automatically" : "keep running"} when Pi exits.`, "info");
156
+ ctx.ui.notify(`Pi will ${enabled ? "stop its own MTPLX server" : "leave its own MTPLX server running"} when Pi exits.`, "info");
157
+ }
158
+ if (top.startsWith("Auto Start")) {
159
+ const choices = [
160
+ autoStart ? "Off" : "Off (current)",
161
+ autoStart ? "On (current)" : "On",
162
+ ];
163
+ const picked = await ctx.ui.select("MTPLX — autostart MTPLX server when loading an MTPLX model", choices, undefined);
164
+ if (!picked) return;
165
+ const enabled = picked.startsWith("On");
166
+ saveAutoStart(enabled);
167
+ ctx.ui.notify(`MTPLX will ${enabled ? "autostart" : "not autostart"} when loading an MTPLX model.`, "info");
92
168
  }
93
169
  if (top.startsWith("SSD Session Cache")) {
94
170
  const choices = [
@@ -120,13 +196,36 @@ export default function mtplxAutostart(pi: ExtensionAPI): void {
120
196
  },
121
197
  });
122
198
 
123
- pi.on("before_agent_start", async (_event, ctx) => {
199
+ // This must run in `input`, not `before_agent_start`: Pi reports errors
200
+ // from before_agent_start but still sends the prompt to the provider.
201
+ pi.on("input", async (_event, ctx) => {
124
202
  if (!ctx.model || !isMtplxModel(ctx.model)) return;
203
+ const autoStart = loadAutoStart();
125
204
  try {
126
- await acquire(ctx.model.id);
205
+ const server = autoStart
206
+ ? await acquire(ctx.model.id)
207
+ : await validateServer(ctx.model.id);
208
+ const fanSync = server.alreadyRunning ? syncFanModeFromRunningServer(server.fanMode) : undefined;
209
+ ctx.ui.setStatus("mtplx", `MTPLX: ${server.model}`);
210
+ if (server.alreadyRunning) {
211
+ const message = autoStart
212
+ ? `MTPLX is already running at ${server.endpoint.baseUrl}, serving ${JSON.stringify(server.model)}. Auto Start will not start MTPLX. The model selected in Pi matches; proceeding.`
213
+ : `MTPLX is available at ${server.endpoint.baseUrl}, serving ${JSON.stringify(server.model)}. It matches Pi's selected model; Auto Start is off, so Pi will proceed without managing the server.`;
214
+ const fanMessage = fanSync
215
+ ? ` Its fan curve is ${fanSync.current}, while Pi's saved default was ${fanSync.previous}; Pi left the server unchanged and updated its saved default to ${fanSync.current}.`
216
+ : "";
217
+ const notice = message + fanMessage;
218
+ if (notice !== lastServerNotice) ctx.ui.notify(notice, "info");
219
+ lastServerNotice = notice;
220
+ } else {
221
+ lastServerNotice = undefined;
222
+ }
127
223
  } catch (error) {
128
- throw new Error(`MTPLX request blocked: ${error instanceof Error ? error.message : String(error)}`);
224
+ lastServerNotice = undefined;
225
+ ctx.ui.notify(`MTPLX request blocked: ${error instanceof Error ? error.message : String(error)}`, "error");
226
+ return { action: "handled" };
129
227
  }
228
+ return { action: "continue" };
130
229
  });
131
230
 
132
231
  pi.on("agent_end", async (_event, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mtplx",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "Zero-config MTPLX integration for Pi coding agent",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -20,7 +20,7 @@ import { join } from "node:path";
20
20
  import { execFile } from "node:child_process";
21
21
  import { promisify } from "node:util";
22
22
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
23
- import { displayNameFromId } from "./utils.ts";
23
+ import { commandError, displayNameFromId } from "./utils.ts";
24
24
 
25
25
  const execFileAsync = promisify(execFile);
26
26
 
@@ -114,7 +114,11 @@ export async function listMtplxModels(): Promise<MtplxListedModel[]> {
114
114
  }
115
115
 
116
116
  export function listedIdentity(model: MtplxListedModel): string {
117
- return typeof model.repo_id === "string" && model.repo_id ? model.repo_id : typeof model.path === "string" ? model.path : "";
117
+ const repo = typeof model.repo_id === "string" ? model.repo_id : "";
118
+ // Forge artifacts have a bare directory name in repo_id. Quickstart treats
119
+ // that as a path relative to Pi's cwd, so use the installed path instead.
120
+ if (repo.includes("/")) return repo;
121
+ return typeof model.path === "string" && model.path ? model.path : repo;
118
122
  }
119
123
 
120
124
  /**
@@ -125,10 +129,41 @@ export function listedIdentity(model: MtplxListedModel): string {
125
129
  export async function servedModelId(ref: string): Promise<string> {
126
130
  const cached = servedIdCache.get(ref);
127
131
  if (cached) return cached;
128
- const { stdout } = await execFileAsync("mtplx", ["quickstart", "--model", ref, "--dry-run", "--json"], { timeout: 30_000 });
129
- const id = servedModelIdFromDryRun(JSON.parse(stdout) as unknown);
130
- servedIdCache.set(ref, id);
131
- return id;
132
+ try {
133
+ const { stdout } = await execFileAsync("mtplx", ["quickstart", "--model", ref, "--dry-run", "--json"], { timeout: 30_000 });
134
+ const id = servedModelIdFromDryRun(JSON.parse(stdout) as unknown);
135
+ servedIdCache.set(ref, id);
136
+ return id;
137
+ } catch (error) {
138
+ // MTPLX reports CLI errors as JSON on stdout, which execFile's default
139
+ // error.message omits. Keep that diagnostic visible in the Models menu.
140
+ const stdout = (error as { stdout?: string } | null)?.stdout;
141
+ let detail: unknown;
142
+ try {
143
+ const payload = JSON.parse(stdout ?? "") as { detail?: unknown; error?: unknown };
144
+ detail = payload.detail || payload.error;
145
+ } catch { /* Fall back to stderr / process error below. */ }
146
+ throw new Error(typeof detail === "string" ? detail : commandError(error), { cause: error });
147
+ }
148
+ }
149
+
150
+ /** A broken or unfinished artifact must not block management of other models. */
151
+ export async function resolveInstalledModels(
152
+ installed: readonly MtplxListedModel[],
153
+ resolveId: (ref: string) => Promise<string> = servedModelId,
154
+ ): Promise<{ resolved: ResolvedMtplxModel[]; failures: string[] }> {
155
+ const resolved: ResolvedMtplxModel[] = [];
156
+ const failures: string[] = [];
157
+ for (const model of installed) {
158
+ const ref = listedIdentity(model);
159
+ if (!ref) continue;
160
+ try {
161
+ resolved.push({ model, ref, id: await resolveId(ref) });
162
+ } catch (error) {
163
+ failures.push(`${ref}: ${error instanceof Error ? error.message : String(error)}`);
164
+ }
165
+ }
166
+ return { resolved, failures };
132
167
  }
133
168
 
134
169
  /** Parse the model id from MTPLX's machine-readable quickstart plan. */
@@ -281,17 +316,11 @@ export async function manageModels(ctx: ExtensionContext): Promise<boolean> {
281
316
  ctx.ui.notify("No MTPLX models found. Install one with `mtplx install`.", "warning");
282
317
  return false;
283
318
  }
284
- let resolved: ResolvedMtplxModel[];
285
- try {
286
- resolved = [];
287
- for (const model of installed) {
288
- const ref = listedIdentity(model);
289
- if (ref) resolved.push({ model, ref, id: await servedModelId(ref) });
290
- }
291
- } catch (error) {
292
- ctx.ui.notify(`Could not read MTPLX's served model id: ${error instanceof Error ? error.message : String(error)}`, "error");
293
- return false;
319
+ const { resolved, failures } = await resolveInstalledModels(installed);
320
+ if (failures.length > 0) {
321
+ ctx.ui.notify(`Skipped ${failures.length} MTPLX model(s) whose served ID could not be read:\n${failures.join("\n")}`, "warning");
294
322
  }
323
+ if (resolved.length === 0) return false;
295
324
  const migrated = migrateRegisteredModelIds(resolved);
296
325
  if (migrated.length > 0) {
297
326
  ctx.ui.notify(`Updated registered model IDs to MTPLX's canonical names. Restart Pi before selecting them.`, "info");
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Minimal HTTP client for the locally-running MTPLX server (OpenAI-compatible
3
- * endpoint on 127.0.0.1:8000). Only touches endpoints that MTPLX exposes:
2
+ * Minimal HTTP client for the configured MTPLX OpenAI-compatible endpoint.
3
+ * Only touches endpoints that MTPLX exposes:
4
4
  * GET /health
5
5
  * POST /v1/mtplx/thermal/fan_mode
6
6
  */
7
- import { HOST, PORT, type FanMode, loadFanMode, loadResolvedMtplxApiKey } from "./utils.ts";
7
+ import { type FanMode, type MtplxEndpoint, loadFanMode, loadMtplxEndpoint, loadResolvedMtplxApiKey } from "./utils.ts";
8
8
 
9
9
  export type Health = { ok: true; model: string; model_path?: string; fan_mode?: string };
10
10
  export type HealthProbe = { health: Health | undefined; authenticationRejected?: true };
@@ -33,12 +33,12 @@ export function authenticationFailureMessage(): string {
33
33
  return "MTPLX rejected Pi's API key. Update it via /mtplx → API Key.";
34
34
  }
35
35
 
36
- export async function healthProbe(): Promise<HealthProbe> {
36
+ export async function healthProbe(endpoint = loadMtplxEndpoint()): Promise<HealthProbe> {
37
37
  const controller = new AbortController();
38
38
  const timeout = setTimeout(() => controller.abort(), 1_500);
39
39
  try {
40
40
  const auth = authRequest();
41
- const response = await fetch(`http://${HOST}:${PORT}/health`, {
41
+ const response = await fetch(`${endpoint.origin}/health`, {
42
42
  headers: auth.headers,
43
43
  signal: controller.signal,
44
44
  });
@@ -63,8 +63,8 @@ export async function healthProbe(): Promise<HealthProbe> {
63
63
  }
64
64
  }
65
65
 
66
- export async function health(): Promise<Health | undefined> {
67
- return (await healthProbe()).health;
66
+ export async function health(endpoint?: MtplxEndpoint): Promise<Health | undefined> {
67
+ return (await healthProbe(endpoint)).health;
68
68
  }
69
69
 
70
70
  export async function setFanMode(): Promise<void> {
@@ -72,7 +72,7 @@ export async function setFanMode(): Promise<void> {
72
72
  const timeout = setTimeout(() => controller.abort(), 10_000);
73
73
  try {
74
74
  const auth = authRequest();
75
- const response = await fetch(`http://${HOST}:${PORT}/v1/mtplx/thermal/fan_mode`, {
75
+ const response = await fetch(`${loadMtplxEndpoint().origin}/v1/mtplx/thermal/fan_mode`, {
76
76
  method: "POST",
77
77
  headers: { "content-type": "application/json", ...auth.headers },
78
78
  body: JSON.stringify({ mode: fanMode }),
@@ -7,22 +7,18 @@
7
7
  * Pi's model id — a positive fingerprint that a given healthy server on
8
8
  * the port is one this extension owns.
9
9
  * - Cleanup is therefore precise: a server only gets shut down if the
10
- * extension spawned it in this session, or if `/health` positively
11
- * identifies it as an MTPLX server serving one of the extension's model
12
- * ids. A foreign service on the port is never touched, and a manually
13
- * started MTPLX server (no matching --model-id fingerprint) is never
14
- * killed — it is simply served from as-is.
10
+ * extension spawned it in this Pi session. A manually or separately
11
+ * started server is never killed, even when it serves a registered model.
15
12
  * - A server launched by this Pi session is stopped through its own detached
16
13
  * process group. This avoids relying on a second `mtplx` CLI invocation
17
- * during shutdown. A positively identified server from an earlier Pi
18
- * session still uses MTPLX's host-and-port stop command.
14
+ * during shutdown.
19
15
  */
20
16
  import { spawn, execFile } from "node:child_process";
21
17
  import { promisify } from "node:util";
22
18
  import type { ChildProcess } from "node:child_process";
23
19
  import { authenticationFailureMessage, getFanMode, health, healthProbe, setFanMode } from "./mtplx-client.ts";
24
20
  import { MTPLX_MODELS } from "./model-discovery.ts";
25
- import { HOST, PORT, READY_TIMEOUT_MS, POLL_MS, loadResolvedMtplxApiKey, loadSsdSessionCache, sleep, commandError, portIsOccupied } from "./utils.ts";
21
+ import { READY_TIMEOUT_MS, POLL_MS, loadMtplxEndpoint, loadResolvedMtplxApiKey, loadSsdSessionCache, sleep, commandError, portIsOccupied, type MtplxEndpoint } from "./utils.ts";
26
22
 
27
23
  const execFileAsync = promisify(execFile);
28
24
 
@@ -43,34 +39,30 @@ export function startupError(cause: Error): Error {
43
39
  }
44
40
 
45
41
  /**
46
- * Shut down the server on HOST:PORT — but only if we can positively identify
47
- * it as MTPLX (via /health). A healthy, non-MTPLX listener is an error, never
48
- * a kill target.
42
+ * Shut down the server at the configured endpoint, but only when this Pi
43
+ * session launched it. A healthy external listener is never a kill target.
49
44
  */
50
- export async function stopServer(): Promise<void> {
45
+ export async function stopServer(): Promise<boolean> {
46
+ const endpoint = loadMtplxEndpoint();
51
47
  const probe = await healthProbe();
52
48
  const current = probe.health;
53
49
  if (!current) {
54
- if (await portIsOccupied()) {
50
+ if (await portIsOccupied(endpoint)) {
55
51
  if (probe.authenticationRejected) throw new Error(authenticationFailureMessage());
56
- throw new Error(`MTPLX cannot use ${HOST}:${PORT}: another, non-MTPLX service is listening there.`);
52
+ throw new Error(`MTPLX cannot use ${endpoint.host}:${endpoint.port}: another, non-MTPLX service is listening there.`);
57
53
  }
58
54
  // Nothing listening (or not MTPLX): nothing to stop. Drop any stale handle.
59
55
  ownedChild = undefined;
60
- return;
56
+ return true;
61
57
  }
62
58
 
63
- // /health says an MTPLX server is answering here.
64
- if (!isOwnedByThisSession() && !currentModelIsOurs(current.model)) {
65
- // An MTPLX server the extension does not own (e.g. started manually by
66
- // the user). MTPLX exposes no reliable way to distinguish it from a
67
- // Pi-managed one at the port level beyond --model-id, so do not kill
68
- // it. If a Pi-owned one is still needed, the user can run /mtplx →
69
- // Toggle.
59
+ if (!isOwnedByThisSession()) {
60
+ // A separately started server can deliberately use the same model id as a
61
+ // Pi-managed one, so model identity is not ownership.
70
62
  console.warn(
71
- `pi-mtplx: leaving MTPLX server on ${HOST}:${PORT} (model ${JSON.stringify(current.model)}) untouched — not owned by this Pi session. Use /mtplx → Toggle to stop it.`,
63
+ `pi-mtplx: leaving MTPLX server on ${endpoint.host}:${endpoint.port} (model ${JSON.stringify(current.model)}) untouched — not owned by this Pi session.`,
72
64
  );
73
- return;
65
+ return false;
74
66
  }
75
67
 
76
68
  let stopError: unknown;
@@ -91,13 +83,10 @@ export async function stopServer(): Promise<void> {
91
83
  }
92
84
  }
93
85
 
94
- if (!isOwnedByThisSession() && await signalMtplxListener()) {
95
- signalledOwnedProcess = true;
96
- }
97
-
98
86
  if (!signalledOwnedProcess) {
99
87
  try {
100
- await execFileAsync("mtplx", ["stop", "--host", HOST, "--port", String(PORT), "--json"], { timeout: 20_000 });
88
+ if (!endpoint.isLoopback) throw new Error("MTPLX endpoint is remote; pi-mtplx will not stop a server it did not launch.");
89
+ await execFileAsync("mtplx", ["stop", "--host", endpoint.host, "--port", String(endpoint.port), "--json"], { timeout: 20_000 });
101
90
  stopError = undefined;
102
91
  } catch (error) {
103
92
  // Some MTPLX CLI failures occur after it has already signalled the server.
@@ -108,58 +97,47 @@ export async function stopServer(): Promise<void> {
108
97
 
109
98
  const deadline = Date.now() + 20_000;
110
99
  while (Date.now() < deadline) {
111
- if (!(await portIsOccupied())) {
100
+ if (!(await portIsOccupied(endpoint))) {
112
101
  ownedChild = undefined;
113
- return;
102
+ return true;
114
103
  }
115
104
  await sleep(POLL_MS);
116
105
  }
117
106
  if (stopError) throw new Error(`MTPLX shutdown failed: ${commandError(stopError)}`);
118
107
  if (signalledOwnedProcess) {
119
- throw new Error(`MTPLX shutdown timed out after signalling Pi's server process; ${HOST}:${PORT} is still occupied.`);
108
+ throw new Error(`MTPLX shutdown timed out after signalling Pi's server process; ${endpoint.host}:${endpoint.port} is still occupied.`);
120
109
  }
121
- throw new Error(`MTPLX shutdown timed out; ${HOST}:${PORT} is still occupied.`);
122
- }
123
-
124
- /** Positive ownership fingerprint: /health model id belongs to this extension's registry. */
125
- function currentModelIsOurs(model: string): boolean {
126
- return Object.keys(MTPLX_MODELS).includes(model);
110
+ throw new Error(`MTPLX shutdown timed out; ${endpoint.host}:${endpoint.port} is still occupied.`);
127
111
  }
128
112
 
129
- /**
130
- * Signal the process listening on the managed MTPLX port. This is used only
131
- * after an authenticated health check and a pi-mtplx model fingerprint have
132
- * proved the listener is one we are allowed to stop. It covers a server that
133
- * a previous Pi session left running, which has no child handle in this one.
134
- */
135
- async function signalMtplxListener(): Promise<boolean> {
136
- try {
137
- const { stdout } = await execFileAsync("lsof", ["-t", "-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN"], { timeout: 5_000 });
138
- const pids = stdout
139
- .split(/\s+/)
140
- .map(Number)
141
- .filter((pid) => Number.isSafeInteger(pid) && pid > 0);
142
- if (pids.length === 0) return false;
143
- for (const pid of pids) process.kill(pid, "SIGTERM");
144
- return true;
145
- } catch {
146
- return false;
147
- }
113
+ function modelMismatchError(runningModel: string, requestedModel: string, endpoint: MtplxEndpoint): Error {
114
+ return new Error(
115
+ `MTPLX is already running at ${endpoint.baseUrl}, serving ${JSON.stringify(runningModel)}, but Pi has ${JSON.stringify(requestedModel)} selected. ` +
116
+ `Pi will not replace the running server. Select ${JSON.stringify(runningModel)} in Pi, or restart MTPLX with \`--model-id ${requestedModel}\`, then try again.`,
117
+ );
148
118
  }
149
119
 
150
- function unmanagedServerModelError(runningModel: string, requestedModel: string): Error {
120
+ function autoStartDisabledUnavailableError(endpoint: MtplxEndpoint): Error {
151
121
  return new Error(
152
- `MTPLX is already running on ${HOST}:${PORT} with model ${JSON.stringify(runningModel)}, but Pi requested ${JSON.stringify(requestedModel)}. ` +
153
- `Pi will not replace this server because it is not managed by pi-mtplx. Stop it from the MTPLX app, or identify its listener with \`lsof -nP -iTCP:${PORT} -sTCP:LISTEN\` and run \`kill -TERM <PID>\`; then retry so Pi can start and manage the requested model.`,
122
+ `MTPLX is unavailable at ${endpoint.baseUrl}. Auto Start is off, so Pi will not start a server. ` +
123
+ "Start MTPLX at this endpoint or enable Auto Start, then try again.",
154
124
  );
155
125
  }
156
126
 
127
+ export type ReadyServer = {
128
+ endpoint: MtplxEndpoint;
129
+ model: string;
130
+ fanMode?: string;
131
+ alreadyRunning: boolean;
132
+ };
133
+
157
134
  /**
158
135
  * Spawn the MTPLX server for a registered model and wait until /health
159
136
  * confirms it serves exactly that model. The child is spawned detached so it
160
137
  * outlives Pi's event-loop teardown, and unref'd so it never blocks Pi exit.
161
138
  */
162
139
  export async function startServer(modelId: string): Promise<void> {
140
+ const endpoint = loadMtplxEndpoint();
163
141
  const configured = MTPLX_MODELS[modelId];
164
142
  if (!configured) {
165
143
  throw new Error(`MTPLX model ${JSON.stringify(modelId)} is not mapped to an installed MTPLX artifact. Update the pi-mtplx model registry after adding it to Pi.`);
@@ -174,9 +152,9 @@ export async function startServer(modelId: string): Promise<void> {
174
152
  "--fan-mode",
175
153
  getFanMode(),
176
154
  "--host",
177
- HOST,
155
+ endpoint.host,
178
156
  "--port",
179
- String(PORT),
157
+ String(endpoint.port),
180
158
  // Explicitly pass the user's persisted choice; this extension defaults it to on.
181
159
  "--ssd-session-cache",
182
160
  loadSsdSessionCache() ? "on" : "off",
@@ -213,37 +191,58 @@ export async function startServer(modelId: string): Promise<void> {
213
191
  }
214
192
 
215
193
  /**
216
- * Ensure the server on HOST:PORT serves `modelId`, transparently switching
217
- * models: stop the current (identified) server, then start the requested one.
194
+ * Confirm that the configured endpoint is healthy and serves `modelId`.
195
+ * Unlike `ensureServer`, this never starts, stops, or switches a server.
218
196
  */
219
- export async function ensureServer(modelId: string): Promise<void> {
220
- const probe = await healthProbe();
197
+ export async function validateServer(modelId: string, endpoint = loadMtplxEndpoint()): Promise<ReadyServer> {
198
+ const probe = await healthProbe(endpoint);
221
199
  const current = probe.health;
222
200
  if (current?.model === modelId) {
223
- if (current.fan_mode !== getFanMode()) await setFanMode();
224
- return;
201
+ return { endpoint, model: current.model, fanMode: current.fan_mode, alreadyRunning: true };
202
+ }
203
+ if (current) {
204
+ throw modelMismatchError(current.model, modelId, endpoint);
225
205
  }
206
+ if (probe.authenticationRejected) throw new Error(authenticationFailureMessage());
207
+ throw autoStartDisabledUnavailableError(endpoint);
208
+ }
209
+
210
+ /**
211
+ * Ensure the configured endpoint serves `modelId`. A healthy separately
212
+ * managed server is authoritative: Pi reuses a matching one and never
213
+ * replaces a different model. A server started by this Pi session can be
214
+ * stopped and switched to the selected model. Pi starts a server only when
215
+ * the endpoint is unavailable or after switching its own server.
216
+ */
217
+ export async function ensureServer(modelId: string, endpoint = loadMtplxEndpoint()): Promise<ReadyServer> {
218
+ const probe = await healthProbe(endpoint);
219
+ const current = probe.health;
226
220
  if (current) {
227
- if (!isOwnedByThisSession() && !currentModelIsOurs(current.model)) {
228
- throw unmanagedServerModelError(current.model, modelId);
221
+ if (current.model === modelId) {
222
+ return { endpoint, model: current.model, fanMode: current.fan_mode, alreadyRunning: true };
229
223
  }
224
+ if (!isOwnedByThisSession()) throw modelMismatchError(current.model, modelId, endpoint);
230
225
  await stopServer();
231
226
  }
232
- else if (await portIsOccupied()) {
227
+ if (await portIsOccupied(endpoint)) {
233
228
  if (probe.authenticationRejected) throw new Error(authenticationFailureMessage());
234
- throw new Error(`MTPLX cannot start because ${HOST}:${PORT} is occupied by a non-MTPLX service.`);
229
+ throw new Error(`MTPLX cannot start because ${endpoint.host}:${endpoint.port} is occupied by a non-MTPLX service.`);
230
+ }
231
+ if (!endpoint.isLoopback) {
232
+ throw new Error(`MTPLX endpoint ${endpoint.baseUrl} is unavailable. pi-mtplx only auto-starts loopback servers; start this endpoint separately.`);
235
233
  }
236
234
  await startServer(modelId);
237
235
  await setFanMode();
236
+ return { endpoint, model: modelId, fanMode: getFanMode(), alreadyRunning: false };
238
237
  }
239
238
 
240
- let transition: Promise<void> | undefined;
239
+ let transition: Promise<ReadyServer> | undefined;
241
240
  let transitionModel: string | undefined;
242
241
  let activeModel: string | undefined;
243
242
  let activeAgents = 0;
244
243
  let idleWaiters: Array<() => void> = [];
245
244
 
246
- export function ensureOnce(modelId: string): Promise<void> {
245
+ export function ensureOnce(modelId: string): Promise<ReadyServer> {
247
246
  if (transition) {
248
247
  if (transitionModel === modelId) return transition;
249
248
  return transition.then(() => ensureOnce(modelId));
@@ -256,7 +255,7 @@ export function ensureOnce(modelId: string): Promise<void> {
256
255
  return transition;
257
256
  }
258
257
 
259
- export async function acquire(modelId: string): Promise<void> {
258
+ export async function acquire(modelId: string): Promise<ReadyServer> {
260
259
  // Never replace a model while an already-admitted MTPLX agent is running.
261
260
  // Same-model requests share both this lease and any in-flight start Promise.
262
261
  while (activeAgents > 0 && activeModel !== modelId) {
@@ -268,7 +267,7 @@ export async function acquire(modelId: string): Promise<void> {
268
267
  activeModel = modelId;
269
268
  activeAgents += 1;
270
269
  try {
271
- await ensureOnce(modelId);
270
+ return await ensureOnce(modelId);
272
271
  } catch (error) {
273
272
  release();
274
273
  throw error;
package/src/utils.ts CHANGED
@@ -17,8 +17,49 @@ export type FanMode = "default" | "smart" | "max";
17
17
  export const FAN_MODES: readonly FanMode[] = ["default", "smart", "max"];
18
18
  export const DEFAULT_SSD_SESSION_CACHE = true;
19
19
  export const DEFAULT_AUTO_SHUTDOWN = true;
20
+ export const DEFAULT_AUTO_START = true;
20
21
  export const DEFAULT_MTPLX_API_KEY = "mtplx-local";
21
22
 
23
+ export type MtplxEndpoint = {
24
+ baseUrl: string;
25
+ origin: string;
26
+ host: string;
27
+ port: number;
28
+ isLoopback: boolean;
29
+ };
30
+
31
+ /** Resolve the endpoint from Pi's provider configuration, with a local default. */
32
+ export function mtplxEndpointFromBaseUrl(baseUrl: unknown): MtplxEndpoint {
33
+ const fallback = `http://${HOST}:${PORT}/v1`;
34
+ let url: URL;
35
+ try {
36
+ url = new URL(typeof baseUrl === "string" && baseUrl.trim() ? baseUrl.trim() : fallback);
37
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("unsupported protocol");
38
+ } catch {
39
+ url = new URL(fallback);
40
+ }
41
+ // URL.hostname retains brackets around IPv6 literals ("[::1]"). The
42
+ // MTPLX CLI and node:net both expect the bare address, and ::1 is local.
43
+ const host = url.hostname.replace(/^\[|\]$/g, "");
44
+ const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
45
+ return {
46
+ baseUrl: url.toString().replace(/\/$/, ""),
47
+ origin: url.origin,
48
+ host,
49
+ port,
50
+ isLoopback: host === "127.0.0.1" || host === "::1" || host.toLowerCase() === "localhost",
51
+ };
52
+ }
53
+
54
+ export function loadMtplxEndpoint(): MtplxEndpoint {
55
+ try {
56
+ const catalog = JSON.parse(readFileSync(PI_MODELS_FILE, "utf8")) as { providers?: { mtplx?: { baseUrl?: unknown } } };
57
+ return mtplxEndpointFromBaseUrl(catalog.providers?.mtplx?.baseUrl);
58
+ } catch {
59
+ return mtplxEndpointFromBaseUrl(undefined);
60
+ }
61
+ }
62
+
22
63
  // Fan mode ("fan curve") applied at autostart, live-updated from `/mtplx` while the
23
64
  // server runs. Persisted to disk so the choice survives Pi restarts: `fanMode` is a
24
65
  // module-level variable that would reset to "smart" on every fresh session, silently
@@ -74,6 +115,26 @@ export function saveSsdSessionCache(enabled: boolean): void {
74
115
  // separate from the server's own settings so users can deliberately leave a
75
116
  // loaded model available after Pi exits.
76
117
  export const AUTO_SHUTDOWN_FILE = join(homedir(), ".pi", "agent", "mtplx-auto-shutdown.json");
118
+ export const AUTO_START_FILE = join(homedir(), ".pi", "agent", "mtplx-auto-start.json");
119
+
120
+ export function loadAutoStart(): boolean {
121
+ try {
122
+ const parsed = JSON.parse(readFileSync(AUTO_START_FILE, "utf8")) as { enabled?: unknown };
123
+ if (typeof parsed.enabled === "boolean") return parsed.enabled;
124
+ } catch {
125
+ // missing or corrupt file → fall back to the default
126
+ }
127
+ return DEFAULT_AUTO_START;
128
+ }
129
+
130
+ export function saveAutoStart(enabled: boolean): void {
131
+ try {
132
+ mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
133
+ writeFileSync(AUTO_START_FILE, JSON.stringify({ enabled }, null, 2) + "\n");
134
+ } catch (error) {
135
+ console.error(`MTPLX could not persist auto-start preference: ${error instanceof Error ? error.message : String(error)}`);
136
+ }
137
+ }
77
138
 
78
139
  export function loadAutoShutdown(): boolean {
79
140
  try {
@@ -203,6 +264,37 @@ export function saveMtplxApiKey(apiKey: string): boolean {
203
264
  }
204
265
  }
205
266
 
267
+ /** Save the OpenAI-compatible base URL used by Pi and lifecycle health checks. */
268
+ export function saveMtplxEndpoint(baseUrl: string): boolean {
269
+ const normalized = baseUrl.trim();
270
+ if (!normalized) return false;
271
+ let endpoint: MtplxEndpoint;
272
+ try {
273
+ const url = new URL(normalized);
274
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
275
+ endpoint = mtplxEndpointFromBaseUrl(normalized);
276
+ } catch {
277
+ return false;
278
+ }
279
+ try {
280
+ const catalog = existsSync(PI_MODELS_FILE)
281
+ ? JSON.parse(readFileSync(PI_MODELS_FILE, "utf8")) as Record<string, unknown>
282
+ : {};
283
+ if (typeof catalog !== "object" || catalog === null || Array.isArray(catalog)) throw new Error("models.json is not an object");
284
+ const providers = (catalog.providers ??= {}) as Record<string, unknown>;
285
+ const provider = (providers.mtplx ?? { api: "openai-completions", authHeader: true }) as Record<string, unknown>;
286
+ if (typeof provider !== "object" || provider === null || Array.isArray(provider)) throw new Error("models.json mtplx provider is not an object");
287
+ provider.baseUrl = endpoint.baseUrl;
288
+ providers.mtplx = provider;
289
+ mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
290
+ writeFileSync(PI_MODELS_FILE, JSON.stringify(catalog, null, 2) + "\n");
291
+ return true;
292
+ } catch (error) {
293
+ console.error(`MTPLX could not update endpoint: ${error instanceof Error ? error.message : String(error)}`);
294
+ return false;
295
+ }
296
+ }
297
+
206
298
  export function displayNameFromId(id: string): string {
207
299
  return id
208
300
  .replace(/^mtplx-/, "")
@@ -227,9 +319,9 @@ export function commandError(error: unknown): string {
227
319
  return stderr || details.message || "unknown command failure";
228
320
  }
229
321
 
230
- export function portIsOccupied(): Promise<boolean> {
322
+ export function portIsOccupied(endpoint = loadMtplxEndpoint()): Promise<boolean> {
231
323
  return new Promise((resolve) => {
232
- const socket = createConnection({ host: HOST, port: PORT });
324
+ const socket = createConnection({ host: endpoint.host, port: endpoint.port });
233
325
  const done = (occupied: boolean) => {
234
326
  socket.destroy();
235
327
  resolve(occupied);