From 047c3ad1009aae658ec221f2f25ef8bc52484df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Wed, 4 Nov 2026 06:33:58 -0600 Subject: [PATCH 01/23] Integrate local model setup and gated workflow qualification --- README.md | 1 + .../e2e/fixtures/local-model-workflow.mjs | 34 ++ .../e2e/local-model-acceptance.spec.ts | 142 ++++++++ apps/desktop/e2e/local-models.spec.ts | 139 ++++++++ apps/desktop/e2e/native-drafts.spec.ts | 31 ++ apps/desktop/e2e/settings-layout.spec.ts | 2 +- apps/desktop/scripts/local-model-offline.mjs | 84 +++++ apps/desktop/src/main/commands.ts | 4 +- .../src/main/engine/execution-binding.ts | 11 + .../src/main/engine/local-models/execution.ts | 19 +- .../engine/local-models/redacting-sink.ts | 80 +++++ .../main/engine/local-models/run-policy.ts | 4 + .../src/main/engine/local-models/service.ts | 8 +- apps/desktop/src/main/engine/native-drafts.ts | 23 +- apps/desktop/src/main/engine/pending-sends.ts | 20 +- apps/desktop/src/main/engine/runner.ts | 47 ++- apps/desktop/src/main/index.ts | 8 +- apps/desktop/src/renderer/App.tsx | 77 ++++- .../src/renderer/components/Composer.tsx | 28 +- .../src/renderer/components/DraftView.tsx | 6 +- .../components/LocalModelsSettings.tsx | 158 +++++++++ .../src/renderer/components/ModelMenu.tsx | 104 +++++- .../renderer/components/SettingsDialog.tsx | 10 +- .../src/renderer/components/ThreadView.tsx | 6 +- apps/desktop/src/renderer/local-models.ts | 173 ++++++++++ .../src/renderer/pending-message-copy.ts | 14 + apps/desktop/src/renderer/styles.css | 17 + apps/desktop/src/shared/native-drafts.ts | 9 + apps/desktop/src/shared/types.ts | 3 +- .../desktop/test/local-codex-configuration.ts | 16 + apps/desktop/test/local-codex-native.test.ts | 307 +++++++++++++++++- .../desktop/test/local-global-config-guard.ts | 46 +++ apps/desktop/test/local-global-config.test.ts | 87 +++++ .../test/local-model-execution.test.ts | 89 ++++- .../test/local-model-live-connection.test.ts | 22 ++ apps/desktop/test/local-run-policy.test.ts | 19 +- apps/desktop/test/local-token-records.test.ts | 180 ++++++++++ .../test/local-workflow-fixture.test.ts | 60 ++++ apps/desktop/test/local-workflow-fixture.ts | 67 ++++ apps/desktop/test/native-drafts.test.ts | 87 +++++ .../desktop/test/pending-message-copy.test.ts | 27 ++ .../test/runner-durable-admission.test.ts | 28 ++ apps/desktop/test/runner-local-models.test.ts | 117 ++++++- apps/desktop/test/update-recovery.test.ts | 3 + docs/design/local-models-settings.md | 82 +++++ docs/local-models.md | 50 ++- docs/reviews/2026-11-01-lm5-fixtures.md | 177 ++++++++++ .../ac1-compaction.json | 83 +++++ .../ac3-queued-cancel.json | 13 + .../ac4-child-exit.json | 42 +++ .../2026-11-01-lm5-fixtures/ac4-dropped.json | 52 +++ .../2026-11-01-lm5-fixtures/ac4-oom-500.json | 52 +++ .../ac4-overflow-http.json | 52 +++ .../ac4-overflow-stream.json | 52 +++ .../2026-11-01-lm5-fixtures/ac4-stop.json | 32 ++ .../ac4-unavailable-503.json | 60 ++++ .../2026-11-01-lm5-fixtures/redirect.json | 60 ++++ .../reviews/2026-11-01-lm5-fixtures/wire.json | 106 ++++++ ...1-03-lm-global-config-and-token-records.md | 38 +++ docs/reviews/2026-11-03-lm5-val-decision.md | 67 ++++ docs/reviews/2026-11-03-lm7-evidence-map.md | 49 +++ .../2026-11-04-local-models-v1-workflow.md | 66 ++++ docs/status.md | 4 +- 63 files changed, 3472 insertions(+), 82 deletions(-) create mode 100644 apps/desktop/e2e/fixtures/local-model-workflow.mjs create mode 100644 apps/desktop/e2e/local-model-acceptance.spec.ts create mode 100644 apps/desktop/e2e/local-models.spec.ts create mode 100644 apps/desktop/scripts/local-model-offline.mjs create mode 100644 apps/desktop/src/main/engine/execution-binding.ts create mode 100644 apps/desktop/src/main/engine/local-models/redacting-sink.ts create mode 100644 apps/desktop/src/renderer/components/LocalModelsSettings.tsx create mode 100644 apps/desktop/src/renderer/local-models.ts create mode 100644 apps/desktop/src/renderer/pending-message-copy.ts create mode 100644 apps/desktop/test/local-codex-configuration.ts create mode 100644 apps/desktop/test/local-global-config-guard.ts create mode 100644 apps/desktop/test/local-global-config.test.ts create mode 100644 apps/desktop/test/local-model-live-connection.test.ts create mode 100644 apps/desktop/test/local-token-records.test.ts create mode 100644 apps/desktop/test/local-workflow-fixture.test.ts create mode 100644 apps/desktop/test/local-workflow-fixture.ts create mode 100644 apps/desktop/test/pending-message-copy.test.ts create mode 100644 docs/design/local-models-settings.md create mode 100644 docs/reviews/2026-11-01-lm5-fixtures.md create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/ac1-compaction.json create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/ac3-queued-cancel.json create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/ac4-child-exit.json create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/ac4-dropped.json create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/ac4-oom-500.json create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/ac4-overflow-http.json create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/ac4-overflow-stream.json create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/ac4-stop.json create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/ac4-unavailable-503.json create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/redirect.json create mode 100644 docs/reviews/2026-11-01-lm5-fixtures/wire.json create mode 100644 docs/reviews/2026-11-03-lm-global-config-and-token-records.md create mode 100644 docs/reviews/2026-11-03-lm5-val-decision.md create mode 100644 docs/reviews/2026-11-03-lm7-evidence-map.md create mode 100644 docs/reviews/2026-11-04-local-models-v1-workflow.md diff --git a/README.md b/README.md index 2884e9cf..6536cf99 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ modex/ explicit; connection tests identify the saved configuration, and failed saves retain your draft. CLI-only routing never falls back to HTTPS, existing sessions stay on their backend, and Auto stops if it cannot honor your reasoning-effort ceiling. +- **Local model connections (experimental).** Settings → Local models manages loopback connections and reads bounded model metadata without loading or downloading models. Local coding execution remains disabled while native qualification and product admission are open. See [setup and current limits](docs/local-models.md#local-models-v1-workflow-candidate). - **Launch from anywhere.** Opened from Finder or the Dock, Modex reads your login shell's PATH once at startup (interactive `zsh -ilc`, falling back to `-lc`), so `claude`, `codex`, and `jev` installed under nvm, Homebrew, or `~/.local/bin` are found exactly as in a terminal. diff --git a/apps/desktop/e2e/fixtures/local-model-workflow.mjs b/apps/desktop/e2e/fixtures/local-model-workflow.mjs new file mode 100644 index 00000000..35bb2517 --- /dev/null +++ b/apps/desktop/e2e/fixtures/local-model-workflow.mjs @@ -0,0 +1,34 @@ +// Inject a scripted app-server only in a disposable Electron acceptance home. +// The production index, preload, IPC, runner, admission policy and renderer stay in use. +// This fixture is never packaged and does not establish real-model qualification. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { ThreadRunner } from "../../dist/src/main/engine/runner.js"; +import { LocalExecution } from "../../dist/src/main/engine/local-models/execution.js"; +import { LocalConnectionStore } from "../../dist/src/main/engine/local-models/connections.js"; +import { SecretStore, testCipher } from "../../dist/src/main/engine/secrets.js"; +import { localWorkflowSpawn } from "../../dist/test/local-workflow-fixture.js"; + +const home = fs.realpathSync(process.env.MODEX_HOME ?? ""); +if (process.env.MODEX_E2E !== "1" || !home.startsWith(fs.realpathSync(os.tmpdir()) + path.sep) + || !path.basename(home).startsWith("modex-e2e-home-") + || JSON.parse(fs.readFileSync(path.join(home, "app/state.json"), "utf8")).settings.default_backend !== "mock") { + throw new Error("Local workflow acceptance requires a disposable mock home."); +} +const installed = new WeakSet(); +function install(runner) { + if (installed.has(runner)) return; + installed.add(runner); + const connections = new LocalConnectionStore(home, new SecretStore(home, testCipher)); + + runner.o.localExecution = new LocalExecution(home, connections, "fixture-codex", localWorkflowSpawn); + runner.backends.codex = { id: "codex", listModels: async () => [{ id: "fixture-cloud", label: "Fixture Cloud", isDefault: true }], + health: async () => ({ executable: "available", authentication: "authenticated", access: "available", detail: "Offline fixture" }), + dispose: async () => {}, runTurn: async () => { throw new Error("A local fixture reached the cloud backend"); } }; +} +for (const method of ["updateThread", "send", "sendDurably", "retry", "listModels", "backend"]) { + const original = ThreadRunner.prototype[method]; + ThreadRunner.prototype[method] = function (...args) { install(this); return original.apply(this, args); }; +} +await import("../../dist/src/main/index.js"); diff --git a/apps/desktop/e2e/local-model-acceptance.spec.ts b/apps/desktop/e2e/local-model-acceptance.spec.ts new file mode 100644 index 00000000..30808d63 --- /dev/null +++ b/apps/desktop/e2e/local-model-acceptance.spec.ts @@ -0,0 +1,142 @@ +import { test, expect, _electron as electron, type Page } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { appDir, seedHome, tid } from "./support.js"; + +// Full app composition with a scripted native transport injected by a temporary-home-only +// bootstrap. This exercises fixture integration, not product admission or a real model. +async function launchFixture(home: string) { + const app = await electron.launch({ args: [path.join(appDir, "e2e/fixtures/local-model-workflow.mjs")], cwd: appDir, + env: { ...process.env, MODEX_HOME: home, MODEX_E2E: "1", MODEX_E2E_NODE: process.execPath, MODEX_NO_LOGIN_PATH: "1", TYPESAFE_API_KEY: "", JEV_API_KEY: "" } }); + const page = await app.firstWindow(); + await page.waitForLoadState("domcontentloaded"); + await expect(tid(page, "composer-input")).toBeVisible(); + return { app, page }; +} + +async function runtime() { + const hits: string[] = []; + const server = http.createServer((req, res) => { + hits.push(`${req.method} ${req.url}`); + res.setHeader("content-type", "application/json"); + if (req.url === "/api/tags") res.end(JSON.stringify({ models: [{ name: "fixture-coder", details: { family: "qwen3moe" } }] })); + else if (req.url === "/api/ps") res.end(JSON.stringify({ models: [] })); + else if (req.url === "/api/show") res.end(JSON.stringify({ capabilities: ["completion", "tools"], model_info: { "qwen3moe.context_length": 65536 } })); + else { res.writeHead(404); res.end(); } + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + return { hits, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + stop: () => { server.closeAllConnections(); server.close(); } }; +} + +async function connect(page: Page, url: string) { + await tid(page, "open-settings").click(); + await page.getByRole("button", { name: "Local models", exact: true }).click(); + await tid(page, "local-runtime").selectOption("ollama"); + await tid(page, "local-label").fill("Ollama fixture"); + await tid(page, "local-url").fill(url); + await tid(page, "local-add").click(); + await expect(tid(page, "local-connection")).toHaveAttribute("data-state", "ready"); + await page.keyboard.press("Escape"); + await choose(page); +} + +async function choose(page: Page) { + await tid(page, "model-picker").click(); + await tid(page, "model-local-option").click(); + await tid(page, "model-local-context").getByRole("menuitemradio", { name: /65,536/ }).click(); + await expect(tid(page, "model-local-badge")).toHaveText("Ollama"); +} + +async function send(page: Page, text: string) { + await tid(page, "composer-input").fill(text); + await tid(page, "send").click(); +} + +test("local golden fixture: approved edit, independent check, diff, Stop, continuation, restart and server failure", async () => { + const r = await runtime(); + const { home, repo } = seedHome(); + fs.writeFileSync(path.join(repo, "greeting.txt"), "hello\n"); + fs.writeFileSync(path.join(repo, "fixture-check.cjs"), `const fs=require('node:fs'); const ok=fs.readFileSync('greeting.txt','utf8')===${JSON.stringify("hello, local\n")}; fs.appendFileSync('checks.txt',String(ok)+${JSON.stringify("\n")}); console.log(ok?'check passed':'check failed'); process.exit(ok?0:1);\n`); + + try { + let run = await launchFixture(home); + try { + await connect(run.page, r.url); + await expect(tid(run.page, "local-inference-host")).toContainText(r.url); + await send(run.page, "Edit the greeting and run its check."); + const approve = run.page.getByRole("button", { name: "Approve", exact: true }); + await expect(approve).toBeVisible(); + expect(fs.readFileSync(path.join(repo, "greeting.txt"), "utf8")).toBe("hello\n"); + await approve.click(); + await expect.poll(() => fs.existsSync(path.join(repo, "checks.txt")) && fs.readFileSync(path.join(repo, "checks.txt"), "utf8")).toBe("true\n"); + await expect(tid(run.page, "stop")).toHaveCount(0); + expect(fs.readFileSync(path.join(repo, "greeting.txt"), "utf8")).toBe("hello, local\n"); + const state = await run.page.evaluate(() => window.modex!.invoke("state:get", undefined)); + const threadId = state.threads.find(t => t.localTarget)?.id; + expect(threadId).toBeTruthy(); + const diff = await run.page.evaluate(id => window.modex!.invoke("changes:diff", { threadId: id!, path: "greeting.txt" }), threadId); + expect(JSON.stringify(diff)).toContain("hello, local"); + await run.page.keyboard.press("Control+Shift+G"); + await tid(run.page, "changes-file").filter({ hasText: "greeting.txt" }).getByRole("button").first().click(); + await expect(tid(run.page, "changes-diff")).toContainText("hello, local"); + + await send(run.page, "STOP_FIXTURE"); + await expect.poll(() => fs.existsSync(path.join(repo, "stop-started.txt"))).toBe(true); + await tid(run.page, "stop").click(); + await expect(tid(run.page, "stop")).toHaveCount(0); + expect(fs.existsSync(path.join(repo, "late-effect.txt"))).toBe(false); + await send(run.page, "Continue explicitly."); + await run.page.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.poll(() => fs.readFileSync(path.join(repo, "edits.txt"), "utf8")).toBe("edit\nedit\n"); + await expect(tid(run.page, "stop")).toHaveCount(0); + + await run.app.close(); run = await launchFixture(home); + expect(fs.readFileSync(path.join(repo, "edits.txt"), "utf8")).toBe("edit\nedit\n"); + await send(run.page, "An old handle must refuse after restart."); + await expect(run.page.getByText(/native session is gone/)).toBeVisible(); + expect(fs.readFileSync(path.join(repo, "edits.txt"), "utf8")).toBe("edit\nedit\n"); + await choose(run.page); + await send(run.page, "SERVER_FAILURE"); + await expect(run.page.getByText(/Local (fixture server unavailable|server reported)/).first()).toBeVisible(); + expect(fs.readFileSync(path.join(repo, "edits.txt"), "utf8")).toBe("edit\nedit\n"); + expect(r.hits.every(hit => /^(GET \/api\/(tags|ps)|POST \/api\/show)$/.test(hit))).toBe(true); + } finally { await run.app.close(); } + } finally { r.stop(); } +}); + +test("editing a connection invalidates its selected revision and cloud return confirms the exact model", async () => { + const r = await runtime(); const { home } = seedHome(); + try { + const run = await launchFixture(home); + try { + await connect(run.page, r.url); + await tid(run.page, "open-settings").click(); + await run.page.getByRole("button", { name: "Local models", exact: true }).click(); + await tid(run.page, "local-edit").click(); + await tid(run.page, "local-edit-form").getByLabel("Name", { exact: true }).fill("Renamed fixture"); + await tid(run.page, "local-edit-form").getByLabel("Replacement token (optional)").fill("fixture-token-revision-2"); + await tid(run.page, "local-edit-form").getByRole("button", { name: "Save connection" }).click(); + await expect(tid(run.page, "local-connection")).toContainText("Renamed fixture"); + await pageReady(run.page); + await send(run.page, "Stale configuration must send nothing."); + await expect(run.page.getByText(/changed or was deleted|destination changed/).first()).toBeVisible(); + await choose(run.page); + await tid(run.page, "model-picker").click(); + const dismissed = run.page.waitForEvent("dialog").then(async dialog => { + expect(dialog.message()).toContain("fixture-cloud"); await dialog.dismiss(); + }); + await tid(run.page, "model-local-clear").click(); await dismissed; + expect((await run.page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads.some(t => t.localTarget)).toBe(true); + await tid(run.page, "model-picker").click(); + run.page.once("dialog", dialog => void dialog.accept()); + await tid(run.page, "model-local-clear").click(); + await expect.poll(async () => (await run.page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads.some(t => t.localTarget)).toBe(false); + expect((await run.page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads[0]?.model).toBe("fixture-cloud"); + } finally { await run.app.close(); } + } finally { r.stop(); } +}); + +async function pageReady(page: Page) { await page.keyboard.press("Escape"); await expect(tid(page, "settings")).toHaveCount(0); } diff --git a/apps/desktop/e2e/local-models.spec.ts b/apps/desktop/e2e/local-models.spec.ts new file mode 100644 index 00000000..c5d34e08 --- /dev/null +++ b/apps/desktop/e2e/local-models.spec.ts @@ -0,0 +1,139 @@ +import { test, expect, type Page } from "@playwright/test"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { launch, seedHome, tid } from "./support.js"; + +// LM-4 (#381), LM Studio first: Settings → Local models and the model menu's local section, through the +// production preload, IPC, store and HTTP transport. Only the runtime is synthetic: an LM Studio-shaped +// server on loopback that records every request, so the spec can show that listing models never loads +// one, downloads one or asks it anything. +function lmStudio() { + const hits: string[] = []; + const server = http.createServer((req, res) => { + hits.push(`${req.method} ${req.url}`); + if (req.method !== "GET" || req.url !== "/api/v0/models") { res.writeHead(404); res.end(); return; } + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ data: [ + { id: "qwen/qwen3.8-27b", type: "llm", state: "not-loaded", max_context_length: 40960, capabilities: ["tool_use"] }, + { id: "text-embedding-nomic-embed-text-v1.5", type: "embeddings", state: "not-loaded" }, + ] })); + }); + return { + hits, + start: () => new Promise(resolve => server.listen(0, "127.0.0.1", () => resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`))), + stop: () => { server.closeAllConnections(); server.close(); }, + }; +} + +/** A loopback port nothing listens on. */ +async function closedPort(): Promise { + const probe = http.createServer(); + await new Promise(resolve => probe.listen(0, "127.0.0.1", resolve)); + const port = (probe.address() as AddressInfo).port; + await new Promise(resolve => probe.close(() => resolve())); + return `http://127.0.0.1:${port}`; +} + +async function openLocalSettings(page: Page) { + await tid(page, "open-settings").click(); + await page.getByRole("button", { name: "Local models" }).click(); + await expect(tid(page, "settings-local-section")).toBeVisible(); +} + +test("Settings adds an LM Studio server, lists its models without loading one, and removes it", async () => { + const runtime = lmStudio(); + const url = await runtime.start(); + const { home } = seedHome(); + const { app, page } = await launch(home); + try { + await openLocalSettings(page); + // No server yet: the form opens with LM Studio's defaults. + await expect(tid(page, "local-runtime")).toHaveValue("lmstudio"); + await expect(tid(page, "local-label")).toHaveValue("LM Studio"); + await expect(tid(page, "local-url")).toHaveValue("http://127.0.0.1:1234"); + await expect(tid(page, "settings-local-section")).toContainText("Checking models reads metadata only"); + await tid(page, "local-url").fill(url); + await tid(page, "local-add").click(); + + const card = tid(page, "local-connection"); + await expect(card).toHaveCount(1); + await expect(card).toHaveAttribute("data-state", "ready"); + await expect(tid(card, "local-connection-status")).toHaveText("Answered · 1 coding candidate"); + await expect(tid(card, "local-model")).toHaveText(["qwen/qwen3.8-27b · not loaded · server advertises 40K context · server advertises tool use · coding capability unverified"]); + await expect(tid(card, "local-unusable")).toContainText("text-embedding-nomic-embed-text-v1.5 (embedding model)"); + // Listing is one metadata request: no load, chat, completion or download endpoint was touched. + expect(runtime.hits).toEqual(["GET /api/v0/models"]); + + // A server that is not running is shown as such, with what to do. + await tid(page, "local-add-open").click(); + await tid(page, "local-label").fill("LM Studio (stopped)"); + await tid(page, "local-url").fill(await closedPort()); + await tid(page, "local-add").click(); + const stopped = tid(page, "local-connection").filter({ hasText: "LM Studio (stopped)" }); + await expect(stopped).toHaveAttribute("data-state", "unavailable"); + await expect(tid(stopped, "local-connection-reason")).toContainText(/Start the server|did not answer/); + + // Refresh lists again; Remove asks first and then deletes the profile in main. + await tid(card.first(), "local-refresh").click(); + await expect.poll(() => runtime.hits.length).toBe(2); + page.once("dialog", dialog => void dialog.accept()); + await tid(stopped, "local-remove").click(); + await expect(tid(page, "local-connection")).toHaveCount(1); + const listed = await page.evaluate(() => window.modex!.invoke("local-models:command", { op: "list" })); + expect(listed.op === "list" && listed.connections.map(c => c.label)).toEqual(["LM Studio"]); + expect(runtime.hits.every(hit => hit === "GET /api/v0/models")).toBe(true); + } finally { + await app.close(); + runtime.stop(); + } +}); + +test("the model menu lists LM Studio's models by server and offers context windows within the advertised maximum", async () => { + const runtime = lmStudio(); + const url = await runtime.start(); + const { home } = seedHome(); + const { app, page } = await launch(home); + try { + await page.evaluate(u => window.modex!.invoke("local-models:command", { op: "create", input: { label: "LM Studio", runtime: "lmstudio", url: u } }), url); + const threadsBefore = (await page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads.length; + await tid(page, "model-picker").click(); + const section = tid(page, "model-local-section"); + await expect(section).toBeVisible(); + const group = tid(section, "model-local-group"); + await expect(group).toContainText("LM Studio"); + await expect(group).toContainText("reachable"); + // Only the generative model is offered; the embedding model is not. + await expect(tid(group, "model-local-title")).toHaveText(["qwen/qwen3.8-27b"]); + await tid(group, "model-local-option").click(); + const context = tid(page, "model-local-context"); + // 40,960 advertised: 8K, 16K, 32K and the maximum itself, nothing larger. + await expect(context.getByRole("menuitemradio")).toHaveText(["8K", "16K", "32K", "40K"]); + + // A draft with text cannot become a local thread: nothing typed may have been meant for the cloud. + await page.keyboard.press("Escape"); + await tid(page, "composer-input").fill("hello"); + await tid(page, "model-picker").click(); + await expect(tid(page, "model-local-blocked")).toContainText("Choose a local model before typing"); + await expect(tid(page, "model-local-option")).toBeDisabled(); + await page.keyboard.press("Escape"); + await tid(page, "composer-input").fill(""); + + // This product candidate keeps execution unadmitted; selection must refuse and remove its empty thread. + await tid(page, "model-picker").click(); + await tid(page, "model-local-option").click(); + await tid(page, "model-local-context").getByRole("menuitemradio", { name: /32,768/ }).click(); + await expect(page.getByText("Local execution is unavailable.", { exact: true })).toBeVisible(); + await expect.poll(async () => (await page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads.length).toBe(threadsBefore); + expect((await page.evaluate(() => window.modex!.invoke("state:get", undefined))).threads.some(t => t.localTarget !== undefined)).toBe(false); + expect(runtime.hits.every(hit => hit === "GET /api/v0/models")).toBe(true); + + // Manage opens Settings on Local models. + await page.keyboard.press("Escape"); + await tid(page, "model-picker").click(); + await tid(page, "model-local-manage").click(); + await expect(tid(page, "settings-local-section")).toBeVisible(); + } finally { + await app.close(); + runtime.stop(); + } +}); diff --git a/apps/desktop/e2e/native-drafts.spec.ts b/apps/desktop/e2e/native-drafts.spec.ts index d80e293a..c040b343 100644 --- a/apps/desktop/e2e/native-drafts.spec.ts +++ b/apps/desktop/e2e/native-drafts.spec.ts @@ -173,3 +173,34 @@ test("retiring a project draft rejects a late native picker without copying its expect(fs.readdirSync(path.join(home, "attachments", "staging"))).toEqual([]); expect(fs.readFileSync(selected, "utf8")).toBe("selected source remains unchanged"); }); + +test("a refused legacy original can be copied to a new chat without sending or replacing newer typing", async () => { + await app.close(); + const stateFile = path.join(home, "app", "state.json"), state = JSON.parse(fs.readFileSync(stateFile, "utf8")); + const at = new Date().toISOString(), sourceId = "legacy-original-thread", id = "11111111-1111-4111-8111-111111111111"; + state.threads = [{ id: sourceId, projectId: "p1", title: "Legacy original", cwd: repo, backend: "mock", model: "mock", mode: "chat", plan: false, auto: false, status: "idle", createdAt: at, updatedAt: at }]; + fs.writeFileSync(stateFile, JSON.stringify(state)); + fs.writeFileSync(path.join(home, "app", "update-recovery.json"), JSON.stringify({ schema: 2, revision: 1, attempt: null, drafts: { threads: [{ threadId: sourceId, text: "newer typing stays here", attachments: [] }], projects: [], selectedThreadId: sourceId, draft: null } })); + const directory = path.join(home, "app", "pending-sends"); fs.mkdirSync(directory, { recursive: true }); + const rawText = " private legacy pending original "; + const pendingFile = path.join(directory, `${id}.json`); + fs.writeFileSync(pendingFile, JSON.stringify({ schema: 1, id, createdAt: at, source: { kind: "thread", threadId: sourceId }, owner: { kind: "thread", threadId: sourceId }, rawText, + configuration: { worktree: false, settings: { backend: "mock", model: "mock", mode: "chat", plan: false, auto: false } }, files: [], creation: null, target: null })); + const bytes = fs.readFileSync(pendingFile); + ({ app, page } = await launch(home)); + await expect(tid(page, "composer-input")).toHaveValue("newer typing stays here"); + await tid(page, "pending-message-retry").click(); + await expect(page.getByText(/destination changed or is unverified/)).toBeVisible(); + await tid(page, "pending-message-copy").click(); + await expect(tid(page, "composer-input")).toHaveValue(rawText); + const saved = await page.evaluate(() => window.modex!.invoke("state:get", undefined)); + const fresh = saved.threads.find(thread => thread.id !== "legacy-original-thread")!; + expect(fresh).toBeTruthy(); + expect(await page.evaluate(id => window.modex!.invoke("thread:items", { threadId: id }), fresh.id)).toHaveLength(0); + expect(fs.readFileSync(pendingFile)).toEqual(bytes); + await tid(page, "send").click(); + await expect.poll(async () => (await page.evaluate(id => window.modex!.invoke("thread:items", { threadId: id }), fresh.id)).filter(item => item.kind === "user").length).toBe(1); + expect(fs.readFileSync(pendingFile)).toEqual(bytes); + await page.locator(`[data-testid="thread-row"][data-thread-id="${sourceId}"] .thread-main`).click(); + await expect(tid(page, "composer-input")).toHaveValue("newer typing stays here"); +}); diff --git a/apps/desktop/e2e/settings-layout.spec.ts b/apps/desktop/e2e/settings-layout.spec.ts index 246fdaaf..ef7f174a 100644 --- a/apps/desktop/e2e/settings-layout.spec.ts +++ b/apps/desktop/e2e/settings-layout.spec.ts @@ -61,7 +61,7 @@ test("Settings sections and persistent actions stay reachable in a narrow, short // horizontally when needed, then Enter selects the section. const general = nav.getByRole("button", { name: "General" }); await general.focus(); - for (const label of ["Coding CLIs", "Auto routing", "Approval rules", "Advanced / demo"]) { + for (const label of ["Coding CLIs", "Local models", "Auto routing", "Approval rules", "Advanced / demo"]) { await page.keyboard.press("Tab"); const next = nav.getByRole("button", { name: label }); await expect(next).toBeFocused(); diff --git a/apps/desktop/scripts/local-model-offline.mjs b/apps/desktop/scripts/local-model-offline.mjs new file mode 100644 index 00000000..b7b7ebcd --- /dev/null +++ b/apps/desktop/scripts/local-model-offline.mjs @@ -0,0 +1,84 @@ +// Opt-in macOS qualification only. Starts its own Ollama on a fresh loopback port; +// never changes the person's server, downloads weights or edits global Codex settings. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { spawn, spawnSync, execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { once } from "node:events"; + +const script = fileURLToPath(import.meta.url); +const appDir = path.dirname(path.dirname(script)); +const inside = process.argv[2] === "--inside"; +const model = process.argv[inside ? 3 : 2]; +const destination = process.argv[inside ? 4 : 3]; +if (process.platform !== "darwin" || !model || !destination || !path.isAbsolute(destination)) { + throw new Error("Usage on macOS: node scripts/local-model-offline.mjs "); +} +const report = path.resolve(destination); +fs.mkdirSync(report, { recursive: true, mode: 0o700 }); +const policy = '(version 1)\n(allow default)\n(deny network*)\n(allow network-inbound (local ip "localhost:*"))\n(allow network-outbound (remote ip "localhost:*"))\n'; +if (!inside) { + const listing = await fetch("http://127.0.0.1:11434/api/tags", { redirect: "error", signal: AbortSignal.timeout(5000) }).then(r => r.json()); + const installed = listing.models?.find(m => m.name === model); + if (!installed?.digest) throw new Error("The exact approved model must already be installed. This canary never downloads one."); + fs.writeFileSync(path.join(report, "model.json"), JSON.stringify(installed, null, 2) + "\n"); + const profile = path.join(report, "loopback-only.sb"); fs.writeFileSync(profile, policy); + const result = spawnSync("/usr/bin/sandbox-exec", ["-f", profile, process.execPath, script, "--inside", model, report], { + cwd: appDir, env: process.env, encoding: "utf8", timeout: 1_300_000, maxBuffer: 8 * 1024 * 1024, + }); + fs.writeFileSync(path.join(report, "launcher.log"), `${result.stdout ?? ""}${result.stderr ?? ""}`); + if (result.status !== 0) { + if (!fs.existsSync(path.join(report, "offline.json"))) fs.writeFileSync(path.join(report, "offline.json"), JSON.stringify({ status: "failed-before-canary", exitCode: result.status, signal: result.signal, error: result.error?.message }, null, 2) + "\n"); + process.exitCode = 1; + } +} else { + const receipt = { status: "running", model, policy, platform: `${os.release()} ${os.arch()}`, hardware: execFileSync("sysctl", ["-n", "machdep.cpu.brand_string"], { encoding: "utf8" }).trim(), + sha: execFileSync("git", ["rev-parse", "HEAD"], { cwd: appDir, encoding: "utf8" }).trim(), contextTokens: 65536 }; + let server; + try { + receipt.externalProbe = await new Promise(resolve => { + const socket = net.connect({ host: "192.0.2.1", port: 443 }); + const settle = value => { socket.destroy(); resolve(value); }; + socket.once("connect", () => settle("connected")); + socket.once("error", error => settle(error.code ?? error.message)); + socket.setTimeout(2000, () => settle("timed-out")); + }); + if (!["EPERM", "EACCES"].includes(receipt.externalProbe)) throw new Error(`External denial was not demonstrated: ${receipt.externalProbe}`); + const reservation = net.createServer(); reservation.listen(0, "127.0.0.1"); await once(reservation, "listening"); + const port = reservation.address().port; await new Promise(resolve => reservation.close(resolve)); + receipt.url = `http://127.0.0.1:${port}`; + const log = fs.openSync(path.join(report, "ollama.log"), "w", 0o600); + server = spawn("ollama", ["serve"], { env: { ...process.env, OLLAMA_HOST: `127.0.0.1:${port}`, OLLAMA_MODELS: path.join(os.homedir(), ".ollama", "models"), OLLAMA_CONTEXT_LENGTH: "65536" }, stdio: ["ignore", log, log] }); + fs.closeSync(log); + const deadline = Date.now() + 20_000; + while (true) { + if (server.exitCode !== null) throw new Error(`Owned Ollama exited: ${server.exitCode}`); + try { + const listing = await fetch(`${receipt.url}/api/tags`, { redirect: "error", signal: AbortSignal.timeout(1000) }).then(r => r.json()); + if (!listing.models?.some(m => m.name === model)) throw new Error("Owned server cannot list the approved model"); + break; + } catch (error) { + if (Date.now() >= deadline) throw error; + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + receipt.ownedServerPid = server.pid; + const checked = spawnSync(process.execPath, ["--test", "--test-force-exit", "--test-name-pattern=AC1: a person", "dist/test/local-codex-native.test.js"], { + cwd: appDir, env: { ...process.env, MODEX_LM3_LIVE: "1", MODEX_LM3_URL: `${receipt.url}/v1`, MODEX_LM3_MODEL: model, MODEX_LM3_CONTEXT: "65536", MODEX_LM3_REPORT: path.join(report, "native") }, + encoding: "utf8", timeout: 1_220_000, maxBuffer: 8 * 1024 * 1024, + }); + fs.writeFileSync(path.join(report, "native.log"), `${checked.stdout ?? ""}${checked.stderr ?? ""}`); + receipt.exitCode = checked.status; receipt.status = checked.status === 0 ? "passed" : "failed"; + if (checked.status !== 0) process.exitCode = 1; + } catch (error) { receipt.status = "failed"; receipt.error = error.message; process.exitCode = 1; } + finally { + if (server && server.exitCode === null) { + const exited = once(server, "exit"); server.kill("SIGTERM"); + const force = setTimeout(() => server.kill("SIGKILL"), 5000); + await exited; clearTimeout(force); + } + fs.writeFileSync(path.join(report, "offline.json"), JSON.stringify(receipt, null, 2) + "\n"); + } +} diff --git a/apps/desktop/src/main/commands.ts b/apps/desktop/src/main/commands.ts index fc189683..bbc5c9c9 100644 --- a/apps/desktop/src/main/commands.ts +++ b/apps/desktop/src/main/commands.ts @@ -101,9 +101,9 @@ export function registerCommands(handle: RegisterCommand, { handle("task:save-note", ({ threadId, turnId }) => runner.saveTaskNote(threadId, turnId)); handle("task:note", ({ pageId }) => runner.taskNote(pageId)); handle("thread:followup", ({ threadId }) => runner.followUp(threadId)); - handle("thread:send", ({ threadId, text, attachments }) => startTurn(threadId, runner.send(threadId, text, attachments))); + handle("thread:send", ({ threadId, text, attachments }) => startTurn(threadId, runner.send(threadId, text, attachments, "browser"))); registerAttachmentCommands(handle, runner.attachments, { bytesOnly: true, reveal: revealAttachment ?? (() => { throw new Error("Attachment reveal is unavailable."); }) }); - handle("thread:retry", ({ threadId }) => startTurn(threadId, runner.retry(threadId))); + handle("thread:retry", ({ threadId }) => startTurn(threadId, runner.retry(threadId, "browser"))); handle("thread:stop", ({ threadId }) => runner.stop(threadId)); handle("thread:answer", ({ threadId, itemId, answer }) => runner.answer(threadId, itemId, answer)); handle("thread:answer-question", ({ threadId, itemId, answers }) => runner.answerQuestion(threadId, itemId, answers)); diff --git a/apps/desktop/src/main/engine/execution-binding.ts b/apps/desktop/src/main/engine/execution-binding.ts new file mode 100644 index 00000000..d90f857e --- /dev/null +++ b/apps/desktop/src/main/engine/execution-binding.ts @@ -0,0 +1,11 @@ +import type { Thread } from "../../shared/types.js"; +import type { PendingExecutionBinding } from "../../shared/native-drafts.js"; +import { threadTarget } from "../../shared/local-models.js"; + +/** Canonical main-owned destination snapshot; no credentials or resume handles. */ +export function executionBinding(thread: Pick, defaultAccount: string | null): PendingExecutionBinding { + const local = thread.localTarget !== undefined ? threadTarget(thread) : null; + return { schema: 1, backend: thread.backend, model: thread.model, + account: local || thread.backend !== "codex" ? null : thread.codexAccount ?? (thread.sessionHandle ? "cli" : defaultAccount), + localTarget: local?.kind === "local" ? local.target : null, localContextTokens: local ? thread.localContextTokens ?? null : null }; +} diff --git a/apps/desktop/src/main/engine/local-models/execution.ts b/apps/desktop/src/main/engine/local-models/execution.ts index f0df54f9..22744c17 100644 --- a/apps/desktop/src/main/engine/local-models/execution.ts +++ b/apps/desktop/src/main/engine/local-models/execution.ts @@ -8,6 +8,7 @@ import { modelKey, threadTarget, type LocalModelRef, type LocalThreadTarget } fr import type { LocalConnectionStore } from "./connections.js"; import { admitLocalConfiguration, type LocalConfigurationProvenance } from "./run-policy.js"; import { OLLAMA_CLOUD_NAME, type AdvertisedContext } from "./discovery.js"; +import { redactingSink, TOKEN_MASK } from "./redacting-sink.js"; /** * Runs a thread's turns on a local model through the Codex CLI (#379), the same `codex app-server` @@ -54,6 +55,19 @@ function refuseProjectConfiguration(cwd: string): void { } } +/** + * Codex words every HTTP 500 as a cloud service under load ("We're currently experiencing high demand"). From a + * loopback server that is not what happened: it is the person's own server failing, often out of memory. Codex's + * text stays in the detail; the turn's error says what is known (#380 AC4). + */ +function truthful(result: TurnResult): TurnResult { + if (result.status !== "failed") return result; + const detail = result.detail as { turnError?: { codexErrorInfo?: unknown }; errorNotification?: { codexErrorInfo?: unknown } } | undefined; + // A turn that completes with no error of its own reports the `error` notification's text instead (backends/codex.ts). + if ((detail?.turnError?.codexErrorInfo ?? detail?.errorNotification?.codexErrorInfo) !== "internalServerError") return result; + return { ...result, error: "The local server returned an internal error (HTTP 500). Check its log: it may have run out of memory or crashed while loading the model. Modex didn't retry the turn or switch models." }; +} + export class LocalExecution { /** The child owning each thread's native conversation, with the key it was started for. */ private readonly children = new Map(); @@ -125,6 +139,7 @@ export class LocalExecution { if (opts.model !== target.model.id || opts.cwd !== thread.cwd || opts.account) throw new Error("The local target changed. Select it again to start a fresh conversation."); if (opts.plan || opts.images || opts.pages || opts.knowledge || opts.attachments?.some(file => file.kind === "image")) throw new Error("Plan, images and Modex's note and knowledge tools are not qualified for local models yet."); const connection = this.connections.resolve(target.model.connection)!; + sink = redactingSink(sink, connection.token); // Every loopback alias of a port is the same server; separate servers sharing a port also wait on each other. release = await this.acquire(`loopback:${connection.endpoint.port}`, signal); this.validate(thread); // The profile may have changed while this turn waited. @@ -146,7 +161,7 @@ export class LocalExecution { this.children.set(thread.id, owned); } // Effort, fast mode and service tiers are cloud-model settings with no local qualification: none is sent. - return await owned.backend.runTurn(text, { ...opts, effort: undefined, fast: false }, sink, signal); + return truthful(await owned.backend.runTurn(text, { ...opts, effort: undefined, fast: false }, sink, signal)); } catch (error) { return signal.aborted ? { status: "interrupted" } : { status: "failed", error: (error as Error).message }; } finally { release?.(); } @@ -190,7 +205,7 @@ export class LocalExecution { ]; return new CodexBackend(this.executable, this.spawnImpl, { args: settings.flatMap(setting => ["-c", setting]), env, - redact: text => token ? text.split(token).join("[local token]") : text, + redact: text => token ? text.split(token).join(TOKEN_MASK) : text, local: { model, provider: PROVIDER_ID, // Before each thread open the child's effective configuration must be exactly this, from these flags alone (#380). admit: reads => admitted(admitLocalConfiguration(reads.config as Parameters[0], reads.requirements as Parameters[1], { provider: PROVIDER_ID, origin, contextTokens, tokenEnv: token ? TOKEN_ENV : null })) }, diff --git a/apps/desktop/src/main/engine/local-models/redacting-sink.ts b/apps/desktop/src/main/engine/local-models/redacting-sink.ts new file mode 100644 index 00000000..26fd2392 --- /dev/null +++ b/apps/desktop/src/main/engine/local-models/redacting-sink.ts @@ -0,0 +1,80 @@ +import type { TurnSink } from "../backends/types.js"; + +export const TOKEN_MASK = "[local token]"; + +/** How much of the end of `text` could still grow into `token`. */ +function heldBack(text: string, token: string): number { + for (let length = Math.min(token.length - 1, text.length); length > 0; length--) { + if (text.endsWith(token.slice(0, length))) return length; + } + return 0; +} + +/** + * CodexBackend redacts each line it reads, but a streamed token can cross two lines. This holds back the end of each + * stream while it could still become the token, so no renderer event or tool log ever holds it or part of its start. + * Whatever is held when an item ends unexpectedly is dropped: at most a piece of text that looked like the token's start. + */ +export function redactingSink(sink: TurnSink, token: string | null): TurnSink { + if (!token) return sink; + const streams = new Map(); + const push = (key: string, delta: string): string => { + const text = ((streams.get(key) ?? "") + delta).split(token).join(TOKEN_MASK); + const held = heldBack(text, token); + streams.set(key, text.slice(text.length - held)); + return text.slice(0, text.length - held); + }; + const end = (key: string): string => { const rest = streams.get(key) ?? ""; streams.delete(key); return rest; }; + return { + ...sink, + ...(sink.output ? { output: (native, chunk) => { const text = push(`output:${native.item}`, chunk); if (text) sink.output!(native, text); } } : {}), + observe(observation) { + switch (observation.type) { + case "message.delta": { + const text = push(`message:${observation.native.item}`, observation.text); + if (text) sink.observe({ ...observation, text }); + return; + } + case "reasoning.delta": { + const text = push(`reasoning:${observation.native.item}:${observation.representation}:${observation.index}`, observation.text); + if (text) sink.observe({ ...observation, text }); + return; + } + case "message.done": { + const rest = end(`message:${observation.native.item}`); + if (rest) sink.observe({ type: "message.delta", native: observation.native, text: rest }); + break; + } + case "reasoning.done": + for (const key of [...streams.keys()]) { + if (!key.startsWith(`reasoning:${observation.native.item}:`)) continue; + const [, , representation, index] = key.split(":") as [string, string, "summary" | "provider-text", string]; + const rest = end(key); + if (rest) sink.observe({ type: "reasoning.delta", native: observation.native, representation, index: Number(index), text: rest }); + } + break; + case "tool.update": { + // The whole output so far, rebuilt from deltas after CodexBackend's per-line redaction. + const output = observation.progress.output; + if (output !== undefined) { + const text = output.split(token).join(TOKEN_MASK); + sink.observe({ ...observation, progress: { ...observation.progress, output: text.slice(0, text.length - heldBack(text, token)) } }); + return; + } + break; + } + case "tool.done": { + const rest = end(`output:${observation.native.item}`); + if (rest) sink.output?.(observation.native, rest); + const output = observation.progress?.output; + if (output !== undefined) { + sink.observe({ ...observation, progress: { ...observation.progress, output: output.split(token).join(TOKEN_MASK) } }); + return; + } + break; + } + } + sink.observe(observation); + }, + }; +} diff --git a/apps/desktop/src/main/engine/local-models/run-policy.ts b/apps/desktop/src/main/engine/local-models/run-policy.ts index 0a774be5..468f3637 100644 --- a/apps/desktop/src/main/engine/local-models/run-policy.ts +++ b/apps/desktop/src/main/engine/local-models/run-policy.ts @@ -62,6 +62,8 @@ export interface LocalContextEvidence { /** Keys a layer other than Modex's session flags may not set: the inference destination, the model, its context and the tools reaching it. */ const GOVERNED = ["model", "model_provider", "model_providers", "openai_base_url", "oss_provider", "model_catalog_json", "model_context_window", "model_auto_compact_token_limit", + // Native compaction: what it sends the provider and when it runs (#380 AC1; key names from codex-cli 0.166.0). + "compact_prompt", "model_auto_compact_token_limit_scope", "model_post_turn_compact_threshold_percent", "model_async_compact_threshold_percent", "model_reasoning_summary", "model_reasoning_effort", "service_tier", "web_search", "features", "mcp_servers", "shell_environment_policy", "plugins", "hooks", "marketplaces", "profile", "profiles", "projects", "notify", "otel", "tools", "developer_instructions", "model_instructions_file", "agents", "cloud"]; const MANAGED = new Set(["mdm", "system", "enterpriseManaged", "legacyManagedConfigTomlFromFile", "legacyManagedConfigTomlFromMdm"]); @@ -131,6 +133,8 @@ export function admitLocalConfiguration(read: ConfigRead, requirements: Requirem want(`model_providers.${expected.provider}.requires_openai_auth`, provider.requires_openai_auth, false); want(`model_providers.${expected.provider}.env_key`, provider.env_key ?? null, expected.tokenEnv); want(`model_providers.${expected.provider}.request_max_retries`, provider.request_max_retries, 0); + // A dropped stream is otherwise re-sent whole: the turn reports the drop instead (#380 AC4). + want(`model_providers.${expected.provider}.stream_max_retries`, provider.stream_max_retries, 0); for (const name of ["experimental_bearer_token", "auth", "http_headers", "env_http_headers", "query_params"]) want(`model_providers.${expected.provider}.${name}`, provider[name] ?? null, null); want("model_context_window", config.model_context_window, expected.contextTokens); want("web_search", config.web_search, "disabled"); diff --git a/apps/desktop/src/main/engine/local-models/service.ts b/apps/desktop/src/main/engine/local-models/service.ts index cc6a45e3..5812fefa 100644 --- a/apps/desktop/src/main/engine/local-models/service.ts +++ b/apps/desktop/src/main/engine/local-models/service.ts @@ -12,7 +12,7 @@ export class LocalModelsService { readonly connections: LocalConnectionStore; readonly discovery: LocalModelDiscovery; - constructor(home: string, secrets: SecretStore, options: DiscoveryOptions = {}) { + constructor(home: string, secrets: SecretStore, private readonly options: DiscoveryOptions & { connectionInUse?: (id: string) => boolean } = {}) { this.connections = new LocalConnectionStore(home, secrets); this.discovery = new LocalModelDiscovery(this.connections, options); } @@ -24,11 +24,13 @@ export class LocalModelsService { case "list": return { op: "list", connections: this.connections.list() }; case "create": return { op: "create", connection: this.connections.create(request.input) }; case "update": { + this.requireIdle(request.id); const connection = this.connections.update(request.id, request.revision, request.patch); this.discovery.invalidate(request.id); return { op: "update", connection }; } case "delete": + this.requireIdle(request.id); this.connections.delete(request.id, request.revision); this.discovery.invalidate(request.id); return { op: "delete" }; @@ -38,4 +40,8 @@ export class LocalModelsService { return { op: "cancel" }; } } + + private requireIdle(id: string): void { + if (this.options.connectionInUse?.(id)) throw new Error("Stop the local turn before editing or removing its connection."); + } } diff --git a/apps/desktop/src/main/engine/native-drafts.ts b/apps/desktop/src/main/engine/native-drafts.ts index 8ad52f3e..9b4d7cc9 100644 --- a/apps/desktop/src/main/engine/native-drafts.ts +++ b/apps/desktop/src/main/engine/native-drafts.ts @@ -1,3 +1,5 @@ +import { executionBinding } from "./execution-binding.js"; +import type { PendingExecutionBinding } from "../../shared/native-drafts.js"; import type { Attachment, AttachmentDraftOwner } from "../../shared/attachments.js"; import type { ComposerRecovery, DraftSaveReceipt } from "../../shared/update-recovery.js"; import type { Thread } from "../../shared/types.js"; @@ -94,7 +96,7 @@ export class NativeDrafts { const matches = this.options.store.items(record.target.threadId).filter(item => item.id === record.id); const item = matches[0]; return matches.length === 1 && item?.kind === "user" && item.text === record.rawText && item.at === record.createdAt - && equal(item.attachments ?? [], record.files); + && equal(item.attachments ?? [], record.files) && (!record.execution || equal(item.execution, record.execution)); } private current(session: string): AttachmentDocument { if (!this.document || session !== this.session) throw fail(); @@ -229,15 +231,28 @@ export class NativeDrafts { const document = this.current(session); this.documents.retire(this.documents.register(document, owner)); } + private execution(thread: Pick): PendingExecutionBinding { + return executionBinding(thread, thread.localTarget === undefined && thread.backend === "codex" + ? this.options.runner.backend("codex")?.identity?.() ?? null : null); + } + private assertExecution(record: PendingSendRecord, thread: Thread): void { + // Legacy composer settings may describe the selection before local was + // chosen. They cannot prove the original's destination, on any backend. + const same = record.execution && equal(record.execution, this.execution(thread)); + if (!same) throw new Error("Saved message destination changed or is unverified. The original is preserved; use Copy text to new chat, review its model, then send explicitly."); + } begin(session: string, input: PendingSendInput): { id: string } { this.assertEditing(); const document = this.current(session); + const existing = input?.source?.kind === "thread" ? this.options.store.thread(input.source.threadId) : undefined; + const destination = existing ?? input?.configuration?.settings; + if (!destination) throw fail(); const pending = this.pending.begin(input, record => { if (this.current(session) !== document || !this.ownerExists(record.source)) return false; for (const other of this.records()) if (key(other.owner) === key(record.source) || other.files.some(file => record.files.some(selected => selected.id === file.id))) return false; return record.files.every(file => this.documents.validate(document, record.source, file)); - }); + }, this.execution(destination)); if (!this.documents.refreshRetention()) throw fail(); return { id: pending.value!.id }; } @@ -253,6 +268,7 @@ export class NativeDrafts { thread = await this.options.runner.createThreadForPending(record.source.projectId, record.creation, record.configuration); } else thread = this.options.store.thread(record.source.threadId); if (this.current(session) !== document || !thread) throw fail(); + this.assertExecution(record, thread); const target = { threadId: thread.id, projectId: thread.projectId, createdAt: thread.createdAt }; if (!this.proveTarget(target)) throw fail(); this.pending.bind(intentId, () => target); @@ -302,6 +318,9 @@ export class NativeDrafts { const document = this.current(session), record = this.requirePending(intentId); if (!this.transferred.has(intentId) || !record.target || record.owner.kind !== "thread" || !this.proveTarget(record.target) || record.files.some(file => !this.documents.validate(document, record.owner, file))) throw fail(); + const thread = this.options.store.thread(record.target.threadId); + if (!thread) throw fail(); + this.assertExecution(record, thread); await this.options.runner.sendDurably(record.target.threadId, record.rawText, record.files, { id: record.id, at: record.createdAt, committed: () => { if (this.current(session) !== document) throw fail(); diff --git a/apps/desktop/src/main/engine/pending-sends.ts b/apps/desktop/src/main/engine/pending-sends.ts index 1ea023d9..d8329fd3 100644 --- a/apps/desktop/src/main/engine/pending-sends.ts +++ b/apps/desktop/src/main/engine/pending-sends.ts @@ -1,3 +1,6 @@ +import { threadTarget } from "../../shared/local-models.js"; +import { isBackendId } from "../../shared/types.js"; +import type { PendingExecutionBinding } from "../../shared/native-drafts.js"; import fs from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; @@ -38,7 +41,18 @@ function validateTarget(value: unknown): asserts value is PendingTarget { if (!id(value.threadId) || !id(value.projectId)) throw fail(); date(value.createdAt); } function validateRecord(value: unknown): asserts value is PendingSendRecord { - shape(value, ["schema", "id", "createdAt", "source", "owner", "rawText", "configuration", "files", "creation", "target"]); + const keys = ["schema", "id", "createdAt", "source", "owner", "rawText", "configuration", "files", "creation", "target"]; + if (value && typeof value === "object" && Object.hasOwn(value, "execution")) keys.push("execution"); + shape(value, keys); + if (Object.hasOwn(value, "execution")) { + const binding = value.execution; + shape(binding, ["schema", "backend", "model", "account", "localTarget", "localContextTokens"]); + if (binding.schema !== 1 || !isBackendId(binding.backend) || typeof binding.model !== "string" || binding.model.length > 512 || (binding.account !== null && (typeof binding.account !== "string" || binding.account.length > 256))) throw fail(); + if (binding.localTarget !== null) { + const parsed = threadTarget({ backend: binding.backend as PendingExecutionBinding["backend"], localTarget: binding.localTarget }); + if (parsed.kind !== "local" || parsed.target.model.id !== binding.model || binding.account !== null || !Number.isSafeInteger(binding.localContextTokens) || (binding.localContextTokens as number) < 4096 || (binding.localContextTokens as number) > 262144) throw fail(); + } else if (binding.localContextTokens !== null) throw fail(); + } if (value.schema !== 1 || !uuid(value.id) || typeof value.rawText !== "string") throw fail(); date(value.createdAt); owner(value.source); owner(value.owner); if (value.rawText.length > PENDING_TEXT_CHARACTERS) throw fail(); @@ -136,12 +150,12 @@ export class PendingSends { this.file(id).checkpoint(expected); }); } - begin(input: unknown, authorize: (record: NonNullable) => boolean): PendingSnapshot { + begin(input: unknown, authorize: (record: NonNullable) => boolean, execution?: PendingExecutionBinding): PendingSnapshot { return this.operation(() => { const copied: unknown = parseRecoveryJSON(recoveryJSON(input, PENDING_RECORD_BYTES), PENDING_RECORD_BYTES); shape(copied, ["source", "rawText", "configuration", "files"]); owner(copied.source); const id = randomUUID(), createdAt = new Date().toISOString(); - const record = copyRecord({ schema: 1, id, createdAt, ...copied, owner: copied.source, + const record = copyRecord({ schema: 1, id, createdAt, ...copied, ...(execution ? { execution } : {}), owner: copied.source, creation: copied.source.kind === "project" ? { threadId: randomUUID(), createdAt } : null, target: null }); const admitted = JSON.parse(recoveryJSON(record, PENDING_RECORD_BYTES)) as PendingSendRecord; const freeze = (value: unknown): void => { if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); } }; diff --git a/apps/desktop/src/main/engine/runner.ts b/apps/desktop/src/main/engine/runner.ts index c6d6e0c9..517face3 100644 --- a/apps/desktop/src/main/engine/runner.ts +++ b/apps/desktop/src/main/engine/runner.ts @@ -1,3 +1,4 @@ +import { executionBinding } from "./execution-binding.js"; import { resolveCli } from "./cli-path.js"; import type { LocalExecution } from "./local-models/execution.js"; import { localOnly } from "./local-models/run-policy.js"; @@ -183,6 +184,11 @@ export class ThreadRunner { return [...this.live].some(([threadId, l]) => Boolean(l.abort) && l.backend === backend && threadAccount(this.o.store.thread(threadId)) === account); } + localConnectionInUse(connectionId: string): boolean { + return [...this.live].some(([threadId, live]) => Boolean(live.run || live.abort) + && this.o.store.thread(threadId)?.localTarget?.model.connection.id === connectionId); + } + async listModels(id: BackendId): Promise<{ models: ModelInfo[]; error?: string }> { if (this.disposing) return { models: [], error: "Modex is shutting down." }; if (this.restartHold) return { models: [], error: "Modex is preparing to restart." }; @@ -648,10 +654,17 @@ export class ThreadRunner { } /** Runs one user turn. Resolves when the thread is idle again (or errored). */ - async send(threadId: string, text: string, attachments: Attachment[] = []): Promise { + async send(threadId: string, text: string, attachments: Attachment[] = [], by = "desktop"): Promise { + this.assertLocalSender(threadId, by); return this.start(threadId, text, undefined, attachments); } + private assertLocalSender(threadId: string, by: string): void { + if (this.o.store.thread(threadId)?.localTarget !== undefined && by !== "desktop") { + throw new Error("Send local-model messages from this Mac. Phone and paired-desktop sends are unavailable for local threads."); + } + } + /** Native durable adapter only. Files have already crossed its journaled * ownership transaction. Existing generic send and explicit Retry stay separate. */ async sendDurably(threadId: string, rawText: string, attachments: readonly Readonly[], admission: DurableSendAdmission): Promise { @@ -668,10 +681,19 @@ export class ThreadRunner { * Runs the thread's last message again, in place: the transcript keeps its one user bubble and the * new attempt follows the failure card. Auto routing is not re-asked; the pick already applied. */ - async retry(threadId: string): Promise { + async retry(threadId: string, by = "desktop"): Promise { + this.assertLocalSender(threadId, by); this.assertThreadAvailable(threadId); const user = [...this.slot(threadId).items].reverse().find((item) => item.kind === "user"); if (!user || user.kind !== "user") throw new Error("Nothing to retry: this thread has no message yet."); + const current = this.o.store.thread(threadId)!; + // Old transcript items carry no destination proof. The current thread may + // already have switched away from local, so its current target is not proof. + if (!user.execution) throw new Error("Saved message destination is unverified. Copy the original into a new message to authorize this destination."); + if (current.localTarget !== undefined || user.execution.localTarget) { + const binding = executionBinding(current, null); + if (JSON.stringify(user.execution) !== JSON.stringify(binding)) throw new Error("Saved message destination changed. Copy the original into a new message to authorize this destination."); + } return this.start(threadId, user.text, user.id, user.attachments); } @@ -722,6 +744,11 @@ export class ThreadRunner { }; const retry = reuse !== undefined; + // Fix the account before durable admission and before any asynchronous + // auxiliary setup can observe a different active account. + const prospectiveCodexAccount = thread.localTarget === undefined && (thread.backend === "codex" || thread.auto) + ? this.backends.codex.identity?.() ?? null : null; + const admittedExecution = executionBinding(thread, prospectiveCodexAccount); // Staged files move into the thread's folder now, so a Retry (or a restart) still finds them. let draft: AttachmentDraft | undefined; if ((prepared || retry) && Array.isArray(attachments) && attachments.length) { @@ -747,7 +774,7 @@ export class ThreadRunner { // The Modex turn is the user message it answers: Retry runs the same turn again as a new attempt. const turnId = reuse ?? prepared?.id ?? newId(); if (!retry) { - const item: UserItem = { id: turnId, kind: "user", text: prepared?.rawText ?? text, + const item: UserItem = { id: turnId, kind: "user", text: prepared?.rawText ?? text, execution: admittedExecution, at: prepared?.at ?? new Date().toISOString(), ...(files.length ? { attachments: files } : {}) }; this.addItem(threadId, item, prepared ? () => { const receipt = Object.freeze({ ...item, ...(item.attachments ? { attachments: Object.freeze(item.attachments.map(file => Object.freeze({ ...file }))) } : {}) }); @@ -848,13 +875,14 @@ export class ThreadRunner { if (knowledge.warning) sink.notice("warn", knowledge.warning); if (abort.signal.aborted) { this.setStatus(threadId, "idle"); return; } } - // Codex and Claude Code bind a thread to the account its first turn ran on; older handles belong to the CLI's own login. + // Preserve Codex's admitted identity across asynchronous setup; Claude keeps its account-pool binding. const bound = threadAccount(thread); const account = !local && (thread.backend === "codex" || thread.backend === "claude") && backend.identity - ? bound ?? (thread.sessionHandle ? "cli" : backend.identity()) : undefined; + ? thread.backend === "codex" + ? admittedExecution.backend === "codex" ? admittedExecution.account ?? undefined : prospectiveCodexAccount ?? undefined + : bound ?? (thread.sessionHandle ? "cli" : backend.identity()) : undefined; if (account && !bound) this.o.store.updateThread(threadId, thread.backend === "codex" ? { codexAccount: account } : { claudeAccount: account }); const accountFor = (ran: Thread) => threadAccount(ran) ?? account; - // Read before the turn: the store's thread object takes the session id the moment the CLI reports one. const freshConversation = !thread.sessionHandle; const turnFiles: TurnAttachment[] = files.map((f) => { const file = this.attachments.resolve(f.rel); @@ -1209,8 +1237,9 @@ export class ThreadRunner { // External edits remain fenced until the renderer adopts the canonical state. if (this.restartHold?.kind !== "owner-removal" || (!opts.fromRouter && !opts.automaticTitle)) this.assertNotRestarting(); // Resume handles and account bindings are main-owned, even if an IPC caller sends extra keys. - // A local target is chosen on this Mac: a paired desktop's patch never carries one. - const localKeys = opts.by === "paired-desktop" ? [] : ["localTarget", "localContextTokens"]; + // A local target is chosen, changed and cleared on this Mac's own window only (#380 AC5): a paired desktop's, + // the phone's or an unnamed caller's patch never carries one. + const localKeys = opts.by === "desktop" ? ["localTarget", "localContextTokens"] : []; patch = Object.fromEntries(Object.entries(patch).filter(([key]) => ["mode", "model", "title", "backend", "plan", "effort", "auto", "approvals", ...localKeys].includes(key))) as ThreadPatch; if (patch.approvals !== undefined && !APPROVAL_POLICIES.includes(patch.approvals)) delete patch.approvals; @@ -1237,7 +1266,7 @@ export class ThreadRunner { const extra: Partial = current && patch.backend && patch.backend !== current.backend ? { sessionHandle: undefined, codexAccount: undefined, claudeAccount: undefined, lastTurn: undefined, model: this.o.store.settings.default_model[patch.backend] ?? "", effort: undefined } : {}; // So does any local target change: the old child's handle is dropped, and clearing the target returns to the default cloud model. const localReset: Partial = switchingLocal ? { sessionHandle: undefined, codexAccount: undefined, claudeAccount: undefined, lastTurn: undefined, - ...(patch.localTarget === null ? { localTarget: undefined, localContextTokens: undefined, model: this.o.store.settings.default_model.codex ?? "", effort: undefined } : {}) } : {}; + ...(patch.localTarget === null ? { localTarget: undefined, localContextTokens: undefined, model: patch.model ?? this.o.store.settings.default_model.codex ?? "", effort: patch.effort } : {}) } : {}; if (switchingLocal) { this.naming.get(threadId)?.abort(); this.naming.delete(threadId); const slot = this.slot(threadId); slot.followUp?.abort.abort(); slot.followUp = undefined; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index ee7d4163..5510ee58 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -222,7 +222,7 @@ const emit = (event: ThreadEvent): void => { }; // The e2e harness has no keychain to unlock; everything else goes through the OS keychain. const secrets = new SecretStore(home, process.env.MODEX_E2E ? testCipher : electronCipher(safeStorage)); -const localModels = new LocalModelsService(home, secrets); +const localModels = new LocalModelsService(home, secrets, { connectionInUse: id => runner.localConnectionInUse(id) }); // Local execution (LM-3, engine/local-models/execution.ts) is not wired into the product runner: Codex's redirect // handling and managed-policy precedence are unqualified (#379 AC6, LM-5 #380), so without the seam the runner // refuses every local selection and persisted target instead of starting a child or routing to a cloud backend. @@ -383,7 +383,7 @@ const companion = new CompanionServer(home, { if (currentDraftRecovery()) throw new Error("Draft recovery is required."); await pathReady; if (currentDraftRecovery()) throw new Error("Draft recovery is required."); - return startTurn(id, runner.send(id, text)); + return startTurn(id, runner.send(id, text, [], "phone")); }), answer: (id, itemId, answer, by) => { restartAdmission.assertOpen(); if (currentDraftRecovery()) throw new Error("Draft recovery is required."); return runner.answer(id, itemId, answer, by); }, answerQuestion: (id, itemId, answers, by) => { restartAdmission.assertOpen(); if (currentDraftRecovery()) throw new Error("Draft recovery is required."); return runner.answerQuestion(id, itemId, answers, by); }, @@ -833,7 +833,7 @@ async function startTurn(threadId: string, run: Promise): Promise<{ ok: bo accepting = false; return earlyError !== undefined ? { ok: false, error: earlyError } : { ok: true }; } -handle("thread:send", ({ threadId, text, attachments: files }) => startTurn(threadId, runner.send(threadId, text, files))); +handle("thread:send", ({ threadId, text, attachments: files }, event) => startTurn(threadId, runner.send(threadId, text, files, event ? "desktop" : "paired-desktop"))); registerAttachmentCommands(handle, attachments, { bytesOnly: false, reveal: (abs) => shell.showItemInFolder(abs) }); handle("attachments:pick", async (req) => { // Explicit renderer paths keep the strict whole-request guard. Only the native dialog's @@ -849,7 +849,7 @@ handle("attachments:pick", async (req) => { if (r.filePaths.length > remaining) result.errors.push(`Up to ${LIMITS.perTurn} attachments per message; ${r.filePaths.length - remaining} left out.`); return result; }, { localOnly: true }); -handle("thread:retry", ({ threadId }) => startTurn(threadId, runner.retry(threadId))); +handle("thread:retry", ({ threadId }, event) => startTurn(threadId, runner.retry(threadId, event ? "desktop" : "paired-desktop"))); handle("thread:stop", ({ threadId }) => runner.stop(threadId)); // Only this Mac's window calls with an IPC event; a paired desktop's command comes through the host without one. const answeredFrom = (event?: NativeIPCEvent) => event ? "desktop" : "paired-desktop"; diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 02138e94..65d18600 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -6,6 +6,7 @@ import { useComposerRecovery } from "./useComposerRecovery"; import { ComposerRestartPreparation } from "../shared/composer-restart-preparation"; import { ComposerOwnerRemoval } from "../shared/composer-owner-removal"; import { restartDrafts } from "./restart-drafts"; +import { copyPendingTextToNewThread } from "./pending-message-copy"; import { StreamerMode, useStreamerRedaction } from "./streamer"; import { redactSecrets } from "../shared/streamer-secrets"; import { RepositoryOverview } from "./components/RepositoryOverview"; @@ -23,6 +24,9 @@ import { Sidebar } from "./components/Sidebar"; import { ThreadView } from "./components/ThreadView"; import { Workspace } from "./components/Workspace"; import { SettingsDialog } from "./components/SettingsDialog"; +import type { LocalMenu } from "./components/ModelMenu"; +import { discoverAll, useLocalModels } from "./local-models"; +import type { LocalModelRef } from "../shared/local-models"; import { CompanionDialog } from "./components/CompanionDialog"; import { EmptyState } from "./components/EmptyState"; import { DraftView, type Draft } from "./components/DraftView"; @@ -189,6 +193,9 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE const [terminalCommands, setTerminalCommands] = useState>({}); const commandNonce = useRef(0); const [showSettings, setShowSettings] = useState(false); + /** Settings opened from the model menu lands on Local models. */ + const [settingsSection, setSettingsSection] = useState<"local" | undefined>(undefined); + const localModels = useLocalModels(); const [showCompanion, setShowCompanion] = useState(false); // Panel toggles survive a relaunch (localStorage, see shared/layout.ts). const [layout, setLayout] = useLayout(persistPreferences); @@ -516,6 +523,20 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE setDraftRevision((revision) => revision + 1); setDraft({ projectId, worktree, settings: draftDefaults(state) }); }; + const pendingProjectId = (record: PendingSendView) => { + const source = record.source; + return source.kind === "project" ? source.projectId : record.target?.projectId ?? state?.threads.find(row => row.id === source.threadId)?.projectId; + }; + const copySavedMessage = (record: PendingSendView) => act(async () => { + const projectId = pendingProjectId(record); + if (!projectId || !state || nativeHoldRef.current || nativeLoading || nativeRecoveryError || pendingRuns.current.has(record.id)) return; + const { backend, model, mode } = draftDefaults(state); + const copy = await copyPendingTextToNewThread(record, { projectId, backend, model, mode }, request => bridge.invoke("thread:create", request)); + await refresh(); + setUnsentFor(copy.thread.id, copy.text); + selectedRef.current = copy.thread.id; setSelected(copy.thread.id); + setDraft(null); setSurface("chat"); + }); // Nothing selected and no draft, but there are projects (first launch, last thread deleted): open a draft. useEffect(() => { if (!state || selected || creating || nativeLoading || nativeHold || nativeRecoveryError) return; @@ -543,7 +564,8 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE // Like the Codex App, a thread always has a concrete model the CLI knows: pick the CLI's // default (or the settings default) when the thread has none or names one the CLI no longer lists. useEffect(() => { - if (!thread) return; + // A local thread is pinned to its exact local model, which the CLI's own list never contains. + if (!thread || thread.localTarget !== undefined) return; const list = models[thread.backend]?.models; if (!list?.length) return; const current = list.find((m) => m.id === thread.model); @@ -755,6 +777,53 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE await bridge.invoke("thread:update", { threadId: thread.id, patch }); await refresh(); }); + const openLocalSettings = () => { setSettingsSection("local"); setShowSettings(true); }; + const localTarget = (model: LocalModelRef) => ({ schema: 1 as const, harness: "codex" as const, model }); + /** + * A draft has no thread to pin, so choosing a local model creates the thread now, pinned before its + * first message: nothing typed in it ever goes to a cloud model. Only an empty draft can do this, so no + * text or file has to move. If main refuses the target, the empty thread is removed again. + */ + const chooseLocalForDraft = (model: LocalModelRef, localContextTokens: number) => draft && act(async () => { + const d = draft; + const t = await bridge.invoke("thread:create", { projectId: d.projectId, worktree: d.worktree, backend: "codex", mode: d.settings.mode, auto: false }); + try { + await bridge.invoke("thread:update", { threadId: t.id, patch: { localTarget: localTarget(model), localContextTokens } }); + } catch (error) { + await bridge.invoke("thread:delete", { threadId: t.id, removeWorktree: true }).catch(() => undefined); + await refresh(); + throw error; + } + await refresh(); + if (activeDraftKeyRef.current === draftKey) { setDraft(null); setSelected(t.id); } + }); + const threadBusy = thread ? thread.status === "running" || thread.status === "waiting" : false; + const threadLocal: LocalMenu | undefined = thread ? { + entries: localModels.entries, + current: thread.localTarget, + currentContext: thread.localContextTokens, + blocked: threadBusy ? "Wait for the turn to finish before changing the model." : undefined, + onOpen: () => void discoverAll(15_000), + onChoose: (model, localContextTokens) => void updateThread({ localTarget: localTarget(model), localContextTokens }), + onClear: (requested) => { + const cloud = requested ?? models.codex?.models.find(m => m.id === state?.settings.default_model.codex) + ?? models.codex?.models.find(m => m.isDefault) ?? models.codex?.models[0]; + if (!cloud) { setError({ message: "Load the Codex cloud model list before returning to cloud models." }); return; } + if (window.confirm(`Use cloud model ${cloud.label} (${cloud.id})? Future messages will go to that model in a fresh conversation. Existing messages will stay in the transcript.`)) { + void updateThread({ localTarget: null, model: cloud.id, effort: cloud.defaultEffort }); + } + }, + onManage: openLocalSettings, + } : undefined; + const draftEmpty = draft ? !(unsent[draftKey] ?? "").trim() && attachmentDrafts.get(draftKey).attachments.length === 0 : false; + const draftLocal: LocalMenu | undefined = draft ? { + entries: localModels.entries, + blocked: creating ? "Wait for the thread to be created." : draftEmpty ? undefined : "Choose a local model before typing: clear this draft, or send it and start a new thread.", + onOpen: () => void discoverAll(15_000), + onChoose: (model, localContextTokens) => void chooseLocalForDraft(model, localContextTokens), + onClear: () => undefined, + onManage: openLocalSettings, + } : undefined; /** Archived threads leave the working list on every device; the selection stays so the row remains visible under Archived. */ const archiveThread = (t: Thread, archived: boolean) => act(async () => { await bridge.invoke("thread:archive", { threadId: t.id, archived }); @@ -970,6 +1039,8 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE
{privateLabel(record.rawText)}
{record.files.length > 0 &&

{record.files.map(file => privateLabel(file.name)).join(", ")}

} + +

Review the new chat's model before sending. Attachments stay with the saved original; attach them again if needed.

)} {surface === "chat" && thread && project && !showChanges && setLayout({ changes: true })} onOpenFolder={() => openPath(thread.cwd)} />} {thread && project ? ( @@ -995,6 +1066,7 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE modelsError={models[thread.backend]?.error} onRetryModels={() => retryModels(thread.backend)} openModelPickerRequest={modelPickerRequest} + local={threadLocal} inputRef={inputRef} branch={changes?.branch ?? undefined} /> @@ -1008,6 +1080,7 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE models={models[draft.settings.backend]?.models ?? []} modelsError={models[draft.settings.backend]?.error} onRetryModels={() => retryModels(draft.settings.backend)} + local={draftLocal} branch={draftBranch?.projectId === draft.projectId ? draftBranch.branch ?? undefined : undefined} onChange={value => { if (!nativeHoldRef.current && !nativeRecoveryError) setDraft(value); }} onSend={sendDraft} @@ -1044,7 +1117,7 @@ export function App({ persistPreferences = true, spaceEnabled = true, analyticsE )} - {showSettings && setShowSettings(false)} {...(analyticsEnabled ? { analyticsInRail: layout.analytics, onAnalyticsInRail: (shown: boolean) => setLayout({ analytics: shown }) } : {})} {...(codeReviewEnabled ? { codeReviewInRail: layout.codeReview, onCodeReviewInRail: (codeReview: boolean) => setLayout({ codeReview }) } : {})} />} + {showSettings && { setShowSettings(false); setSettingsSection(undefined); }} initialSection={settingsSection} {...(analyticsEnabled ? { analyticsInRail: layout.analytics, onAnalyticsInRail: (shown: boolean) => setLayout({ analytics: shown }) } : {})} {...(codeReviewEnabled ? { codeReviewInRail: layout.codeReview, onCodeReviewInRail: (codeReview: boolean) => setLayout({ codeReview }) } : {})} />} {showCompanion && setShowCompanion(false)} />} ) : {error?.message ?? "Loading…"}; diff --git a/apps/desktop/src/renderer/components/Composer.tsx b/apps/desktop/src/renderer/components/Composer.tsx index f49e05ff..d3499057 100644 --- a/apps/desktop/src/renderer/components/Composer.tsx +++ b/apps/desktop/src/renderer/components/Composer.tsx @@ -6,7 +6,7 @@ import type { AttachmentActions } from "../composer-attachments"; import type { Attachment } from "../../shared/attachments"; import { hasFiles } from "../attachments"; import { AttachmentChips } from "./Attachments"; -import { ModelMenu } from "./ModelMenu"; +import { ModelMenu, type LocalMenu } from "./ModelMenu"; import { Icon } from "./ui/Icon"; import { IconButton } from "./ui/IconButton"; import { ErrorNotice } from "./ui/ErrorNotice"; @@ -48,6 +48,8 @@ interface Props { /** Asks the CLI for its models again after `modelsError`. */ onRetryModels?: () => void; openModelPickerRequest?: number; + /** Local models beside the CLI's own (LM-4). A thread with a local target is pinned: no Plan, Auto or backend change. */ + local?: LocalMenu; /** A suggested next message for an idle thread; shown only while the box is empty. */ suggestion?: FollowUp | null; onBackend: (b: BackendId) => void; @@ -71,7 +73,7 @@ const MAX_INPUT = 180; * input and one control row — `+` (plan, auto, backend), the access pill (mode), any active chips, * the model picker, and a round send/stop button. */ -export function Composer({ text, onText: setText, attachments, attachmentActions, busy, context, backend, mode, approvals = "ask", onApprovals, plan, model, effort, auto, models, modelsError, onRetryModels, openModelPickerRequest, suggestion, onBackend, onMode, onPlan, onModel, onEffort, onAuto, onSend, onStop, inputRef }: Props) { +export function Composer({ text, onText: setText, attachments, attachmentActions, busy, context, backend, mode, approvals = "ask", onApprovals, plan, model, effort, auto, models, modelsError, onRetryModels, openModelPickerRequest, local, suggestion, onBackend, onMode, onPlan, onModel, onEffort, onAuto, onSend, onStop, inputRef }: Props) { const fallbackRef = useRef(null); const ta = inputRef ?? fallbackRef; const { copying, sending, warning: attachWarning, onFiles: attach, onPick: pick, onRemove, onDismissWarning } = attachmentActions; @@ -92,7 +94,8 @@ export function Composer({ text, onText: setText, attachments, attachmentActions ta.current?.focus({ preventScroll: true }); }, [busy]); - const canSend = Boolean(text.trim() || attachments.length); + const unsupportedImages = Boolean(local?.current && attachments.some(file => file.kind === "image")); + const canSend = Boolean(text.trim() || attachments.length) && !unsupportedImages; const submit = () => { if (!canSend || busy || copying || sending) return; void onSend(text); @@ -144,6 +147,8 @@ export function Composer({ text, onText: setText, attachments, attachmentActions
+ {local?.current &&

Runs through Codex at {local.entries.find(e => e.connection.id === local.current!.model.connection.id && e.connection.revision === local.current!.model.connection.revision)?.connection.origin ?? "an unavailable local server"} · connection revision {local.current.model.connection.revision}. A local address does not verify offline operation.

} + {unsupportedImages &&

Images are not qualified for local models. Remove the image or choose a cloud model before sending; the attachment is still here.

}
{canSuggest && ( )} - + {busy ? ( { stopFocused.current = document.activeElement === e.currentTarget; onStop(); }} /> ) : ( @@ -245,7 +250,7 @@ function ContextStrip({ context }: { context: ComposerContext }) { } /** `+`: plan mode, Auto routing, and which CLI runs the thread. Everything is disabled while a turn runs. */ -function PlusMenu({ busy, backend, plan, auto, onBackend, onPlan, onAuto, onAttach }: { onAttach: () => void; busy: boolean; backend: BackendId; plan: boolean; auto: boolean; onBackend: (b: BackendId) => void; onPlan: (p: boolean) => void; onAuto: (a: boolean) => void }) { +function PlusMenu({ busy, pinned, backend, plan, auto, onBackend, onPlan, onAuto, onAttach }: { onAttach: () => void; busy: boolean; pinned: boolean; backend: BackendId; plan: boolean; auto: boolean; onBackend: (b: BackendId) => void; onPlan: (p: boolean) => void; onAuto: (a: boolean) => void }) { const [open, setOpen] = useState(false); const trigger = useRef(null); const pick = (fn: () => void) => () => { fn(); setOpen(false); }; @@ -255,23 +260,24 @@ function PlusMenu({ busy, backend, plan, auto, onBackend, onPlan, onAuto, onAtta setOpen(false)} anchorRef={trigger} label="Composer options" placement="top-start" testId="composer-plus-menu" className="composer-options"> Attach files - onPlan(!plan))}> + {pinned &&
This thread uses a local model, so Plan, Auto and the backend are fixed. Choose cloud models in the model menu to change them.
} + onPlan(!plan))}> Plan mode ⇧⌘P - Investigate read-only and return a plan instead of editing. + {pinned ? "Plan mode is not qualified for local models." : "Investigate read-only and return a plan instead of editing."} - onAuto(!auto))}> + onAuto(!auto))}> Auto routing - A fast judge picks the model, effort and speed for each turn, within your limits. + {pinned ? "Local threads keep the model you explicitly selected." : "A fast judge picks the model, effort and speed for each turn, within your limits."} Backend
{BACKENDS.map((b) => ( - onBackend(b.id))}> + onBackend(b.id))}> {b.label} ))} diff --git a/apps/desktop/src/renderer/components/DraftView.tsx b/apps/desktop/src/renderer/components/DraftView.tsx index 1dc2b7ae..da223fb2 100644 --- a/apps/desktop/src/renderer/components/DraftView.tsx +++ b/apps/desktop/src/renderer/components/DraftView.tsx @@ -4,6 +4,7 @@ import type { BackendId, Mode, ModelInfo, Project } from "../../shared/types"; import type { AttachmentActions } from "../composer-attachments"; import type { Attachment } from "../../shared/attachments"; import { Composer } from "./Composer"; +import type { LocalMenu } from "./ModelMenu"; import { Icon } from "./ui/Icon"; import { Menu, MenuItem } from "./ui/Menu"; @@ -32,6 +33,8 @@ interface Props { models: ModelInfo[]; modelsError?: string; onRetryModels?: () => void; + /** Local models beside the CLI's own (LM-4). */ + local?: LocalMenu; /** The project checkout's current branch; shown for a Local draft (a worktree's branch does not exist yet). */ branch?: string; onChange: (draft: Draft) => void; @@ -47,7 +50,7 @@ interface Props { * The "What should we build in ?" screen: a live composer before any thread exists. * The project name is a picker; the context strip's Local/Worktree item is a toggle. */ -export function DraftView({ draft, projects, creating, models, modelsError, onRetryModels, branch, onChange, onSend, inputRef, text, onText, attachments, attachmentActions }: Props) { +export function DraftView({ draft, projects, creating, models, modelsError, onRetryModels, local, branch, onChange, onSend, inputRef, text, onText, attachments, attachmentActions }: Props) { const project = projects.find((p) => p.id === draft.projectId); const set = (patch: Partial) => onChange({ ...draft, settings: { ...draft.settings, ...patch } }); if (!project) return null; @@ -75,6 +78,7 @@ export function DraftView({ draft, projects, creating, models, modelsError, onRe auto={draft.settings.auto} models={models} modelsError={modelsError} + local={local} onRetryModels={onRetryModels} onBackend={(backend) => set({ backend, model: "", effort: undefined })} onMode={(mode) => set({ mode })} diff --git a/apps/desktop/src/renderer/components/LocalModelsSettings.tsx b/apps/desktop/src/renderer/components/LocalModelsSettings.tsx new file mode 100644 index 00000000..c1ea037c --- /dev/null +++ b/apps/desktop/src/renderer/components/LocalModelsSettings.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import type { LocalConnectionView, LocalModelDescriptor, LocalRuntime } from "../../shared/local-connections"; +import { FAILURE_WORDS, RUNTIME_DEFAULTS, RUNTIME_NAMES, addConnection, cancelDiscovery, discover, discoverAll, entryModels, removeConnection, tokensLabel, updateConnection, useLocalModels, type LocalEntry } from "../local-models"; +import { Icon } from "./ui/Icon"; + +/** + * Settings → Local models (LM-4, #381): connect a model server running on this Mac, list its models, + * and remove it. Every change applies at once through main, like an account sign-in, not on Save. + * Listing reads metadata only. Nothing here loads a model, downloads one or runs inference; a model is + * used only when a person picks it for a thread and sends a message. + */ +export function LocalModelsSettings() { + const local = useLocalModels(); + const [runtime, setRuntime] = useState("lmstudio"); + const [label, setLabel] = useState(RUNTIME_DEFAULTS.lmstudio.label); + const [url, setUrl] = useState(RUNTIME_DEFAULTS.lmstudio.url); + const [token, setToken] = useState(""); + const [manualModels, setManualModels] = useState(""); + const [busy, setBusy] = useState(false); + const [feedback, setFeedback] = useState(""); + const [adding, setAdding] = useState(false); + + useEffect(() => { void discoverAll(10_000); }, [local.loaded]); + useEffect(() => { if (local.loaded && !local.entries.length) setAdding(true); }, [local.loaded, local.entries.length]); + + const chooseRuntime = (next: LocalRuntime) => { + // Replace the defaults only while the person has not typed their own. + if (label === RUNTIME_DEFAULTS[runtime].label) setLabel(RUNTIME_DEFAULTS[next].label); + if (url === RUNTIME_DEFAULTS[runtime].url) setUrl(RUNTIME_DEFAULTS[next].url); + setRuntime(next); + }; + const run = async (fn: () => Promise) => { + setBusy(true); setFeedback(""); + try { setFeedback(await fn()); } catch (error) { setFeedback((error as Error).message); } finally { setBusy(false); } + }; + const add = () => void run(async () => { + await addConnection({ label: label.trim(), runtime, url: url.trim(), manualModels: manualModels.split("\n").filter(Boolean), ...(token ? { token } : {}) }); + setToken(""); setAdding(false); + return `${label.trim()} added. Checking its models…`; + }); + + return ( +
+
+
+

Local models

+

Connect Ollama, LM Studio or another compatible model server. Coding support is qualified separately for each configuration.

+
+ +
+

Changes here apply immediately. Checking models reads metadata only and does not load a model. Modex never downloads models. Your first message may make the server load one, which can take time and use a lot of memory. Local coding execution is unavailable until its qualification and admission gates pass.

+ {local.error &&

Local models are unavailable: {local.error}

} +
+ {local.entries.map(entry => )} +
+ {adding ? ( +
{ event.preventDefault(); add(); }}> +

Add a model server

+ + + + +